From 89ff28e6b034f2ebcf734ecb297bc1f9af8ce852 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 9 Jul 2026 00:22:40 +0800 Subject: [PATCH 1/8] feat(packages): add jcode-ui + jcode-ui-core React component library Two-package monorepo for the reusable AI chat UI: - jcode-ui-core: framework-agnostic types (Message/ToolCall/Approval/ThreadItem), ChatRuntime abstraction + ExternalStoreRuntime (wraps any Redux-shaped store), MockRuntime (for demos/tests), ToolRendererRegistry (plugin seam), and headless React primitives (Thread with virtualization + auto-follow, MessageView, Composer, ToolCallView, ApprovalBlock, AskUserBlock). - jcode-ui: styled components wrapping the primitives with token-driven Tailwind 4 styling, the marked+highlight.js+DOMPurify markdown pipeline, and 9 default tool renderers (terminal/file-viewer/diff/search/todo/skill/team/browser-shot/generic). Both packages typecheck and build clean. Core dist + CSS bundle verified. Root pnpm-workspace.yaml added; .gitignore updated for node_modules/ and dist/. --- .gitignore | 10 + packages/jcode-ui-core/package.json | 85 ++ packages/jcode-ui-core/src/adapters/index.ts | 87 ++ packages/jcode-ui-core/src/hooks/index.ts | 100 +++ packages/jcode-ui-core/src/index.ts | 20 + .../src/primitives/ApprovalBlock.tsx | 104 +++ .../src/primitives/AskUserBlock.tsx | 234 +++++ .../jcode-ui-core/src/primitives/Composer.tsx | 253 ++++++ .../src/primitives/MessageView.tsx | 132 +++ .../jcode-ui-core/src/primitives/Thread.tsx | 218 +++++ .../src/primitives/ToolCallView.tsx | 141 +++ .../jcode-ui-core/src/primitives/index.ts | 6 + .../jcode-ui-core/src/runtime/context.tsx | 105 +++ .../src/runtime/externalStore.ts | 58 ++ packages/jcode-ui-core/src/runtime/index.ts | 95 ++ .../jcode-ui-core/src/runtime/mockRuntime.ts | 106 +++ packages/jcode-ui-core/src/types/index.ts | 188 ++++ packages/jcode-ui-core/tsconfig.build.json | 14 + packages/jcode-ui-core/tsconfig.json | 23 + packages/jcode-ui/package.json | 76 ++ .../src/components/ApprovalBanner.tsx | 185 ++++ .../jcode-ui/src/components/AskUserCard.tsx | 109 +++ .../jcode-ui/src/components/ChatInput.tsx | 129 +++ .../jcode-ui/src/components/ContextBar.tsx | 111 +++ packages/jcode-ui/src/components/Message.tsx | 103 +++ packages/jcode-ui/src/components/Thread.tsx | 70 ++ .../jcode-ui/src/components/ToolCallCard.tsx | 107 +++ .../src/components/ToolRegistryContext.tsx | 63 ++ packages/jcode-ui/src/index.ts | 58 ++ packages/jcode-ui/src/lib/apiBaseContext.tsx | 19 + packages/jcode-ui/src/lib/markdown.ts | 37 + packages/jcode-ui/src/styles/animations.css | 205 +++++ packages/jcode-ui/src/styles/components.css | 107 +++ packages/jcode-ui/src/styles/entry.css | 37 + packages/jcode-ui/src/styles/tokens.css | 263 ++++++ .../src/toolRenderers/browserShot.tsx | 28 + packages/jcode-ui/src/toolRenderers/diff.tsx | 82 ++ .../jcode-ui/src/toolRenderers/fileViewer.tsx | 69 ++ .../jcode-ui/src/toolRenderers/generic.tsx | 41 + packages/jcode-ui/src/toolRenderers/index.ts | 9 + .../jcode-ui/src/toolRenderers/search.tsx | 55 ++ packages/jcode-ui/src/toolRenderers/skill.tsx | 31 + packages/jcode-ui/src/toolRenderers/team.tsx | 102 +++ .../jcode-ui/src/toolRenderers/terminal.tsx | 42 + packages/jcode-ui/src/toolRenderers/todo.tsx | 88 ++ packages/jcode-ui/tsconfig.build.json | 14 + packages/jcode-ui/tsconfig.json | 23 + pnpm-lock.yaml | 846 ++++++++++++++++++ pnpm-workspace.yaml | 14 + 49 files changed, 5102 insertions(+) create mode 100644 packages/jcode-ui-core/package.json create mode 100644 packages/jcode-ui-core/src/adapters/index.ts create mode 100644 packages/jcode-ui-core/src/hooks/index.ts create mode 100644 packages/jcode-ui-core/src/index.ts create mode 100644 packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx create mode 100644 packages/jcode-ui-core/src/primitives/AskUserBlock.tsx create mode 100644 packages/jcode-ui-core/src/primitives/Composer.tsx create mode 100644 packages/jcode-ui-core/src/primitives/MessageView.tsx create mode 100644 packages/jcode-ui-core/src/primitives/Thread.tsx create mode 100644 packages/jcode-ui-core/src/primitives/ToolCallView.tsx create mode 100644 packages/jcode-ui-core/src/primitives/index.ts create mode 100644 packages/jcode-ui-core/src/runtime/context.tsx create mode 100644 packages/jcode-ui-core/src/runtime/externalStore.ts create mode 100644 packages/jcode-ui-core/src/runtime/index.ts create mode 100644 packages/jcode-ui-core/src/runtime/mockRuntime.ts create mode 100644 packages/jcode-ui-core/src/types/index.ts create mode 100644 packages/jcode-ui-core/tsconfig.build.json create mode 100644 packages/jcode-ui-core/tsconfig.json create mode 100644 packages/jcode-ui/package.json create mode 100644 packages/jcode-ui/src/components/ApprovalBanner.tsx create mode 100644 packages/jcode-ui/src/components/AskUserCard.tsx create mode 100644 packages/jcode-ui/src/components/ChatInput.tsx create mode 100644 packages/jcode-ui/src/components/ContextBar.tsx create mode 100644 packages/jcode-ui/src/components/Message.tsx create mode 100644 packages/jcode-ui/src/components/Thread.tsx create mode 100644 packages/jcode-ui/src/components/ToolCallCard.tsx create mode 100644 packages/jcode-ui/src/components/ToolRegistryContext.tsx create mode 100644 packages/jcode-ui/src/index.ts create mode 100644 packages/jcode-ui/src/lib/apiBaseContext.tsx create mode 100644 packages/jcode-ui/src/lib/markdown.ts create mode 100644 packages/jcode-ui/src/styles/animations.css create mode 100644 packages/jcode-ui/src/styles/components.css create mode 100644 packages/jcode-ui/src/styles/entry.css create mode 100644 packages/jcode-ui/src/styles/tokens.css create mode 100644 packages/jcode-ui/src/toolRenderers/browserShot.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/diff.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/fileViewer.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/generic.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/index.ts create mode 100644 packages/jcode-ui/src/toolRenderers/search.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/skill.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/team.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/terminal.tsx create mode 100644 packages/jcode-ui/src/toolRenderers/todo.tsx create mode 100644 packages/jcode-ui/tsconfig.build.json create mode 100644 packages/jcode-ui/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 33735c01..d1eb057b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,16 @@ internal/model/registry_generated.go web/src/styles/tokens.generated.css web/src/composables/themes.generated.ts +# Node / pnpm (root monorepo + per-app workspaces) +node_modules/ +**/node_modules/ +*.tsbuildinfo +packages/*/dist/ +web-react/dist/ + +# Lockfile — committed at repo root for the monorepo +# (pnpm-lock.yaml IS committed; do NOT ignore it) + # Jekyll docs/_site/ docs/.jekyll-cache/ diff --git a/packages/jcode-ui-core/package.json b/packages/jcode-ui-core/package.json new file mode 100644 index 00000000..86a388a6 --- /dev/null +++ b/packages/jcode-ui-core/package.json @@ -0,0 +1,85 @@ +{ + "name": "jcode-ui-core", + "version": "0.1.0", + "description": "Framework-agnostic core for jcode-ui: types, chat runtime abstraction, and headless React primitives for AI chat interfaces.", + "type": "module", + "license": "MIT", + "author": "jack", + "homepage": "https://www.j-code.net/docs/chat-ui", + "repository": { + "type": "git", + "url": "https://github.com/cnjack/jcode", + "directory": "packages/jcode-ui-core" + }, + "keywords": [ + "ai", + "chat", + "react", + "agent", + "llm", + "ui", + "components", + "headless" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js" + }, + "./primitives": { + "types": "./dist/primitives/index.d.ts", + "import": "./dist/primitives/index.js" + }, + "./adapters": { + "types": "./dist/adapters/index.d.ts", + "import": "./dist/adapters/index.js" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js" + }, + "./types": { + "types": "./dist/types/index.d.ts", + "import": "./dist/types/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "typecheck": "tsc --noEmit -p tsconfig.json", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "dependencies": { + "@tanstack/react-virtual": "^3.13.12" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.9.2" + } +} diff --git a/packages/jcode-ui-core/src/adapters/index.ts b/packages/jcode-ui-core/src/adapters/index.ts new file mode 100644 index 00000000..d7823895 --- /dev/null +++ b/packages/jcode-ui-core/src/adapters/index.ts @@ -0,0 +1,87 @@ +/** + * Tool renderer registry — the plugin seam for tool-call visualization. + * + * `ToolCallCard` doesn't know how to render any specific tool. Instead it looks + * up a renderer by `tool.name` in a `ToolRendererRegistry`. jcode-ui ships + * default renderers (terminal/file-viewer/diff/search/…) as a preset; consumers + * override or extend with their own. This is what makes the component reusable + * across agents with completely different tool surfaces. + */ + +import type { ComponentType } from 'react' +import type { ToolCall, ToolDisplayInfo, ToolStatus } from '../types/index.js' + +export type { ToolStatus } + +/** Props every tool renderer receives. */ +export interface ToolRendererProps { + /** Logical tool name (e.g. 'execute', 'read', 'edit', 'grep', …). */ + name: string + /** Raw args JSON string. Renderers parse what they need. */ + args: string + /** Raw output string (may be omitted while running). */ + output?: string + /** Clean display output (backend metadata stripped). */ + displayOutput?: string + /** Error string if the tool failed. */ + error?: string + status: ToolStatus + /** Pre-extracted display metadata (title/subtitle/icon). May be absent. */ + displayInfo?: ToolDisplayInfo + /** Nested subagent calls — renderers decide whether to recurse. */ + children?: ToolCall[] +} + +/** A tool renderer is just a React component. */ +export type ToolRenderer = ComponentType + +/** + * Name-keyed registry of tool renderers, with a fallback. Lookups are + * case-sensitive and exact (no globbing) — keep tool names stable. + * + * Register a single renderer, or a whole map at once. The registry is mutable + * so consumers can register at app bootstrap and add more later. + */ +export class ToolRendererRegistry { + private renderers = new Map() + private fallback: ToolRenderer | null = null + + /** Register a renderer for one or more tool names (later writes win). */ + register(name: string, renderer: ToolRenderer): this + register(names: string[], renderer: ToolRenderer): this + register(nameOrNames: string | string[], renderer: ToolRenderer): this { + const names = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames] + for (const n of names) this.renderers.set(n, renderer) + return this + } + + /** Register a batch of { name → renderer } entries. */ + registerAll(entries: Record): this { + for (const [name, renderer] of Object.entries(entries)) { + this.renderers.set(name, renderer) + } + return this + } + + /** Set the renderer used when no name-specific match exists. */ + setFallback(renderer: ToolRenderer): this { + this.fallback = renderer + return this + } + + /** Look up a renderer by tool name, falling back if absent. Returns null + * only when nothing is registered AND no fallback is set. */ + get(name: string): ToolRenderer | null { + return this.renderers.get(name) ?? this.fallback + } + + /** True if a name-specific renderer is registered. */ + has(name: string): boolean { + return this.renderers.has(name) + } +} + +/** Create a fresh registry. Convenience over `new` for chained registration. */ +export function createToolRendererRegistry(): ToolRendererRegistry { + return new ToolRendererRegistry() +} diff --git a/packages/jcode-ui-core/src/hooks/index.ts b/packages/jcode-ui-core/src/hooks/index.ts new file mode 100644 index 00000000..4be1daa4 --- /dev/null +++ b/packages/jcode-ui-core/src/hooks/index.ts @@ -0,0 +1,100 @@ +/** + * Behavioral hooks for chat UI primitives. These contain the interaction logic + * the Vue version baked into App.vue (scroll tracking, type-ahead draining, + * etc.) but framework-correct and reusable. + */ + +import { useCallback, useEffect, useRef } from 'react' +import { useRuntimeState } from '../runtime/context.js' + +/** + * Auto-scroll-to-bottom tracking: reports whether the user is "at the bottom" + * of a scroll container (within `threshold` px of the bottom edge). When at the + * bottom, streaming content auto-follows; when scrolled up, it does NOT yank the + * user back down (the core streaming-UX contract from the Vue version). + * + * Returns the container ref to attach, the live `isAtBottom` flag, and a + * `scrollToBottom` imperative. The caller decides when to call the latter + * (typically on send, and on new content if `isAtBottom`). + */ +export function useAutoScroll(threshold = 80) { + const ref = useRef(null) + const isAtBottomRef = useRef(true) + + /** Imperatively scroll to the bottom edge. `behavior` defaults to 'auto' + * (instant) since this is called mid-stream. */ + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => { + const el = ref.current + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior }) + isAtBottomRef.current = true + }, []) + + /** Attach to the container's onScroll (or wire a listener). Updates the flag. */ + const onScroll = useCallback(() => { + const el = ref.current + if (!el) return + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + isAtBottomRef.current = distance <= threshold + }, [threshold]) + + /** Read the current flag. Use this in effects; for render, prefer the + * `useIsAtBottom` hook below which re-renders on change. */ + const getIsAtBottom = useCallback(() => isAtBottomRef.current, []) + + return { ref, onScroll, scrollToBottom, getIsAtBottom, isAtBottomRef } +} + +/** + * Re-render-friendly version of the at-bottom flag: re-renders the component + * when the flag flips. Use sparingly (the scroll handler runs a lot); for most + * cases the imperative `getIsAtBottom` + an effect is enough. + * + * NOTE: this intentionally tracks a coarse boolean — it only re-renders on + * crossing the threshold, not on every scroll event. + */ +export function useIsAtBottom(threshold = 80) { + const { ref, onScroll, scrollToBottom } = useAutoScroll(threshold) + return { ref, onScroll, scrollToBottom } +} + +/** + * Stream-follow effect: when the runtime emits new/changed items, scroll to + * bottom ONLY if the user was already at the bottom. This is the declarative + * form of the Vue watch on `timeline.length + lastMessage.content.length`. + * + * `dep` should be a value that changes whenever there's new content to follow + * (e.g. items length, or last-item content length). + */ +export function useStreamFollow( + autoScroll: ReturnType>, + dep: unknown, +) { + const { getIsAtBottom, scrollToBottom } = autoScroll + useEffect(() => { + if (getIsAtBottom()) scrollToBottom('auto') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dep]) +} + +/** + * Auto-focus a ref on mount and when `isRunning` flips false (the Vue version + * refocuses the composer when a turn ends). + */ +export function useFocusOnIdle(isRunning: boolean) { + const ref = useRef(null) + useEffect(() => { + if (!isRunning) ref.current?.focus() + }, [isRunning]) + return ref +} + +/** + * Track + drain the type-ahead queue: returns the current queued messages. + * Draining is the runtime's job (it sends the next queued message on each turn + * end); this hook just surfaces the queue for rendering. + */ +export function useQueuedMessages() { + const { queued } = useRuntimeState() + return queued +} diff --git a/packages/jcode-ui-core/src/index.ts b/packages/jcode-ui-core/src/index.ts new file mode 100644 index 00000000..3f8e1243 --- /dev/null +++ b/packages/jcode-ui-core/src/index.ts @@ -0,0 +1,20 @@ +/** + * jcode-ui-core — the framework-agnostic core of jcode-ui. + * + * Layers: + * - types : Message / ToolCall / Approval / ThreadItem / TokenSnapshot … + * - runtime : ChatRuntime interface + ExternalStoreRuntime + MockRuntime + * + React + useRuntimeState/useRuntimeSelector hooks + * - adapters : ToolRendererRegistry (the tool-call plugin seam) + * - primitives : headless React components (Thread/MessageView/Composer/…) + * - hooks : useAutoScroll / useStreamFollow / useFocusOnIdle … + * + * This entry re-exports everything for convenience. For tree-shaking, prefer + * the subpath imports: `jcode-ui-core/runtime`, `jcode-ui-core/primitives`, … + */ + +export * from './types/index.js' +export * from './runtime/index.js' +export * from './adapters/index.js' +export * from './hooks/index.js' +export * from './primitives/index.js' diff --git a/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx new file mode 100644 index 00000000..6236b1d9 --- /dev/null +++ b/packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx @@ -0,0 +1,104 @@ +/** + * 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). + * + * The `resolving` flag on the approval object disables controls while a resolve + * request is in flight (prevents double-submit). + */ + +import { useState } from 'react' +import type { ReactNode } from 'react' +import type { Approval } from '../types/index.js' +import { useRuntimeActions } from '../runtime/context.js' + +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 + /** Render the resolved inline note. */ + renderResolved?: (approval: Approval) => ReactNode +} + +export interface ApprovalBlockProps extends ApprovalBlockRenderSlots { + approval: Approval + /** className passthrough. */ + className?: string +} + +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). + const [armed, setArmed] = useState(false) + + if (approval.resolved) { + return
{renderResolved?.(approval) ?? }
+ } + + const allowOnce = () => actions.resolveApproval(approval.id, true, false) + const allowAllArm = () => setArmed(true) + const allowAllConfirm = () => actions.resolveApproval(approval.id, true, true) + const allowAllCancel = () => setArmed(false) + const deny = () => actions.resolveApproval(approval.id, false, false) + + return ( +
+ {renderPending?.(approval, { allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed }) ?? + DefaultPending({ approval, allowOnce, allowAllArm, allowAllConfirm, allowAllCancel, deny, armed })} +
+ ) +} + +function DefaultResolved({ approval }: { approval: Approval }): ReactNode { + return ( + + {approval.approved ? '✓ allowed' : '✗ denied'} · {approval.tool_name} + + ) +} + +function DefaultPending(args: { + approval: Approval + allowOnce: () => void + allowAllArm: () => void + allowAllConfirm: () => void + allowAllCancel: () => void + deny: () => void + armed: boolean +}): 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 ? ( + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx new file mode 100644 index 00000000..63f8f266 --- /dev/null +++ b/packages/jcode-ui-core/src/primitives/AskUserBlock.tsx @@ -0,0 +1,234 @@ +/** + * AskUserBlock — the headless interactive question block. + * + * Owns: the pending/resolved split, per-question selection state (single + + * multi-select), free-text "Other" input, digit-key shortcuts (1-9), and + * dispatching via runtime actions. Does NOT own styling or the output-format + * parsing for resolved display (those live in the styled wrapper). + * + * The `renderPending` slot receives a `controls` object exposing the live + * `selected`/`other` maps plus mutators (`toggleOption`, `setOther`) and the + * `submit`/`skip` actions — so a styled consumer needs no local state. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { AskUserQuestion, AskUserAnswer, ToolCall } from '../types/index.js' +import { useRuntimeActions } from '../runtime/context.js' + +export interface AskUserState { + /** Per-question-header selected labels (single-select: one entry; multi: N). */ + selected: Record + /** Per-question-header free-text "Other" value. */ + other: Record +} + +/** Controls handed to the pending render-prop. */ +export interface AskUserControls { + /** Current selection map (question key → labels). */ + selected: Record + /** Current "Other" text map (question key → free text). */ + other: Record + /** Toggle an option. Honors multi_select. */ + toggleOption: (question: AskUserQuestion, label: string) => void + /** Set the free-text value for a question. */ + setOther: (question: AskUserQuestion, value: string) => void + /** Submit the current selections (no-op if nothing chosen per question). */ + submit: () => void + /** Submit empty answers (skip). */ + skip: () => void +} + +export interface AskUserBlockRenderSlots { + /** Render the pending interactive card. */ + renderPending?: (questions: AskUserQuestion[], controls: AskUserControls) => ReactNode + /** Render the resolved (replay) view. */ + renderResolved?: (tool: ToolCall, answers: AskUserAnswer[]) => ReactNode +} + +export interface AskUserBlockProps extends AskUserBlockRenderSlots { + tool: ToolCall + /** className passthrough. */ + className?: string +} + +const EMPTY_STATE: AskUserState = { selected: {}, other: {} } + +export function AskUserBlock({ tool, className, renderPending, renderResolved }: AskUserBlockProps): ReactNode { + const actions = useRuntimeActions() + const questions = useMemo(() => extractQuestions(tool), [tool]) + const isPending = !!tool.askUserId && tool.status === 'running' && !tool.output + + const [state, setState] = useState(EMPTY_STATE) + + const keyOf = useCallback((q: AskUserQuestion) => q.header ?? q.question, []) + + const toggleOption = useCallback( + (q: AskUserQuestion, label: string) => { + const key = keyOf(q) + setState((s) => ({ + ...s, + selected: q.multi_select + ? { ...s.selected, [key]: toggle(s.selected[key], label) } + : { ...s.selected, [key]: [label] }, + })) + }, + [keyOf], + ) + + const setOther = useCallback( + (q: AskUserQuestion, value: string) => { + const key = keyOf(q) + setState((s) => ({ ...s, other: { ...s.other, [key]: value } })) + }, + [keyOf], + ) + + const submit = useCallback(() => { + const answers: AskUserAnswer[] = questions.map((q) => { + const key = keyOf(q) + const sel = state.selected[key] ?? [] + const other = state.other[key] ?? '' + return { + question_header: key, + answer: sel.length > 0 ? sel.join(', ') : other, + selected: sel.length > 0 ? sel : undefined, + } + }) + if (tool.askUserId) actions.submitAskUser(tool.askUserId, answers) + }, [actions, keyOf, questions, state, tool.askUserId]) + + const skip = useCallback(() => { + if (tool.askUserId) actions.submitAskUser(tool.askUserId, []) + }, [actions, tool.askUserId]) + + // Digit-key shortcuts (1-9) select an option for the first unanswered question. + useEffect(() => { + if (!isPending) return + function onKey(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + const n = Number(e.key) + if (!Number.isInteger(n) || n < 1 || n > 9) return + const q = questions.find((qq) => (state.selected[keyOf(qq)]?.length ?? 0) === 0) + if (!q?.options || n > q.options.length) return + e.preventDefault() + toggleOption(q, q.options[n - 1].label) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [isPending, questions, state.selected, keyOf, toggleOption]) + + if (!isPending) { + const answers = parseResolvedAnswers(tool) + return
{renderResolved?.(tool, answers) ?? }
+ } + + const controls: AskUserControls = { + selected: state.selected, + other: state.other, + toggleOption, + setOther, + submit, + skip, + } + + return ( +
+ {renderPending?.(questions, controls) ?? } +
+ ) +} + +/** Extract the questions list from tool fields (with fallbacks). */ +function extractQuestions(tool: ToolCall): AskUserQuestion[] { + if (tool.askUserQuestions && tool.askUserQuestions.length > 0) return tool.askUserQuestions + try { + const parsed = JSON.parse(tool.args) + if (Array.isArray(parsed.questions)) return parsed.questions as AskUserQuestion[] + if (parsed.question) { + // legacy single-question shape + return [{ question: parsed.question, options: parsed.options ?? [] }] + } + } catch { + // ignore + } + return [] +} + +/** Best-effort parse of a resolved tool's output into answers (for replay). */ +export function parseResolvedAnswers(tool: ToolCall): AskUserAnswer[] { + if (!tool.output) return [] + try { + const parsed = JSON.parse(tool.output) + if (Array.isArray(parsed.answers)) return parsed.answers as AskUserAnswer[] + } catch { + // fall through to text parse + } + // "User's answer: X" form. + const m = tool.output.match(/User'?s answer:\s*(.+)/i) + if (m) return [{ question_header: '', answer: m[1].trim() }] + return [] +} + +function toggle(arr: string[] | undefined, label: string): string[] { + const set = new Set(arr ?? []) + if (set.has(label)) set.delete(label) + else set.add(label) + return [...set] +} + +function DefaultPending({ questions, controls }: { questions: AskUserQuestion[]; controls: AskUserControls }): ReactNode { + return ( +
+ {questions.map((q, qi) => { + const key = q.header ?? q.question + const sel = controls.selected[key] ?? [] + return ( +
+ {q.header &&
{q.header}
} +
{q.question}
+ {(q.options ?? []).map((opt, oi) => { + const active = sel.includes(opt.label) + return ( + + ) + })} + controls.setOther(q, e.target.value)} + /> +
+ ) + })} +
+ + +
+
+ ) +} + +function DefaultResolved({ answers }: { tool: ToolCall; answers: AskUserAnswer[] }): ReactNode { + if (answers.length === 0) return · no answer + return ( +
    + {answers.map((a, i) => ( +
  • + {a.question_header ? `${a.question_header}: ` : ''} + {a.answer} +
  • + ))} +
+ ) +} diff --git a/packages/jcode-ui-core/src/primitives/Composer.tsx b/packages/jcode-ui-core/src/primitives/Composer.tsx new file mode 100644 index 00000000..b4d83683 --- /dev/null +++ b/packages/jcode-ui-core/src/primitives/Composer.tsx @@ -0,0 +1,253 @@ +/** + * 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). + * + * 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. + */ + +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { KeyboardEvent, ReactNode } from 'react' +import { useRuntimeActions, useRuntimeState } from '../runtime/context.js' +import type { ChatImage } from '../types/index.js' + +export interface SlashCommand { + /** The literal text inserted when chosen (e.g. '/goal'). */ + slash: string + description?: string +} + +export interface ComposerRenderSlots { + /** Render the slash-command dropdown when `slashState` is open. */ + renderSlashMenu?: (state: SlashMenuState) => ReactNode + /** Render queued-message chips above the textarea. */ + renderQueue?: (queued: { id: string; text: string; images?: ChatImage[] }[]) => ReactNode + /** Render the send/stop button. `mode` is 'send' or 'stop'. */ + renderSubmitButton?: (mode: 'send' | 'stop', disabled: boolean) => ReactNode + /** Render attached-image thumbnails below the textarea. */ + renderAttachments?: (imgs: ChatImage[], remove: (i: number) => void) => ReactNode + /** Optional content rendered before the textarea inside the input row + * (e.g. a "+" menu button). */ + renderPrefix?: () => ReactNode + /** Optional content rendered after the textarea (e.g. a context ring). */ + renderSuffix?: () => ReactNode +} + +export interface ComposerProps extends ComposerRenderSlots { + /** Placeholder text. */ + placeholder?: string + /** Max textarea height in px before it scrolls internally. */ + maxRows?: number + /** Slash commands (fetched by the host). Empty/undefined disables the menu. */ + slashCommands?: SlashCommand[] + /** Whether image attachments are allowed (gated by model vision support). */ + allowImages?: boolean + /** Max image size in bytes (default 10MB). */ + maxImageBytes?: number + /** aria-label for the textarea. */ + ariaLabel?: string + /** Controlled initial value (uncontrolled thereafter). */ + defaultValue?: string + /** className passthrough. */ + className?: string + /** Callback after a message is sent or queued (host snaps timeline to bottom). */ + onSent?: () => void +} + +export interface SlashMenuState { + open: boolean + /** Filtered commands for the current input. */ + commands: SlashCommand[] + /** Active (highlighted) index, or -1. */ + activeIndex: number + /** Apply a command: inserts its slash text at the caret. */ + apply: (cmd: SlashCommand) => void +} + +const DEFAULT_MAX_ROWS_PX = 160 + +export function Composer({ + placeholder = 'Send a message…', + maxRows = DEFAULT_MAX_ROWS_PX, + slashCommands, + allowImages = false, + maxImageBytes = 10 * 1024 * 1024, + ariaLabel = 'Message input', + defaultValue = '', + className, + onSent, + renderSlashMenu, + renderQueue, + renderSubmitButton, + renderAttachments, + renderPrefix, + renderSuffix, +}: ComposerProps): ReactNode { + const actions = useRuntimeActions() + const { isRunning, queued } = useRuntimeState() + const [text, setText] = useState(defaultValue) + const [images, setImages] = useState([]) + const textareaRef = useRef(null) + + // --- Autosize: grow with content up to maxRows, then scroll. --- + useLayoutEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, maxRows)}px` + }, [text, maxRows]) + + // --- Slash command menu: open when text starts with '/' and matches. --- + const slashOpen = slashCommands && slashCommands.length > 0 && text.startsWith('/') && !text.includes(' ') + const slashQuery = slashOpen ? text.slice(1).toLowerCase() : '' + const filteredCommands = (slashCommands ?? []).filter((c) => c.slash.slice(1).toLowerCase().startsWith(slashQuery)) + const [slashActive, setSlashActive] = useState(0) + // Reset active index when the filter changes. + const filterKey = filteredCommands.map((c) => c.slash).join('|') + const lastFilterKey = useRef(filterKey) + if (lastFilterKey.current !== filterKey) { + lastFilterKey.current = filterKey + if (slashActive >= filteredCommands.length) setSlashActive(0) + } + + const applySlash = useCallback((cmd: SlashCommand) => { + setText(cmd.slash + ' ') + textareaRef.current?.focus() + }, []) + + // --- Send / queue / stop. --- + const canSend = text.trim().length > 0 || images.length > 0 + const send = useCallback(() => { + if (!canSend) return + const imgs = images.length > 0 ? images : undefined + if (isRunning) { + actions.enqueueMessage(text.trim(), imgs) + } else { + actions.sendMessage(text.trim(), imgs) + } + setText('') + setImages([]) + onSent?.() + }, [actions, canSend, images, isRunning, onSent, text]) + + const stop = useCallback(() => actions.stop(), [actions]) + + // --- Key handling: Enter=send, Shift+Enter=newline, IME-safe, slash nav. --- + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + // IME composition: never hijack. + if (e.nativeEvent.isComposing || e.keyCode === 229) return + + if (slashOpen && filteredCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setSlashActive((i) => (i + 1) % filteredCommands.length) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setSlashActive((i) => (i - 1 + filteredCommands.length) % filteredCommands.length) + return + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + applySlash(filteredCommands[slashActive]) + return + } + if (e.key === 'Escape') { + e.preventDefault() + setText('') + return + } + } + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } + }, + [applySlash, filteredCommands, send, slashActive, slashOpen], + ) + + // --- Image attachment: paste + remove. File picking is left to the host + // (it needs a file input + app-specific UX); addImage accepts a ready + // ChatImage. --- + 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]) + }, + [allowImages, maxImageBytes], + ) + const removeImage = useCallback((i: number) => { + setImages((prev) => prev.filter((_, idx) => idx !== i)) + }, []) + + const mode: 'send' | 'stop' = isRunning ? 'stop' : 'send' + + return ( +
+ {renderQueue?.(queued)} + {renderSlashMenu?.({ + open: !!slashOpen && filteredCommands.length > 0, + commands: filteredCommands, + activeIndex: slashOpen ? slashActive : -1, + apply: applySlash, + })} +
+ {renderPrefix?.()} +