From ca149cd9ad066a3c95eec8e01741624e35b62626 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 11 Jul 2026 21:24:07 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(jcode-ui):=200.2.0=20=E2=80=94=20scope?= =?UTF-8?q?d=20tokens,=20conversation=20loop,=20composer=20v2,=20AG-UI,=20?= =?UTF-8?q?canvas/voice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking: design tokens move from :root to [data-jcode-ui] with a --jcode- prefix (compat.css bridges legacy names; shadcn.css inherits shadcn themes). Scoped element resets now live in @layer base via :where() so they never outrank utility/component classes; animation classes/keyframes gain the same prefix; prose gets host-article isolation guards. - Approvals: host-defined options (ACP-compatible) + resolveApprovalOption; two-step arming preserved for allow_always kinds - Conversation loop: BranchPicker (Message.versions/switchVersion), regenerate, thumbs feedback, failed-turn retry, ConnectionBanner, ThreadWelcome + Suggestions, ExportButton/exportThreadMarkdown, QuoteSelection + ComposerHandle - Composer v2: AttachmentAdapter (progress/retry), drag&drop + paste, leading/trailing/footer slots, ModelSelector, optional dictation - Rendering: streaming-stable markdown with block caching, code-block chrome + copy, optional mermaid/katex plugin subentries - Runtime-wired renderers: TaskList, FileTree, TestResults, StackTrace, Artifact container, Message/ToolCallCard slots - createAGUIRuntime (AG-UI protocol, SSE + JSON Patch, selftest), ThreadStore contract + ThreadList - New subentries: jcode-ui/canvas (@xyflow/react optional peer), jcode-ui/voice (browser APIs only) - Both packages bumped to 0.2.0; CHANGELOG added Generated with Jack AI bot --- packages/jcode-ui-core/package.json | 3 +- packages/jcode-ui-core/src/export/markdown.ts | 123 ++++ packages/jcode-ui-core/src/index.ts | 2 + .../src/primitives/ApprovalBlock.tsx | 160 +++-- .../src/primitives/AskUserBlock.tsx | 4 +- .../jcode-ui-core/src/primitives/Composer.tsx | 561 +++++++++++++++--- .../src/primitives/MessageView.tsx | 2 +- .../jcode-ui-core/src/primitives/Thread.tsx | 13 + .../src/primitives/ToolCallView.tsx | 1 + .../src/primitives/attachmentAdapter.ts | 112 ++++ .../jcode-ui-core/src/primitives/index.ts | 1 + .../src/runtime/agui.selftest.ts | 249 ++++++++ packages/jcode-ui-core/src/runtime/agui.ts | 22 + .../jcode-ui-core/src/runtime/aguiEvents.ts | 331 +++++++++++ .../jcode-ui-core/src/runtime/aguiRuntime.ts | 466 +++++++++++++++ packages/jcode-ui-core/src/runtime/index.ts | 29 +- .../jcode-ui-core/src/runtime/mockRuntime.ts | 63 ++ .../jcode-ui-core/src/threads/context.tsx | 51 ++ packages/jcode-ui-core/src/threads/index.ts | 11 + packages/jcode-ui-core/src/threads/store.ts | 178 ++++++ packages/jcode-ui-core/src/types/index.ts | 43 ++ packages/jcode-ui/CHANGELOG.md | 93 +++ packages/jcode-ui/README.md | 35 +- packages/jcode-ui/fixture/main.tsx | 182 +++++- packages/jcode-ui/package.json | 43 +- .../jcode-ui/scripts/generate-compat-css.mjs | 77 +++ packages/jcode-ui/scripts/smoke-markdown.mjs | 93 +++ .../jcode-ui/src/canvas/CanvasControls.tsx | 58 ++ packages/jcode-ui/src/canvas/CanvasPanel.tsx | 35 ++ .../jcode-ui/src/canvas/WorkflowCanvas.tsx | 86 +++ packages/jcode-ui/src/canvas/WorkflowEdge.tsx | 77 +++ packages/jcode-ui/src/canvas/WorkflowNode.tsx | 64 ++ packages/jcode-ui/src/canvas/canvas.css | 237 ++++++++ packages/jcode-ui/src/canvas/index.ts | 40 ++ .../jcode-ui/src/canvas/toolTreeToGraph.ts | 86 +++ .../src/components/ApprovalBanner.tsx | 137 +++-- packages/jcode-ui/src/components/Artifact.tsx | 69 +++ .../jcode-ui/src/components/AskUserCard.tsx | 34 +- .../jcode-ui/src/components/Attachment.tsx | 125 +++- .../jcode-ui/src/components/BranchPicker.tsx | 84 +++ .../jcode-ui/src/components/ChatInput.tsx | 257 +++++--- .../src/components/CompactToolRow.tsx | 14 +- .../src/components/ConnectionBanner.tsx | 84 +++ .../jcode-ui/src/components/ContextBar.tsx | 18 +- .../src/components/ExploringGroupCard.tsx | 20 +- .../jcode-ui/src/components/ExportButton.tsx | 50 ++ packages/jcode-ui/src/components/Message.tsx | 202 +++++-- .../jcode-ui/src/components/ModelSelector.tsx | 202 +++++++ .../src/components/QuoteSelection.tsx | 125 ++++ .../jcode-ui/src/components/Reasoning.tsx | 8 +- packages/jcode-ui/src/components/Sources.tsx | 14 +- .../jcode-ui/src/components/Suggestions.tsx | 59 ++ packages/jcode-ui/src/components/TaskList.tsx | 184 ++++++ packages/jcode-ui/src/components/Thread.tsx | 15 +- .../jcode-ui/src/components/ThreadList.tsx | 379 ++++++++++++ .../jcode-ui/src/components/ThreadWelcome.tsx | 60 ++ .../jcode-ui/src/components/ToolCallCard.tsx | 64 +- .../src/components/ToolRegistryContext.tsx | 3 + packages/jcode-ui/src/index.ts | 21 +- packages/jcode-ui/src/lib/markdown.ts | 308 +++++++++- .../jcode-ui/src/lib/streamingMarkdown.ts | 228 +++++++ .../jcode-ui/src/lib/useStreamingMarkdown.ts | 48 ++ .../src/plugins/external-modules.d.ts | 34 ++ packages/jcode-ui/src/plugins/katex.ts | 47 ++ packages/jcode-ui/src/plugins/mermaid.ts | 104 ++++ packages/jcode-ui/src/styles/animations.css | 106 ++-- packages/jcode-ui/src/styles/components.css | 554 ++++++++--------- packages/jcode-ui/src/styles/composer2.css | 417 +++++++++++++ packages/jcode-ui/src/styles/conversation.css | 127 ++++ packages/jcode-ui/src/styles/entry.css | 11 + .../jcode-ui/src/styles/markdown-chrome.css | 173 ++++++ packages/jcode-ui/src/styles/p5.css | 535 +++++++++++++++++ packages/jcode-ui/src/styles/shadcn.css | 42 ++ packages/jcode-ui/src/styles/threadlist.css | 350 +++++++++++ packages/jcode-ui/src/styles/tokens.css | 420 ++++++------- packages/jcode-ui/src/styles/welcome.css | 149 +++++ .../src/toolRenderers/browserShot.tsx | 4 +- packages/jcode-ui/src/toolRenderers/diff.tsx | 8 +- .../jcode-ui/src/toolRenderers/fileTree.tsx | 233 ++++++++ .../jcode-ui/src/toolRenderers/fileViewer.tsx | 6 +- .../jcode-ui/src/toolRenderers/generic.tsx | 16 +- packages/jcode-ui/src/toolRenderers/index.ts | 3 + .../jcode-ui/src/toolRenderers/search.tsx | 18 +- packages/jcode-ui/src/toolRenderers/skill.tsx | 10 +- .../jcode-ui/src/toolRenderers/stackTrace.tsx | 218 +++++++ packages/jcode-ui/src/toolRenderers/team.tsx | 76 +-- .../jcode-ui/src/toolRenderers/terminal.tsx | 4 +- .../src/toolRenderers/testResults.tsx | 232 ++++++++ packages/jcode-ui/src/toolRenderers/todo.tsx | 108 +--- packages/jcode-ui/src/voice/AudioPlayer.tsx | 229 +++++++ packages/jcode-ui/src/voice/SpeechInput.tsx | 289 +++++++++ packages/jcode-ui/src/voice/Transcription.tsx | 178 ++++++ .../jcode-ui/src/voice/VoiceVisualizer.tsx | 197 ++++++ packages/jcode-ui/src/voice/index.ts | 24 + packages/jcode-ui/src/voice/voice.css | 361 +++++++++++ 95 files changed, 10594 insertions(+), 1132 deletions(-) create mode 100644 packages/jcode-ui-core/src/export/markdown.ts create mode 100644 packages/jcode-ui-core/src/primitives/attachmentAdapter.ts create mode 100644 packages/jcode-ui-core/src/runtime/agui.selftest.ts create mode 100644 packages/jcode-ui-core/src/runtime/agui.ts create mode 100644 packages/jcode-ui-core/src/runtime/aguiEvents.ts create mode 100644 packages/jcode-ui-core/src/runtime/aguiRuntime.ts create mode 100644 packages/jcode-ui-core/src/threads/context.tsx create mode 100644 packages/jcode-ui-core/src/threads/index.ts create mode 100644 packages/jcode-ui-core/src/threads/store.ts create mode 100644 packages/jcode-ui/CHANGELOG.md create mode 100644 packages/jcode-ui/scripts/generate-compat-css.mjs create mode 100644 packages/jcode-ui/scripts/smoke-markdown.mjs create mode 100644 packages/jcode-ui/src/canvas/CanvasControls.tsx create mode 100644 packages/jcode-ui/src/canvas/CanvasPanel.tsx create mode 100644 packages/jcode-ui/src/canvas/WorkflowCanvas.tsx create mode 100644 packages/jcode-ui/src/canvas/WorkflowEdge.tsx create mode 100644 packages/jcode-ui/src/canvas/WorkflowNode.tsx create mode 100644 packages/jcode-ui/src/canvas/canvas.css create mode 100644 packages/jcode-ui/src/canvas/index.ts create mode 100644 packages/jcode-ui/src/canvas/toolTreeToGraph.ts create mode 100644 packages/jcode-ui/src/components/Artifact.tsx create mode 100644 packages/jcode-ui/src/components/BranchPicker.tsx create mode 100644 packages/jcode-ui/src/components/ConnectionBanner.tsx create mode 100644 packages/jcode-ui/src/components/ExportButton.tsx create mode 100644 packages/jcode-ui/src/components/ModelSelector.tsx create mode 100644 packages/jcode-ui/src/components/QuoteSelection.tsx create mode 100644 packages/jcode-ui/src/components/Suggestions.tsx create mode 100644 packages/jcode-ui/src/components/TaskList.tsx create mode 100644 packages/jcode-ui/src/components/ThreadList.tsx create mode 100644 packages/jcode-ui/src/components/ThreadWelcome.tsx create mode 100644 packages/jcode-ui/src/lib/streamingMarkdown.ts create mode 100644 packages/jcode-ui/src/lib/useStreamingMarkdown.ts create mode 100644 packages/jcode-ui/src/plugins/external-modules.d.ts create mode 100644 packages/jcode-ui/src/plugins/katex.ts create mode 100644 packages/jcode-ui/src/plugins/mermaid.ts create mode 100644 packages/jcode-ui/src/styles/composer2.css create mode 100644 packages/jcode-ui/src/styles/conversation.css create mode 100644 packages/jcode-ui/src/styles/markdown-chrome.css create mode 100644 packages/jcode-ui/src/styles/p5.css create mode 100644 packages/jcode-ui/src/styles/shadcn.css create mode 100644 packages/jcode-ui/src/styles/threadlist.css create mode 100644 packages/jcode-ui/src/styles/welcome.css create mode 100644 packages/jcode-ui/src/toolRenderers/fileTree.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/stackTrace.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/testResults.tsx create mode 100644 packages/jcode-ui/src/voice/AudioPlayer.tsx create mode 100644 packages/jcode-ui/src/voice/SpeechInput.tsx create mode 100644 packages/jcode-ui/src/voice/Transcription.tsx create mode 100644 packages/jcode-ui/src/voice/VoiceVisualizer.tsx create mode 100644 packages/jcode-ui/src/voice/index.ts create mode 100644 packages/jcode-ui/src/voice/voice.css diff --git a/packages/jcode-ui-core/package.json b/packages/jcode-ui-core/package.json index e34a49b7..97ef4189 100644 --- a/packages/jcode-ui-core/package.json +++ b/packages/jcode-ui-core/package.json @@ -1,6 +1,6 @@ { "name": "jcode-ui-core", - "version": "0.1.0", + "version": "0.2.0", "description": "Framework-agnostic core for jcode-ui: types, chat runtime abstraction, and headless React primitives for AI chat interfaces.", "type": "module", "license": "MIT", @@ -64,6 +64,7 @@ "build": "tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", "typecheck": "tsc --noEmit -p tsconfig.json", + "selftest:agui": "pnpm run build && node dist/runtime/agui.selftest.js", "clean": "rm -rf dist *.tsbuildinfo" }, "peerDependencies": { diff --git a/packages/jcode-ui-core/src/export/markdown.ts b/packages/jcode-ui-core/src/export/markdown.ts new file mode 100644 index 00000000..1f65b8af --- /dev/null +++ b/packages/jcode-ui-core/src/export/markdown.ts @@ -0,0 +1,123 @@ +/** + * exportThreadMarkdown — serialize a conversation timeline to portable + * GitHub-flavored markdown. + * + * Pure and deterministic: no clock access (pass `now` for the header stamp) + * and no DOM. Tool calls fold into
so long transcripts stay + * scannable; fences inside tool output are escaped by widening the outer + * fence, never by mangling the content. + */ + +import type { ThreadItem, ToolCall, Message, Approval, ExploringGroup } from '../types/index.js' + +export interface ExportMarkdownOptions { + /** Document H1. Default: "Conversation". */ + title?: string + /** Timestamp for the header line; omitted → no stamp (deterministic). */ + now?: Date + /** Truncate a single tool output beyond this many chars. Default 4000. */ + maxToolOutput?: number + /** Role display labels. */ + labels?: { user?: string; assistant?: string; system?: string } +} + +/** Pick a fence longer than any run of backticks inside `body`. */ +function fenceFor(body: string): string { + const longest = body.match(/`{3,}/g)?.reduce((m, s) => Math.max(m, s.length), 0) ?? 0 + return '`'.repeat(Math.max(3, longest + 1)) +} + +function codeBlock(body: string, lang = ''): string { + const fence = fenceFor(body) + return `${fence}${lang}\n${body}\n${fence}` +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s + return `${s.slice(0, max)}\n… (${s.length - max} chars truncated)` +} + +function prettyJSON(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} + +function renderMessage(m: Message, labels: Required>): string { + const label = m.role === 'user' ? labels.user : m.role === 'assistant' ? labels.assistant : labels.system + const parts = [`### ${label}`] + if (m.reasoning) { + parts.push(`
Reasoning\n\n${m.reasoning}\n\n
`) + } + parts.push(m.content.trim() || '_(empty)_') + if (m.sources?.length) { + parts.push(m.sources.map((s) => `- [${s.title}](${s.url ?? ''})`).join('\n')) + } + return parts.join('\n\n') +} + +function renderTool(t: ToolCall, maxOutput: number, depth = 0): string { + const title = t.displayInfo?.title ?? t.name + const subtitle = t.displayInfo?.subtitle ? ` — ${t.displayInfo.subtitle}` : '' + const status = t.status === 'done' ? '' : ` (${t.status})` + const body: string[] = [] + const args = prettyJSON(t.args) + if (args && args !== '{}') body.push(codeBlock(args, 'json')) + const output = t.displayOutput ?? t.output + if (output) body.push(codeBlock(truncate(output, maxOutput))) + if (t.error) body.push(codeBlock(truncate(t.error, maxOutput))) + for (const child of t.children ?? []) body.push(renderTool(child, maxOutput, depth + 1)) + return [ + `
🔧 ${title}${subtitle}${status}`, + '', + body.join('\n\n') || '_(no output)_', + '', + '
', + ].join('\n') +} + +function renderApproval(a: Approval): string { + const chosen = a.resolvedOptionId + ? a.options?.find((o) => o.id === a.resolvedOptionId)?.label + : undefined + const outcome = a.resolved ? (chosen ?? (a.approved ? 'Allowed' : 'Denied')) : 'Pending' + return `> 🛡️ Approval — \`${a.tool_name}\`: **${outcome}**${a.is_external ? ' _(external target)_' : ''}` +} + +function renderExploring(g: ExploringGroup, maxOutput: number): string { + const steps = g.tools + .map((t) => `- ${t.displayInfo?.title ?? t.name}${t.displayInfo?.subtitle ? ` ${t.displayInfo.subtitle}` : ''}`) + .join('\n') + void maxOutput + return `
🔍 Explored ${g.tools.length} steps\n\n${steps}\n\n
` +} + +export function exportThreadMarkdown(items: ThreadItem[], opts: ExportMarkdownOptions = {}): string { + const labels = { + user: opts.labels?.user ?? 'You', + assistant: opts.labels?.assistant ?? 'Assistant', + system: opts.labels?.system ?? 'System', + } + const maxOutput = opts.maxToolOutput ?? 4000 + const out: string[] = [`# ${opts.title ?? 'Conversation'}`] + if (opts.now) out.push(`_Exported ${opts.now.toISOString()}_`) + for (const item of items) { + switch (item.kind) { + case 'message': + out.push(renderMessage(item.data, labels)) + break + case 'tool': + out.push(renderTool(item.data, maxOutput)) + break + case 'approval': + out.push(renderApproval(item.data)) + break + case 'exploring': + out.push(renderExploring(item.data, maxOutput)) + break + } + } + return out.join('\n\n') + '\n' +} diff --git a/packages/jcode-ui-core/src/index.ts b/packages/jcode-ui-core/src/index.ts index c402cea3..cfd6df35 100644 --- a/packages/jcode-ui-core/src/index.ts +++ b/packages/jcode-ui-core/src/index.ts @@ -19,3 +19,5 @@ export * from './adapters/index.js' export * from './hooks/index.js' export * from './primitives/index.js' export * from './timeline/groupExploring.js' +export * from './threads/index.js' +export * from './export/markdown.js' diff --git a/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx index 6236b1d9..26be190b 100644 --- a/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx +++ b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx @@ -1,10 +1,17 @@ /** * ApprovalBlock — the headless approval gate. * - * Owns: the pending/resolved state split, the 3-tier decision (allow once / allow - * all / deny), the "arming" UX for "allow all" (two-step confirm to prevent - * accidental blanket approval), and dispatching via runtime actions. Does NOT - * own styling or the tool-name→icon mapping (those live in the styled wrapper). + * Owns: the pending/resolved state split, the decision contracts, the "arming" + * UX (two-step confirm for blanket approvals to prevent accidents), and + * dispatching via runtime actions. Does NOT own styling or the tool-name→icon + * mapping (those live in the styled wrapper). + * + * Two decision shapes: + * - classic boolean: allow once / allow all (armed) / deny → `resolveApproval` + * - host-defined `approval.options` (arbitrary ids, e.g. ACP + * permission_request) → `resolveApprovalOption`; if the host didn't provide + * that action, kinds fall back onto the boolean contract so mock/demo + * runtimes keep working. * * The `resolving` flag on the approval object disables controls while a resolve * request is in flight (prevents double-submit). @@ -12,22 +19,31 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Approval } from '../types/index.js' +import type { Approval, ApprovalOption } from '../types/index.js' import { useRuntimeActions } from '../runtime/context.js' +export interface ApprovalDecisionActions { + allowOnce: () => void + allowAllArm: () => void + allowAllConfirm: () => void + allowAllCancel: () => void + deny: () => void + armed: boolean + /** Options mode: choose a non-arming option (kind ≠ 'allow_always'). */ + choose: (optionId: string) => void + /** Options mode: id currently armed for two-step confirm, or null. */ + armedOptionId: string | null + /** Options mode: arm an 'allow_always' option (first click). */ + armOption: (optionId: string) => void + /** Options mode: confirm the armed option (second click). */ + confirmOption: (optionId: string) => void + /** Options mode: cancel arming. */ + cancelArm: () => void +} + export interface ApprovalBlockRenderSlots { /** Render the pending decision card. Receives the action callbacks. */ - renderPending?: ( - approval: Approval, - actions: { - allowOnce: () => void - allowAllArm: () => void - allowAllConfirm: () => void - allowAllCancel: () => void - deny: () => void - armed: boolean - }, - ) => ReactNode + renderPending?: (approval: Approval, actions: ApprovalDecisionActions) => ReactNode /** Render the resolved inline note. */ renderResolved?: (approval: Approval) => ReactNode } @@ -38,14 +54,31 @@ export interface ApprovalBlockProps extends ApprovalBlockRenderSlots { className?: string } +/** Map an option kind onto the boolean contract — the fallback used when the + * host implements only `resolveApproval`. */ +function booleanFallback( + resolve: (id: string, approved: boolean, approveAll?: boolean) => void, + approvalId: string, + option: ApprovalOption, +) { + const kind = option.kind ?? 'custom' + if (kind === 'deny') resolve(approvalId, false, false) + else resolve(approvalId, true, kind === 'allow_always') +} + export function ApprovalBlock({ approval, className, renderPending, renderResolved }: ApprovalBlockProps): ReactNode { const actions = useRuntimeActions() - // Arming state for "allow all" — the user must click twice (first arms, - // turning the button destructive; second confirms). + // Arming state — the user must click twice for blanket approvals (first + // arms, turning the button cautionary; second confirms). const [armed, setArmed] = useState(false) + const [armedOptionId, setArmedOptionId] = useState(null) if (approval.resolved) { - return
{renderResolved?.(approval) ?? }
+ return ( +
+ {renderResolved?.(approval) ?? } +
+ ) } const allowOnce = () => actions.resolveApproval(approval.id, true, false) @@ -54,50 +87,97 @@ export function ApprovalBlock({ approval, className, renderPending, renderResolv const allowAllCancel = () => setArmed(false) const deny = () => actions.resolveApproval(approval.id, false, false) + const dispatchOption = (optionId: string) => { + const opt = approval.options?.find((o) => o.id === optionId) + if (!opt) return + if (actions.resolveApprovalOption) actions.resolveApprovalOption(approval.id, optionId) + else booleanFallback(actions.resolveApproval, approval.id, opt) + } + const choose = (optionId: string) => dispatchOption(optionId) + const armOption = (optionId: string) => setArmedOptionId(optionId) + const confirmOption = (optionId: string) => { + setArmedOptionId(null) + dispatchOption(optionId) + } + const cancelArm = () => setArmedOptionId(null) + + const decisionActions: ApprovalDecisionActions = { + allowOnce, + allowAllArm, + allowAllConfirm, + allowAllCancel, + deny, + armed, + choose, + armedOptionId, + armOption, + confirmOption, + cancelArm, + } + return ( -
- {renderPending?.(approval, { allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed }) ?? - DefaultPending({ approval, allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed })} +
+ {renderPending?.(approval, decisionActions) ?? DefaultPending({ approval, actions: decisionActions })}
) } function DefaultResolved({ approval }: { approval: Approval }): ReactNode { + const optionLabel = approval.resolvedOptionId + ? approval.options?.find((o) => o.id === approval.resolvedOptionId)?.label + : undefined return ( - {approval.approved ? '✓ allowed' : '✗ denied'} · {approval.tool_name} + {optionLabel ?? (approval.approved ? '✓ allowed' : '✗ denied')} · {approval.tool_name} ) } -function DefaultPending(args: { +function DefaultPending({ + approval, + actions, +}: { approval: Approval - allowOnce: () => void - allowAllArm: () => void - allowAllConfirm: () => void - allowAllCancel: () => void - deny: () => void - armed: boolean + actions: ApprovalDecisionActions }): ReactNode { - const { approval, allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed } = args const disabled = !!approval.resolving return (
Approve {approval.tool_name}?
{approval.is_external &&
⚠ external path
} -
- - {!armed ? ( - +
+ {approval.options?.length ? ( + approval.options.map((o) => + (o.kind ?? 'custom') === 'allow_always' && actions.armedOptionId !== o.id ? ( + + ) : (o.kind ?? 'custom') === 'allow_always' ? ( + + ) : ( + + ), + ) ) : ( <> - - + + {!actions.armed ? ( + + ) : ( + <> + + + + )} + )} -
) diff --git a/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx index 63f8f266..133ac019 100644 --- a/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx +++ b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx @@ -120,7 +120,7 @@ export function AskUserBlock({ tool, className, renderPending, renderResolved }: if (!isPending) { const answers = parseResolvedAnswers(tool) - return
{renderResolved?.(tool, answers) ?? }
+ return
{renderResolved?.(tool, answers) ?? }
} const controls: AskUserControls = { @@ -133,7 +133,7 @@ export function AskUserBlock({ tool, className, renderPending, renderResolved }: } return ( -
+
{renderPending?.(questions, controls) ?? }
) diff --git a/packages/jcode-ui-core/src/primitives/Composer.tsx b/packages/jcode-ui-core/src/primitives/Composer.tsx index 168b62d1..47698b14 100644 --- a/packages/jcode-ui-core/src/primitives/Composer.tsx +++ b/packages/jcode-ui-core/src/primitives/Composer.tsx @@ -2,19 +2,38 @@ * Composer — the headless message composer. * * Owns: textarea state, autosize, IME-safe key handling, send/queue/stop - * dispatch, and a slash-command palette skeleton. Does NOT own styling or the - * model/mode/workspace pickers (those are app-specific — the styled jcode-ui - * `ChatInput` composes this primitive and layers them on). + * dispatch, a slash-command palette skeleton, and (Composer 2) a pluggable + * attachment pipeline, drag/paste ingestion, control slots, dictation, and an + * imperative `ComposerHandle`. Does NOT own styling or the model/mode/workspace + * pickers (those are app-specific — the styled jcode-ui `ChatInput` composes + * this primitive and layers them on). * * Streaming interaction: when the runtime reports `isRunning`, the send button * becomes a stop button, and `send()` routes to `enqueueMessage` instead of * `sendMessage` (type-ahead). The runtime drains the queue on each turn end. + * + * Attachments: with no `attachmentAdapter` the legacy `allowImages` base64 path + * is used (unchanged). With an adapter, every picked/pasted/dropped file flows + * through `adapter.add`, tracked in a pending state machine (uploading → + * done/error). On send, completed image attachments still ride the ChatImage + * `images` argument (so `sendMessage` stays compatible) and the full completed + * set is also handed to `onSendAttachments`. */ -import { useCallback, useLayoutEffect, useRef, useState } from 'react' -import type { KeyboardEvent, ReactNode } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, +} from 'react' +import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactNode } from 'react' import { useRuntimeActions, useRuntimeState } from '../runtime/context.js' import type { ChatImage } from '../types/index.js' +import { nextAttachmentId } from './attachmentAdapter.js' +import type { AttachmentAdapter, PendingAttachment } from './attachmentAdapter.js' export interface SlashCommand { /** The literal text inserted when chosen (e.g. '/goal'). */ @@ -22,6 +41,33 @@ export interface SlashCommand { description?: string } +/** Lifecycle status of a pending attachment slot. */ +export type PendingStatus = 'uploading' | 'done' | 'error' + +/** A pending-attachment row surfaced to `renderPendingAttachments`. */ +export interface PendingAttachmentItem { + /** Latest adapter snapshot (or the provisional entry while uploading). */ + attachment: PendingAttachment + status: PendingStatus + /** Remove this attachment (also invokes `adapter.remove` when present). */ + remove: () => void + /** Re-run `adapter.add` for this file (error recovery). */ + retry: () => void +} + +/** Dictation (speech-to-text) UI state passed to `renderDictationButton`. */ +export interface DictationState { + listening: boolean +} + +/** Imperative handle exposed via `ref` (used by quote / insert features). */ +export interface ComposerHandle { + /** Insert text at the caret (replacing any selection) and refocus. */ + insertText(text: string): void + /** Focus the textarea. */ + focus(): void +} + export interface ComposerRenderSlots { /** Render the slash-command dropdown when `slashState` is open. */ renderSlashMenu?: (state: SlashMenuState) => ReactNode @@ -30,15 +76,21 @@ export interface ComposerRenderSlots { /** Render the send/stop button. `mode` is 'send' or 'stop'. * Call `onActivate` on click (send when idle, stop when running). */ renderSubmitButton?: (mode: 'send' | 'stop', disabled: boolean, onActivate: () => void) => ReactNode - /** Render attached-image thumbnails (composer attachments strip). */ + /** Render attached-image thumbnails (legacy `allowImages` strip). */ renderAttachments?: (imgs: ChatImage[], remove: (i: number) => void) => ReactNode + /** Render the pending-attachment strip (adapter path). */ + renderPendingAttachments?: (items: PendingAttachmentItem[]) => ReactNode /** * Render the "add attachment" control (paperclip / +). Called with * `openPicker` that opens the hidden file input. When omitted and - * `allowImages` is true, a minimal default button is rendered. + * attachments are enabled, a minimal default button is rendered. * Mirrors assistant-ui `ComposerAddAttachment`. */ renderAddAttachment?: (openPicker: () => void) => ReactNode + /** Render an overlay while files are dragged over the composer. */ + renderDropOverlay?: () => ReactNode + /** Render the dictation (microphone) button. Only called when supported. */ + renderDictationButton?: (state: DictationState, toggle: () => void) => ReactNode /** Optional content rendered before the textarea inside the input row * (e.g. a "+" menu button). */ renderPrefix?: () => ReactNode @@ -59,6 +111,24 @@ export interface ComposerProps extends ComposerRenderSlots { acceptImages?: string /** Max image size in bytes (default 10MB). */ maxImageBytes?: number + /** + * Pluggable attachment pipeline. When provided it supersedes the legacy + * base64 image path: the picker/paste/drop route files through the adapter + * and a pending state machine tracks upload progress. + */ + attachmentAdapter?: AttachmentAdapter + /** Fired on send with the completed attachments (adapter path). */ + onSendAttachments?: (attachments: PendingAttachment[]) => void + /** Content rendered after the add-attachment control (e.g. a ModelSelector). */ + leadingControls?: ReactNode + /** Content rendered just before the submit button. */ + trailingControls?: ReactNode + /** Content rendered below the composer row. */ + footer?: ReactNode + /** Enable the dictation button (rendered only when the browser supports it). */ + enableDictation?: boolean + /** BCP-47 language tag for dictation (default: browser default). */ + dictationLang?: string /** aria-label for the textarea. */ ariaLabel?: string /** Controlled initial value (uncontrolled thereafter). */ @@ -81,6 +151,14 @@ export interface SlashMenuState { const DEFAULT_MAX_ROWS_PX = 160 +interface PendingSlot { + /** Stable react/tracking key (also used as the canonical attachment id). */ + key: string + file: File + attachment: PendingAttachment + status: PendingStatus +} + function readImageFile(file: File): Promise { return new Promise((resolve) => { if (!file.type.startsWith('image/')) { @@ -106,31 +184,82 @@ function readImageFile(file: File): Promise { }) } -export function Composer({ - placeholder = 'Send a message…', - maxRows = DEFAULT_MAX_ROWS_PX, - slashCommands, - allowImages = false, - acceptImages = 'image/*', - maxImageBytes = 10 * 1024 * 1024, - ariaLabel = 'Message input', - defaultValue = '', - className, - onSent, - renderSlashMenu, - renderQueue, - renderSubmitButton, - renderAttachments, - renderAddAttachment, - renderPrefix, - renderSuffix, -}: ComposerProps): ReactNode { +// ─── Dictation (SpeechRecognition) — minimal ambient typing, SSR-safe ───────── + +interface SpeechRecognitionResultLike { + isFinal: boolean + 0: { transcript: string } + length: number +} +interface SpeechRecognitionEventLike { + resultIndex: number + results: { length: number } & Record +} +interface SpeechRecognitionLike { + continuous: boolean + interimResults: boolean + lang: string + start(): void + stop(): void + onresult: ((e: SpeechRecognitionEventLike) => void) | null + onend: (() => void) | null + onerror: (() => void) | null +} +type SpeechRecognitionCtor = new () => SpeechRecognitionLike + +function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null { + if (typeof window === 'undefined') return null + const w = window as unknown as { + SpeechRecognition?: SpeechRecognitionCtor + webkitSpeechRecognition?: SpeechRecognitionCtor + } + return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null +} + +export const Composer = forwardRef(function Composer( + { + placeholder = 'Send a message…', + maxRows = DEFAULT_MAX_ROWS_PX, + slashCommands, + allowImages = false, + acceptImages = 'image/*', + maxImageBytes = 10 * 1024 * 1024, + attachmentAdapter, + onSendAttachments, + leadingControls, + trailingControls, + footer, + enableDictation = false, + dictationLang, + ariaLabel = 'Message input', + defaultValue = '', + className, + onSent, + renderSlashMenu, + renderQueue, + renderSubmitButton, + renderAttachments, + renderPendingAttachments, + renderAddAttachment, + renderDropOverlay, + renderDictationButton, + renderPrefix, + renderSuffix, + }: ComposerProps, + ref, +): ReactNode { const actions = useRuntimeActions() const { isRunning, queued } = useRuntimeState() const [text, setText] = useState(defaultValue) const [images, setImages] = useState([]) + const [pending, setPending] = useState([]) + const [dropping, setDropping] = useState(false) + const [dictating, setDictating] = useState(false) const textareaRef = useRef(null) const fileInputRef = useRef(null) + const dragDepth = useRef(0) + + const attachmentsEnabled = !!attachmentAdapter || allowImages // --- Autosize: grow with content up to maxRows, then scroll. --- useLayoutEffect(() => { @@ -158,23 +287,235 @@ export function Composer({ textareaRef.current?.focus() }, []) + // --- Imperative handle (quote / insert). --- + const insertText = useCallback((insert: string) => { + const el = textareaRef.current + setText((prev) => { + const start = el ? el.selectionStart : prev.length + const end = el ? el.selectionEnd : prev.length + const next = prev.slice(0, start) + insert + prev.slice(end) + requestAnimationFrame(() => { + if (!el) return + el.focus() + const pos = start + insert.length + el.setSelectionRange(pos, pos) + }) + return next + }) + }, []) + + useImperativeHandle( + ref, + () => ({ + insertText, + focus: () => textareaRef.current?.focus(), + }), + [insertText], + ) + + // --- Adapter attachment pipeline (pending state machine). --- + const runAdapterAdd = useCallback( + (key: string, file: File) => { + if (!attachmentAdapter) return + attachmentAdapter + .add(file, (p) => { + setPending((prev) => + prev.map((s) => (s.key === key ? { ...s, attachment: { ...s.attachment, progress: p } } : s)), + ) + }) + .then((result) => { + setPending((prev) => + prev.map((s) => + s.key === key + ? { + ...s, + // Keep the stable key as the canonical id. + attachment: { ...result, id: key }, + status: result.error ? 'error' : 'done', + } + : s, + ), + ) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + setPending((prev) => + prev.map((s) => + s.key === key ? { ...s, attachment: { ...s.attachment, error: message }, status: 'error' } : s, + ), + ) + }) + }, + [attachmentAdapter], + ) + + const addFilesViaAdapter = useCallback( + (files: FileList | File[]) => { + if (!attachmentAdapter) return + for (const file of Array.from(files)) { + const key = nextAttachmentId() + const provisional: PendingAttachment = { + id: key, + kind: file.type.startsWith('image/') ? 'image' : 'file', + name: file.name || 'attachment', + size: file.size, + media_type: file.type || undefined, + progress: 0, + } + setPending((prev) => [...prev, { key, file, attachment: provisional, status: 'uploading' }]) + runAdapterAdd(key, file) + } + }, + [attachmentAdapter, runAdapterAdd], + ) + + const removePending = useCallback( + (key: string) => { + setPending((prev) => prev.filter((s) => s.key !== key)) + void attachmentAdapter?.remove?.(key) + }, + [attachmentAdapter], + ) + + const retryPending = useCallback( + (key: string) => { + setPending((prev) => + prev.map((s) => + s.key === key + ? { ...s, status: 'uploading', attachment: { ...s.attachment, error: undefined, progress: 0 } } + : s, + ), + ) + const slot = pending.find((s) => s.key === key) + if (slot) runAdapterAdd(key, slot.file) + }, + [pending, runAdapterAdd], + ) + + const pendingItems: PendingAttachmentItem[] = pending.map((s) => ({ + attachment: s.attachment, + status: s.status, + remove: () => removePending(s.key), + retry: () => retryPending(s.key), + })) + + // --- Legacy image attachments (no adapter). --- + const addImage = useCallback( + (img: ChatImage) => { + if (!allowImages) return + const approxBytes = (img.data.length * 3) / 4 + if (approxBytes > maxImageBytes) return + setImages((prev) => [...prev, img]) + }, + [allowImages, maxImageBytes], + ) + const addImageFiles = useCallback( + async (files: FileList | File[]) => { + if (!allowImages) return + for (const file of Array.from(files)) { + if (file.size > maxImageBytes) continue + const img = await readImageFile(file) + if (img) addImage(img) + } + }, + [addImage, allowImages, maxImageBytes], + ) + const removeImage = useCallback((i: number) => { + setImages((prev) => prev.filter((_, idx) => idx !== i)) + }, []) + + // --- Unified file ingestion (picker / paste / drop). --- + const ingestFiles = useCallback( + (files: FileList | File[]) => { + if (attachmentAdapter) addFilesViaAdapter(files) + else void addImageFiles(files) + }, + [addFilesViaAdapter, addImageFiles, attachmentAdapter], + ) + + const openPicker = useCallback(() => { + fileInputRef.current?.click() + }, []) + // --- Send / queue / stop. --- - const canSend = text.trim().length > 0 || images.length > 0 + const doneAttachments = pending.filter((s) => s.status === 'done').map((s) => s.attachment) + const canSend = + text.trim().length > 0 || images.length > 0 || doneAttachments.length > 0 const send = useCallback(() => { if (!canSend) return - const imgs = images.length > 0 ? images : undefined + const doneNow = pending.filter((s) => s.status === 'done').map((s) => s.attachment) + const attachmentImages: ChatImage[] = doneNow + .filter((a) => a.kind === 'image' && a.data) + .map((a) => ({ data: a.data as string, media_type: a.media_type || 'image/*', name: a.name })) + const allImages = [...images, ...attachmentImages] + const imgs = allImages.length > 0 ? allImages : undefined if (isRunning) { actions.enqueueMessage(text.trim(), imgs) } else { actions.sendMessage(text.trim(), imgs) } + if (doneNow.length > 0) onSendAttachments?.(doneNow) setText('') setImages([]) + setPending([]) onSent?.() - }, [actions, canSend, images, isRunning, onSent, text]) + }, [actions, canSend, images, isRunning, onSendAttachments, onSent, pending, text]) const stop = useCallback(() => actions.stop(), [actions]) + // --- Dictation. --- + const recognitionRef = useRef(null) + const dictBaseRef = useRef('') + const dictFinalRef = useRef('') + const dictationSupported = enableDictation && getSpeechRecognitionCtor() !== null + + const toggleDictation = useCallback(() => { + const Ctor = getSpeechRecognitionCtor() + if (!Ctor) return + if (recognitionRef.current) { + recognitionRef.current.stop() + return + } + const rec = new Ctor() + rec.continuous = true + rec.interimResults = true + if (dictationLang) rec.lang = dictationLang + dictFinalRef.current = '' + setText((prev) => { + dictBaseRef.current = prev ? prev.replace(/\s*$/, '') + ' ' : '' + return prev + }) + rec.onresult = (e: SpeechRecognitionEventLike) => { + let interim = '' + for (let i = e.resultIndex; i < e.results.length; i++) { + const r = e.results[i] + if (!r) continue + if (r.isFinal) dictFinalRef.current += r[0].transcript + else interim += r[0].transcript + } + setText(dictBaseRef.current + dictFinalRef.current + interim) + } + rec.onend = () => { + recognitionRef.current = null + setDictating(false) + } + rec.onerror = () => { + recognitionRef.current = null + setDictating(false) + } + recognitionRef.current = rec + setDictating(true) + rec.start() + }, [dictationLang]) + + useEffect( + () => () => { + recognitionRef.current?.stop() + recognitionRef.current = null + }, + [], + ) + // --- Key handling: Enter=send, Shift+Enter=newline, IME-safe, slash nav. --- const onKeyDown = useCallback( (e: KeyboardEvent) => { @@ -212,54 +553,109 @@ export function Composer({ [applySlash, filteredCommands, send, slashActive, slashOpen], ) - // --- Image attachments: paste + file picker + remove. - // Mirrors assistant-ui ComposerAttachments / ComposerAddAttachment, - // scoped to vision images (ChatImage base64) for agent backends. --- - const addImage = useCallback( - (img: ChatImage) => { - if (!allowImages) return - // Reject oversize by raw base64 length (~4/3 of bytes). - const approxBytes = (img.data.length * 3) / 4 - if (approxBytes > maxImageBytes) return - setImages((prev) => [...prev, img]) + const onPaste = useCallback( + (e: ClipboardEvent) => { + if (!attachmentsEnabled) return + const items = e.clipboardData?.items + if (!items) return + const files: File[] = [] + for (const it of items) { + // Adapter accepts any file kind; legacy path only images. + const wanted = attachmentAdapter ? it.kind === 'file' : it.kind === 'file' && it.type.startsWith('image/') + if (wanted) { + const file = it.getAsFile() + if (file) files.push(file) + } + } + if (files.length === 0) return + e.preventDefault() + ingestFiles(files) }, - [allowImages, maxImageBytes], + [attachmentAdapter, attachmentsEnabled, ingestFiles], ) - const addImageFiles = useCallback( - async (files: FileList | File[]) => { - if (!allowImages) return - for (const file of Array.from(files)) { - if (file.size > maxImageBytes) continue - const img = await readImageFile(file) - if (img) addImage(img) - } + + // --- Drag & drop. --- + const onDragOver = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + if (!Array.from(e.dataTransfer?.types ?? []).includes('Files')) return + e.preventDefault() }, - [addImage, allowImages, maxImageBytes], + [attachmentsEnabled], + ) + const onDragEnter = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + if (!Array.from(e.dataTransfer?.types ?? []).includes('Files')) return + e.preventDefault() + dragDepth.current += 1 + setDropping(true) + }, + [attachmentsEnabled], + ) + const onDragLeave = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + e.preventDefault() + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDropping(false) + }, + [attachmentsEnabled], + ) + const onDrop = useCallback( + (e: DragEvent) => { + if (!attachmentsEnabled) return + e.preventDefault() + dragDepth.current = 0 + setDropping(false) + const files = e.dataTransfer?.files + if (files && files.length > 0) ingestFiles(files) + }, + [attachmentsEnabled, ingestFiles], ) - const removeImage = useCallback((i: number) => { - setImages((prev) => prev.filter((_, idx) => idx !== i)) - }, []) - const openPicker = useCallback(() => { - fileInputRef.current?.click() - }, []) const mode: 'send' | 'stop' = isRunning ? 'stop' : 'send' - const onActivate = mode === 'send' ? send : stop - const addAttachmentControl = - allowImages - ? renderAddAttachment - ? renderAddAttachment(openPicker) - : ( - - ) - : null + const pickerAccept = attachmentAdapter ? attachmentAdapter.accept ?? '*/*' : acceptImages + + const addAttachmentControl = attachmentsEnabled + ? renderAddAttachment + ? renderAddAttachment(openPicker) + : ( + + ) + : null + + const dictationButton = dictationSupported + ? renderDictationButton + ? renderDictationButton({ listening: dictating }, toggleDictation) + : ( + + ) + : null return ( -
+
+ {dropping && renderDropOverlay?.()} {renderQueue?.(queued)} {renderSlashMenu?.({ open: !!slashOpen && filteredCommands.length > 0, @@ -267,30 +663,18 @@ export function Composer({ activeIndex: slashOpen ? slashActive : -1, apply: applySlash, })} - {allowImages && images.length > 0 && renderAttachments?.(images, removeImage)} + {allowImages && !attachmentAdapter && images.length > 0 && renderAttachments?.(images, removeImage)} + {attachmentAdapter && pending.length > 0 && renderPendingAttachments?.(pendingItems)}
{renderPrefix?.()} {addAttachmentControl} + {leadingControls}