diff --git a/package.json b/package.json index 134e8bb..444d694 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,11 @@ "import": "./dist/web-react/index.js", "default": "./dist/web-react/index.js" }, + "./assistant": { + "types": "./dist/assistant/index.d.ts", + "import": "./dist/assistant/index.js", + "default": "./dist/assistant/index.js" + }, "./redact": { "types": "./dist/redact/index.d.ts", "import": "./dist/redact/index.js", diff --git a/playground/src/App.tsx b/playground/src/App.tsx index b992701..fb1ff75 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -3,6 +3,7 @@ import { BrandHeader } from '@tangle-network/agent-app/brand' import { CanvasRoute } from './routes/CanvasRoute' import { TimelineRoute } from './routes/TimelineRoute' import { ChatRoute } from './routes/ChatRoute' +import { ComposerRoute } from './routes/ComposerRoute' type ThemeName = 'light' | 'dark' @@ -10,6 +11,7 @@ const ROUTES = [ { path: '/canvas', label: 'Design' }, { path: '/timeline', label: 'Storyboard' }, { path: '/chat', label: 'Agent' }, + { path: '/composer', label: 'Composer' }, ] as const function applyTheme(theme: ThemeName) { @@ -85,6 +87,7 @@ export function App() { {path === '/canvas' && } {path === '/timeline' && } {path === '/chat' && } + {path === '/composer' && } ) diff --git a/playground/src/routes/ComposerRoute.tsx b/playground/src/routes/ComposerRoute.tsx new file mode 100644 index 0000000..dedd9c0 --- /dev/null +++ b/playground/src/routes/ComposerRoute.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react' +import { + ChatComposer, + ModelPicker, + type ComposerFile, +} from '@tangle-network/agent-app/web-react' +import { makeModels } from '../fixtures' + +/** + * Visual audit for ChatComposer: the shared message input across its states — + * model pill above the box, empty vs typed, streaming (Stop), the attach + + * pending-file surface, and the footer-placement variant. Token-only styling, so + * this is also the proof it themes from the shared tokens (light + dark) without + * any private --chat-* variables. + */ +function Demo({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ) +} + +export function ComposerRoute() { + const models = makeModels() + const [model, setModel] = useState(models[0]!.id) + const pill = ( + + ) + + const pendingFiles: ComposerFile[] = [ + { id: 'f1', name: 'q3-metrics.csv', kind: 'file', status: 'ready' }, + { id: 'f2', name: 'design-assets', kind: 'folder', fileCount: 12, status: 'uploading' }, + ] + + return ( +
+
+ + {}} + placeholder="Message the assistant…" + controls={pill} + /> + + + + {}} + placeholder="Message the assistant…" + controls={pill} + initialValue="Create a workflow that reviews opened PRs with a cheap but good model and posts the review as a comment." + /> + + + + {}} + onCancel={() => {}} + isStreaming + placeholder="Message the assistant…" + controls={pill} + /> + + + + {}} + placeholder="Ask the agent to inspect files…" + controls={pill} + onAttach={() => {}} + onAttachFolder={() => {}} + onRemoveFile={() => {}} + pendingFiles={pendingFiles} + /> + + + + {}} + placeholder="Message the assistant…" + controls={pill} + controlsPlacement="footer" + focusShortcut={false} + /> + +
+
+ ) +} diff --git a/playground/terminal-stub.js b/playground/terminal-stub.js new file mode 100644 index 0000000..75f290d --- /dev/null +++ b/playground/terminal-stub.js @@ -0,0 +1,6 @@ +// Dev-only stub for `@tangle-network/sandbox-ui/terminal`. web-react's barrel +// statically pulls workspace-terminal-panel, which lazy-imports the sandbox-ui +// terminal (and its @xterm deps). The playground never renders the terminal, so +// alias it here to keep Vite's optimizer from resolving @xterm. +export const TerminalView = () => null +export default {} diff --git a/playground/vite.config.ts b/playground/vite.config.ts index f457db7..47537e9 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { createRequire } from 'node:module' -import { dirname } from 'node:path' +import { dirname, resolve } from 'node:path' const require = createRequire(import.meta.url) // Resolve each peer to the PLAYGROUND's own copy. agent-app is linked via @@ -20,9 +20,17 @@ export default defineConfig({ plugins: [react()], server: { port: 4321 }, preview: { port: 4321 }, + // The terminal panel (a lazy, never-rendered import here) drags in @xterm, + // which the playground doesn't install. Exclude agent-app from pre-bundling + // and alias the terminal subpath to a stub so the optimizer doesn't choke. + optimizeDeps: { exclude: ['@tangle-network/agent-app'] }, resolve: { dedupe: ['react', 'react-dom', 'react-konva', 'konva'], alias: { + '@tangle-network/sandbox-ui/terminal': resolve(__dirname, 'terminal-stub.js'), + '@xterm/xterm': resolve(__dirname, 'terminal-stub.js'), + '@xterm/addon-fit': resolve(__dirname, 'terminal-stub.js'), + '@xterm/addon-web-links': resolve(__dirname, 'terminal-stub.js'), react: pkgDir('react'), 'react-dom': pkgDir('react-dom'), 'react-konva': pkgDir('react-konva'), diff --git a/src/assistant/AssistantDock.tsx b/src/assistant/AssistantDock.tsx new file mode 100644 index 0000000..06e987c --- /dev/null +++ b/src/assistant/AssistantDock.tsx @@ -0,0 +1,200 @@ +/** + * Persistent assistant entry point: a floating launcher that opens the chat + * panel as a right-side drawer (full-screen on small viewports), with focus + * trapping and a resizable width. Owns the chat state (via useAssistantChat) so + * the conversation survives the drawer closing — host-shell concerns (the user, + * navigation, balance, money formatting, the graph renderer, and the workflow- + * mutation signal) are injected. + * + * Mount inside an (transport) and an + * (open/seed state). + */ + +import { MessageSquare } from "lucide-react"; +import { + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, + useEffect, + useRef, +} from "react"; +import type { ToolDetailRenderers } from "../web-react"; +import { AssistantPanel } from "./AssistantPanel"; +import { useAssistantLauncher } from "./launcher"; +import { ResizeHandle } from "./ResizeHandle"; +import type { AssistantTranscriptView } from "./types"; +import { useAssistantChat } from "./useAssistantChat"; +import { useIsDesktop, usePanelWidth } from "./usePanelPrefs"; + +export interface AssistantDockProps { + /** The signed-in user this conversation belongs to (null when signed out). */ + userId: string | null; + /** Host navigation for error CTAs and connect targets. */ + navigate?: (path: string) => void; + balanceUsd?: number | null; + formatMoney?: (usd: number | null) => string; + /** Render workflow YAML as a node graph in a proposal card. */ + renderGraph?: (yaml: string) => ReactNode; + /** Called after a workflow-mutating tool is confirmed (host re-fetches its list). */ + onWorkflowMutation?: () => void; + /** Markdown renderer for assistant message content (plain text when absent). */ + renderMarkdown?: (content: string) => ReactNode; + /** Per-tool custom detail renderers for expanded tool cards in the transcript. */ + toolRenderers?: ToolDetailRenderers; + /** Swap the conversation rendering for a host-supplied renderer (see + * {@link AssistantPanelProps.renderTranscript}); the dock chrome, composer, + * transport, and proposal flow stay owned by the panel. */ + renderTranscript?: (view: AssistantTranscriptView) => ReactNode; +} + +/** Visible, focusable descendants of a container, in tab order. Visibility is + * checked via getClientRects rather than offsetParent, which is null for + * position:fixed elements and would wrongly exclude them. */ +function focusableWithin(container: HTMLElement): HTMLElement[] { + const selector = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.from(container.querySelectorAll(selector)).filter( + (el) => el.getClientRects().length > 0, + ); +} + +export function AssistantDock({ + userId, + navigate, + balanceUsd = null, + formatMoney, + renderGraph, + onWorkflowMutation, + renderMarkdown, + toolRenderers, + renderTranscript, +}: AssistantDockProps) { + const { open, openAssistant, closeAssistant } = useAssistantLauncher(); + const chat = useAssistantChat(userId, { onWorkflowMutation }); + + const isDesktop = useIsDesktop(); + const { width, maxWidth, setWidth, previewWidth, nudgeWidth } = + usePanelWidth(); + + const launcherRef = useRef(null); + const dialogRef = useRef(null); + const returnFocusRef = useRef(null); + const wasOpenRef = useRef(false); + + // Close on Escape. + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeAssistant(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, closeAssistant]); + + // Move focus into the dialog on open; restore it to the opener on close. + useEffect(() => { + if (open) { + if (!wasOpenRef.current && !returnFocusRef.current) { + returnFocusRef.current = document.activeElement as HTMLElement | null; + } + wasOpenRef.current = true; + const el = dialogRef.current; + if (el) (focusableWithin(el)[0] ?? el).focus(); + } else if (wasOpenRef.current) { + wasOpenRef.current = false; + const target = returnFocusRef.current?.isConnected + ? returnFocusRef.current + : launcherRef.current; + target?.focus(); + returnFocusRef.current = null; + } + }, [open]); + + const openDialog = () => { + returnFocusRef.current = document.activeElement as HTMLElement | null; + openAssistant(); + }; + + if (!open) { + return ( + + ); + } + + // Keep Tab focus within the dialog while it's open. + const trapTab = (e: ReactKeyboardEvent) => { + if (e.key !== "Tab") return; + const dialog = dialogRef.current; + if (!dialog) return; + const focusables = focusableWithin(dialog); + if (focusables.length === 0) { + e.preventDefault(); + dialog.focus(); + return; + } + // Non-empty here (length === 0 returned above); the index access is safe. + const first = focusables[0]!; + const last = focusables[focusables.length - 1]!; + const active = document.activeElement; + const inside = active instanceof Node && dialog.contains(active); + if (e.shiftKey) { + if (!inside || active === first || active === dialog) { + e.preventDefault(); + last.focus(); + } + } else if (!inside || active === last) { + e.preventDefault(); + first.focus(); + } + }; + + return ( + <> +