From 5f08fb20f7f5c89c6ee01cabcdf48e809cda678c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 30 Jun 2026 19:08:39 -0600 Subject: [PATCH] fix(web-react): keep router chat sandbox-free --- README.md | 4 +- docs/product-surfaces.md | 2 +- package.json | 5 + src/composer/index.ts | 11 +- src/web-react/chat-composer.tsx | 443 ++++++++++++++---- src/web-react/index.tsx | 1 - src/web-react/terminal.ts | 2 + .../web-react/router-safe-entrypoint.test.ts | 26 + tsup.config.ts | 1 + 9 files changed, 402 insertions(+), 93 deletions(-) create mode 100644 src/web-react/terminal.ts create mode 100644 tests/web-react/router-safe-entrypoint.test.ts diff --git a/README.md b/README.md index edfe56b..5abd761 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,9 @@ Each is an independent entry point — import only what you use. | [`/crypto`](src/crypto) | AES-GCM field encryption: `encryptAesGcm`, `decryptAesGcm`, `createFieldCrypto`. Key supplied by the caller. | | [`/missions`](src/missions) | Durable multi-step mission orchestration over a `MissionStorePort` seam: guarded status/step machine, idempotent plan engine with budget/approval gates, `:::mission` parser, the client-safe live-event reducer, and the canonical `StepAgentActivity` per-step delegated-run lane. | | [`/trace`](src/trace) | Flow observability: `buildFlowTrace` + ASCII `renderWaterfall`/`renderHistogram`; the mission trace bridge (`createMissionTraceContext`, `childSpanContext`, `traceEnv`) whose ids/env a delegated run inherits; and delegation→FlowSpan converters (`delegationActivityToFlowSpans`, `loopTraceEventsToFlowSpans`, `composeMissionFlowTrace`). | -| [`/web-react`](src/web-react) | Shared React components: `ModelPicker`, `EffortPicker`, `ChatMessages`, `RunDrillIn`, plus the observability surfaces — `MissionActivityLane`, `AgentActivityPanel`, `FlowWaterfall`. React is an optional peer; not re-exported from the root entry. | +| [`/web-react`](src/web-react) | Router-safe React chat components: `ChatComposer`, `ModelPicker`, `EffortPicker`, `ChatMessages`, `RunDrillIn`, plus observability surfaces — `MissionActivityLane`, `AgentActivityPanel`, `FlowWaterfall`. This path must not import sandbox-only UI. | +| [`/composer`](src/composer) | Sandbox-first `AgentComposer` and profile/model/sandbox-runner controls re-exported from `@tangle-network/sandbox-ui/chat`. Use when the chat owns a sandbox profile or needs the full sandbox composer. | +| `/web-react/terminal` | Sandbox terminal React components, including `WorkspaceTerminalPanel`. Import this explicit path only for container/terminal views. | | [`/web`](src/web) | Request-boundary utilities: `parseJsonObjectBody`, `requireString`, `extractRequestContext`, `checkRateLimit`, `addSecurityHeaders`. | | [`/stream`](src/stream) | SSE normalization and turn identity: `normalizeToolEvent`, `resolveChatTurn`, `encodeEvent`, message-part merging. | | [`/redact`](src/redact) | `redactForIngestion` — PII redaction before content leaves the boundary. | diff --git a/docs/product-surfaces.md b/docs/product-surfaces.md index 60564fd..c96eb0c 100644 --- a/docs/product-surfaces.md +++ b/docs/product-surfaces.md @@ -228,7 +228,7 @@ it a *timeline* must be the first thing you see. this without losing my place."* First impression: state exactly what one more seat buys, in one sentence, with the price — no tier wall. -### Workspace terminal (`workspace-terminal-panel`) +### Workspace terminal (`web-react/terminal` -> `WorkspaceTerminalPanel`) - **Purpose / goal:** a live view into the sandbox session the agent is running in. Goal for the *power* user: *"see the raw truth when I need to debug."* Keep it opt-in and secondary — it's the inspection hatch, not the front door. diff --git a/package.json b/package.json index 7a8b448..df33406 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,11 @@ "import": "./dist/web-react/index.js", "default": "./dist/web-react/index.js" }, + "./web-react/terminal": { + "types": "./dist/web-react/terminal.d.ts", + "import": "./dist/web-react/terminal.js", + "default": "./dist/web-react/terminal.js" + }, "./composer": { "types": "./dist/composer/index.d.ts", "import": "./dist/composer/index.js", diff --git a/src/composer/index.ts b/src/composer/index.ts index 81abf82..2c44dce 100644 --- a/src/composer/index.ts +++ b/src/composer/index.ts @@ -1,10 +1,9 @@ /** - * The canonical agent chat composer, re-exported from `@tangle-network/sandbox-ui` - * so agent-app apps adopt the one shared input box — model · harness · effort · - * agent-profile, with harness↔model snapping — without each wiring sandbox-ui - * directly. Opt-in subpath: importing it requires the (otherwise optional) - * `@tangle-network/sandbox-ui` peer, so apps that don't use the composer pay - * nothing. Prefer this over agent-app's legacy `web-react` composer for new UI. + * Sandbox-first chat composer, re-exported from `@tangle-network/sandbox-ui` so + * agent-app apps can opt into the full profile/model/sandbox-runner/reasoning + * control surface without wiring sandbox-ui directly. Router-only apps should + * use `@tangle-network/agent-app/web-react`; importing this subpath requires the + * otherwise optional `@tangle-network/sandbox-ui` peer. */ export { AgentComposer, diff --git a/src/web-react/chat-composer.tsx b/src/web-react/chat-composer.tsx index 75cc385..f4eca02 100644 --- a/src/web-react/chat-composer.tsx +++ b/src/web-react/chat-composer.tsx @@ -1,131 +1,406 @@ /** - * ChatComposer — the shared message input, now a thin wrapper over the canonical - * `AgentComposer` from `@tangle-network/sandbox-ui`. The bespoke implementation - * is gone; this preserves ChatComposer's API (controlled/uncontrolled value, - * `onSend`, attachments, streaming Stop, a `controls` slot, Cmd/Ctrl+L focus) and - * maps it onto the one shared composer, so every agent-app surface and sandbox-ui - * render the same input box. + * ChatComposer — the shared message input every agent app used to hand-roll: + * an auto-resizing textarea (Enter sends, Shift+Enter inserts a newline), an + * opt-in attach + drag-and-drop surface with pending-file chips, a streaming + * Stop/Send toggle, a slot for inline controls (model picker, reasoning + * effort), and a Cmd/Ctrl+L focus shortcut. * - * Theming: AgentComposer is authored against the brand MD3 surface tokens; the - * agent-app theme (`@tangle-network/agent-app/styles` + `/tailwind-preset`) - * bridges those onto its shadcn palette, so this renders on-palette in any - * agent-app shell. Consumers must also have their Tailwind scan sandbox-ui's - * dist so the composer's classes are generated. + * Styling contract matches the rest of `web-react`: Tailwind over the shared + * design tokens (`bg-card`, `border-border`, `text-foreground`, `bg-primary`, …) + * and inline-SVG glyphs. It defines NO `--chat-*` / `--brand-*` custom + * properties, so it themes correctly in any shell that provides the standard + * tokens — the input renders on-palette instead of collapsing to unstyled + * fallbacks when a host hasn't defined a private chat-token set. */ -import { useCallback, useState, type ReactNode } from "react"; import { - AgentComposer, - type ComposerFile as SandboxComposerFile, -} from "@tangle-network/sandbox-ui/chat"; + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, + type DragEvent, + type KeyboardEvent, + type ReactNode, +} from 'react' -/** Re-exported from sandbox-ui — the staged-attachment chip shape. */ -export type ComposerFile = SandboxComposerFile; +// ── glyphs (no icon-library dependency) ─────────────────────────────────── + +function SendGlyph({ className }: { className?: string }) { + return ( + + + + ) +} + +function StopGlyph({ className }: { className?: string }) { + return ( + + + + ) +} + +function PaperclipGlyph({ className }: { className?: string }) { + return ( + + + + ) +} + +function FolderGlyph({ className }: { className?: string }) { + return ( + + + + + ) +} + +function CloseGlyph({ className }: { className?: string }) { + return ( + + + + ) +} + +function UploadGlyph({ className }: { className?: string }) { + return ( + + + + ) +} + +// ── component ────────────────────────────────────────────────────────────── + +export interface ComposerFile { + id: string + name: string + size?: number + kind: 'file' | 'folder' + /** Number of files inside, for a folder chip. */ + fileCount?: number + status: 'pending' | 'uploading' | 'ready' | 'error' +} export interface ChatComposerProps { /** Send the trimmed, non-empty message. Attached files travel separately via * `onAttach` + `pendingFiles` (the host consumes and clears them on send). */ - onSend: (message: string) => void; + onSend: (message: string) => void /** Stop the in-flight turn; shown in place of Send while `isStreaming`. */ - onCancel?: () => void; - isStreaming?: boolean; - /** Block input + send (e.g. while restoring). */ - disabled?: boolean; - placeholder?: string; + onCancel?: () => void + isStreaming?: boolean + /** Block input + send (e.g. while restoring). Distinct from `isStreaming`, + * which keeps the textarea editable so the next turn can be composed. */ + disabled?: boolean + placeholder?: string /** Controlled value. Omit for self-managed internal state (cleared on send). */ - value?: string; - onValueChange?: (value: string) => void; + value?: string + onValueChange?: (value: string) => void /** Initial text in uncontrolled mode; ignored when `value` is provided. */ - initialValue?: string; + initialValue?: string - /** Inline controls (e.g. ``), rendered in the control row. */ - controls?: ReactNode; - /** - * @deprecated The composer renders a single control row; this no longer moves - * the controls above the input. Retained for API compatibility. - */ - controlsPlacement?: "above" | "footer"; + /** Inline controls (e.g. `` + `` or + * ``). Rendered in a row above the input by default. */ + controls?: ReactNode + controlsPlacement?: 'above' | 'footer' /** Attachments are opt-in: pass `onAttach` to show the attach button, accept * drag-and-drop onto the input, and render `pendingFiles` chips. */ - onAttach?: (files: FileList) => void; - onAttachFolder?: (files: FileList) => void; - pendingFiles?: ComposerFile[]; - onRemoveFile?: (id: string) => void; - accept?: string; - dropTitle?: string; - dropDescription?: string; + onAttach?: (files: FileList) => void + onAttachFolder?: (files: FileList) => void + pendingFiles?: ComposerFile[] + onRemoveFile?: (id: string) => void + accept?: string + dropTitle?: string + dropDescription?: string /** Cmd/Ctrl+L focuses the input and shows the hint. Default true. */ - focusShortcut?: boolean; - /** Send button label (aria/title; the button is an icon). Default "Send". */ - sendLabel?: string; - className?: string; + focusShortcut?: boolean + /** Send button label. Default "Send". */ + sendLabel?: string + className?: string } +const MAX_HEIGHT = 168 + export function ChatComposer({ onSend, onCancel, isStreaming = false, disabled = false, - placeholder = "Message the agent…", + placeholder = 'Message the agent…', value, onValueChange, initialValue, controls, + controlsPlacement = 'above', onAttach, onAttachFolder, - pendingFiles, + pendingFiles = [], onRemoveFile, accept, - dropTitle, - dropDescription, + dropTitle = 'Drop files to add context', + dropDescription = 'They attach to your next message.', focusShortcut = true, - sendLabel = "Send", + sendLabel = 'Send', className, }: ChatComposerProps) { - const isControlled = value !== undefined; - const [internal, setInternal] = useState(initialValue ?? ""); - const text = isControlled ? value : internal; + const isControlled = value !== undefined + const [internal, setInternal] = useState(initialValue ?? '') + const text = isControlled ? value : internal + + const textareaRef = useRef(null) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + const [dragOver, setDragOver] = useState(false) + const dragDepth = useRef(0) const setText = useCallback( (next: string) => { - if (!isControlled) setInternal(next); - onValueChange?.(next); + if (!isControlled) setInternal(next) + onValueChange?.(next) }, [isControlled, onValueChange], - ); + ) + + // Keep the textarea height in sync with the content for BOTH typed and + // external (controlled) value changes — one effect covers both paths. + useEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px` + }, [text]) + + // Cmd/Ctrl+L focuses the composer from anywhere — the shortcut the hint + // advertises. Scoped to when the shortcut is enabled and not disabled. + useEffect(() => { + if (!focusShortcut || disabled) return + function onKeyDown(e: globalThis.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'l') { + e.preventDefault() + textareaRef.current?.focus() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [focusShortcut, disabled]) + + const canSend = text.trim().length > 0 && !isStreaming && !disabled + + const send = useCallback(() => { + const trimmed = text.trim() + if (!trimmed || isStreaming || disabled) return + onSend(trimmed) + setText('') + }, [text, isStreaming, disabled, onSend, setText]) + + const handleKeyDown = (e: KeyboardEvent) => { + // Respect IME composition — Enter commits the candidate, it doesn't send. + if (e.nativeEvent.isComposing) return + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } + } + + const handleFileChange = (e: ChangeEvent) => { + if (e.target.files?.length) onAttach?.(e.target.files) + e.target.value = '' + } + + const handleFolderChange = (e: ChangeEvent) => { + if (e.target.files?.length) (onAttachFolder ?? onAttach)?.(e.target.files) + e.target.value = '' + } + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepth.current++ + if (e.dataTransfer?.types.includes('Files')) setDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepth.current-- + if (dragDepth.current <= 0) { + dragDepth.current = 0 + setDragOver(false) + } + }, []) - const handleSubmit = useCallback(() => { - const trimmed = text.trim(); - if (!trimmed || isStreaming || disabled) return; - onSend(trimmed); - // Always signal a clear: uncontrolled resets internal state; controlled - // notifies the host via onValueChange (the input stays until it re-renders). - setText(""); - }, [text, isStreaming, disabled, onSend, setText]); + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + }, []) + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragDepth.current = 0 + setDragOver(false) + const files = e.dataTransfer?.files + if (files?.length) onAttach?.(files) + }, + [onAttach], + ) + + const folderChips = pendingFiles.filter((f) => f.kind === 'folder') + const fileChips = pendingFiles.filter((f) => f.kind !== 'folder') + const showFooter = controls != null && controlsPlacement === 'footer' + const showAbove = controls != null && controlsPlacement === 'above' return ( - - ); +
+ {dragOver && ( +
+
+ + + +

{dropTitle}

+

{dropDescription}

+
+
+ )} + + {showAbove &&
{controls}
} + + {pendingFiles.length > 0 && ( +
+ {[...folderChips, ...fileChips].map((f) => ( + + {f.kind === 'folder' ? : } + {f.name} + {f.fileCount !== undefined && ({f.fileCount})} + {f.status === 'uploading' && ( + + )} + {onRemoveFile && ( + + )} + + ))} +
+ )} + +
+ {onAttach && ( + <> + + + + )} + {onAttachFolder && ( + <> + + {/* webkitdirectory is non-standard but widely supported for folder picks. */} + )} + /> + + )} + +