From 90bf01c0b350323264d067d48973f34b7642a988 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 01:22:08 +0800 Subject: [PATCH] feat(jcode-ui): polish chat UI, docs demos, and attachments Raise code/card contrast, fix dark-token inheritance for tool subtitles, unify image attachments across library/product/docs, and expand the jcode-ui docs site with live component demos, API pages, and examples. --- examples/README.md | 26 + examples/jcode-ui-minimal/.gitignore | 4 + examples/jcode-ui-minimal/.npmrc | 1 + examples/jcode-ui-minimal/README.md | 30 + examples/jcode-ui-minimal/index.html | 12 + examples/jcode-ui-minimal/package.json | 24 + examples/jcode-ui-minimal/pnpm-workspace.yaml | 7 + examples/jcode-ui-minimal/src/App.tsx | 116 + examples/jcode-ui-minimal/src/main.tsx | 11 + examples/jcode-ui-minimal/src/styles.css | 45 + examples/jcode-ui-minimal/tsconfig.json | 14 + examples/jcode-ui-minimal/tsconfig.node.json | 12 + examples/jcode-ui-minimal/vite.config.ts | 7 + examples/jcode-ui-zustand/.gitignore | 4 + examples/jcode-ui-zustand/.npmrc | 1 + examples/jcode-ui-zustand/README.md | 42 + examples/jcode-ui-zustand/index.html | 12 + examples/jcode-ui-zustand/package.json | 25 + examples/jcode-ui-zustand/pnpm-workspace.yaml | 7 + examples/jcode-ui-zustand/src/App.tsx | 90 + examples/jcode-ui-zustand/src/main.tsx | 11 + examples/jcode-ui-zustand/src/store.ts | 135 + examples/jcode-ui-zustand/src/styles.css | 40 + examples/jcode-ui-zustand/tsconfig.json | 14 + examples/jcode-ui-zustand/vite.config.ts | 7 + packages/jcode-ui-core/README.md | 2 +- packages/jcode-ui-core/package.json | 2 +- .../jcode-ui-core/src/primitives/Composer.tsx | 120 +- .../jcode-ui-core/src/primitives/Thread.tsx | 126 +- .../jcode-ui-core/src/runtime/mockRuntime.ts | 105 +- packages/jcode-ui-core/src/types/index.ts | 10 +- packages/jcode-ui/README.md | 18 +- packages/jcode-ui/package.json | 3 +- .../src/components/ApprovalBanner.tsx | 95 +- .../jcode-ui/src/components/AskUserCard.tsx | 54 +- .../jcode-ui/src/components/Attachment.tsx | 179 +- .../jcode-ui/src/components/ChatInput.tsx | 82 +- packages/jcode-ui/src/components/Message.tsx | 79 +- .../jcode-ui/src/components/Reasoning.tsx | 13 +- packages/jcode-ui/src/components/Sources.tsx | 25 +- packages/jcode-ui/src/components/Thread.tsx | 46 +- .../jcode-ui/src/components/ToolCallCard.tsx | 18 +- packages/jcode-ui/src/index.ts | 3 + packages/jcode-ui/src/styles/animations.css | 26 +- packages/jcode-ui/src/styles/components.css | 935 ++++++- packages/jcode-ui/src/styles/entry.css | 29 +- packages/jcode-ui/src/styles/tokens.css | 77 +- packages/jcode-ui/src/toolRenderers/diff.tsx | 2 +- .../jcode-ui/src/toolRenderers/fileViewer.tsx | 4 +- .../jcode-ui/src/toolRenderers/terminal.tsx | 34 +- script/generate_jcode_ui_api_docs.mjs | 254 ++ site/docs/chat-ui/api.md | 43 + site/docs/chat-ui/api/components.md | 30 + site/docs/chat-ui/api/generated.md | 2379 +++++++++++++++++ site/docs/chat-ui/api/hooks.md | 47 + site/docs/chat-ui/api/primitives.md | 90 + site/docs/chat-ui/api/runtime.md | 105 + site/docs/chat-ui/api/types.md | 207 ++ site/docs/chat-ui/components.md | 318 +-- .../chat-ui/components/approval-banner.md | 56 + site/docs/chat-ui/components/ask-user-card.md | 50 + site/docs/chat-ui/components/attachment.md | 108 + site/docs/chat-ui/components/chat-input.md | 70 + site/docs/chat-ui/components/context-bar.md | 49 + site/docs/chat-ui/components/message.md | 64 + site/docs/chat-ui/components/reasoning.md | 35 + site/docs/chat-ui/components/sources.md | 36 + site/docs/chat-ui/components/thread.md | 74 + .../docs/chat-ui/components/tool-call-card.md | 65 + site/docs/chat-ui/examples.md | 39 + site/docs/chat-ui/guides.md | 17 + site/docs/chat-ui/guides/approvals.md | 53 + .../chat-ui/guides/custom-tool-renderer.md | 70 + site/docs/chat-ui/guides/external-store.md | 94 + site/docs/chat-ui/guides/slash-commands.md | 50 + site/docs/chat-ui/guides/virtualization.md | 41 + site/docs/chat-ui/index.md | 29 +- site/docs/chat-ui/installation.md | 192 ++ site/docs/chat-ui/primitives.md | 44 +- site/docs/chat-ui/runtime.md | 73 +- site/docs/chat-ui/theming.md | 49 +- site/docs/chat-ui/tool-renderers.md | 53 +- site/package.json | 4 +- site/pnpm-lock.yaml | 3 + site/public/llms.txt | 60 + site/src/App.tsx | 5 + site/src/lib/chatUiDocs.ts | 122 +- site/src/lib/docs.ts | 4 +- site/src/main.tsx | 2 +- site/src/pages/ChatUIPage.tsx | 60 +- site/src/pages/chatui.css | 76 +- site/src/pages/chatui/ChatUiDocPage.tsx | 129 +- site/src/pages/chatui/ChatUiDocsIndex.tsx | 59 +- site/src/pages/chatui/ChatUiDocsLayout.tsx | 74 +- site/src/pages/docs/docs.css | 82 +- site/src/playground/ChatDemo.tsx | 43 +- site/src/playground/ComponentDemo.tsx | 620 +++++ site/src/playground/component-demo.css | 573 ++++ site/src/playground/demoSources.ts | 349 +++ site/src/playground/highlightDemoCode.ts | 41 + site/vite.config.ts | 39 + web/src/components/ChatInput.tsx | 39 +- web/src/lib/types.ts | 2 + web/src/styles.css | 18 +- 104 files changed, 9064 insertions(+), 1008 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/jcode-ui-minimal/.gitignore create mode 100644 examples/jcode-ui-minimal/.npmrc create mode 100644 examples/jcode-ui-minimal/README.md create mode 100644 examples/jcode-ui-minimal/index.html create mode 100644 examples/jcode-ui-minimal/package.json create mode 100644 examples/jcode-ui-minimal/pnpm-workspace.yaml create mode 100644 examples/jcode-ui-minimal/src/App.tsx create mode 100644 examples/jcode-ui-minimal/src/main.tsx create mode 100644 examples/jcode-ui-minimal/src/styles.css create mode 100644 examples/jcode-ui-minimal/tsconfig.json create mode 100644 examples/jcode-ui-minimal/tsconfig.node.json create mode 100644 examples/jcode-ui-minimal/vite.config.ts create mode 100644 examples/jcode-ui-zustand/.gitignore create mode 100644 examples/jcode-ui-zustand/.npmrc create mode 100644 examples/jcode-ui-zustand/README.md create mode 100644 examples/jcode-ui-zustand/index.html create mode 100644 examples/jcode-ui-zustand/package.json create mode 100644 examples/jcode-ui-zustand/pnpm-workspace.yaml create mode 100644 examples/jcode-ui-zustand/src/App.tsx create mode 100644 examples/jcode-ui-zustand/src/main.tsx create mode 100644 examples/jcode-ui-zustand/src/store.ts create mode 100644 examples/jcode-ui-zustand/src/styles.css create mode 100644 examples/jcode-ui-zustand/tsconfig.json create mode 100644 examples/jcode-ui-zustand/vite.config.ts create mode 100644 script/generate_jcode_ui_api_docs.mjs create mode 100644 site/docs/chat-ui/api.md create mode 100644 site/docs/chat-ui/api/components.md create mode 100644 site/docs/chat-ui/api/generated.md create mode 100644 site/docs/chat-ui/api/hooks.md create mode 100644 site/docs/chat-ui/api/primitives.md create mode 100644 site/docs/chat-ui/api/runtime.md create mode 100644 site/docs/chat-ui/api/types.md create mode 100644 site/docs/chat-ui/components/approval-banner.md create mode 100644 site/docs/chat-ui/components/ask-user-card.md create mode 100644 site/docs/chat-ui/components/attachment.md create mode 100644 site/docs/chat-ui/components/chat-input.md create mode 100644 site/docs/chat-ui/components/context-bar.md create mode 100644 site/docs/chat-ui/components/message.md create mode 100644 site/docs/chat-ui/components/reasoning.md create mode 100644 site/docs/chat-ui/components/sources.md create mode 100644 site/docs/chat-ui/components/thread.md create mode 100644 site/docs/chat-ui/components/tool-call-card.md create mode 100644 site/docs/chat-ui/examples.md create mode 100644 site/docs/chat-ui/guides.md create mode 100644 site/docs/chat-ui/guides/approvals.md create mode 100644 site/docs/chat-ui/guides/custom-tool-renderer.md create mode 100644 site/docs/chat-ui/guides/external-store.md create mode 100644 site/docs/chat-ui/guides/slash-commands.md create mode 100644 site/docs/chat-ui/guides/virtualization.md create mode 100644 site/docs/chat-ui/installation.md create mode 100644 site/public/llms.txt create mode 100644 site/src/playground/ComponentDemo.tsx create mode 100644 site/src/playground/component-demo.css create mode 100644 site/src/playground/demoSources.ts create mode 100644 site/src/playground/highlightDemoCode.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..dc2220dd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# jcode-ui examples + +Cloneable starters for the React chat component library. + +| Example | Port | Pattern | +|---------|------|---------| +| [jcode-ui-minimal](./jcode-ui-minimal) | 5177 | `createMockRuntime` only — fastest start | +| [jcode-ui-zustand](./jcode-ui-zustand) | 5178 | `createExternalStoreRuntime` + Zustand | + +## Prerequisites + +```bash +cd packages/jcode-ui-core && pnpm build +cd ../jcode-ui && pnpm build +``` + +Each example is a **standalone pnpm workspace** (`ignore-workspace` via `.npmrc`) using +`file:../../packages/jcode-ui`, so installs stay local and always link monorepo packages. + +If pnpm blocks esbuild postinstall, the example `pnpm-workspace.yaml` already allows it. + +## Docs + +- https://www.j-code.net/chat-ui/docs +- https://www.j-code.net/chat-ui/docs/installation +- https://www.j-code.net/chat-ui/docs/guides/external-store diff --git a/examples/jcode-ui-minimal/.gitignore b/examples/jcode-ui-minimal/.gitignore new file mode 100644 index 00000000..596a78df --- /dev/null +++ b/examples/jcode-ui-minimal/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +pnpm-lock.yaml +*.tsbuildinfo diff --git a/examples/jcode-ui-minimal/.npmrc b/examples/jcode-ui-minimal/.npmrc new file mode 100644 index 00000000..de14fd9a --- /dev/null +++ b/examples/jcode-ui-minimal/.npmrc @@ -0,0 +1 @@ +ignore-workspace=true diff --git a/examples/jcode-ui-minimal/README.md b/examples/jcode-ui-minimal/README.md new file mode 100644 index 00000000..f756da68 --- /dev/null +++ b/examples/jcode-ui-minimal/README.md @@ -0,0 +1,30 @@ +# jcode-ui minimal example + +Drop-in chat UI with **`createMockRuntime`** — no backend, no store library. + +## Run + +From the monorepo root (packages must be built first): + +```bash +# once +cd packages/jcode-ui-core && pnpm build +cd ../jcode-ui && pnpm build + +cd ../../examples/jcode-ui-minimal +pnpm install +pnpm dev +``` + +Open http://localhost:5177 + +## What it shows + +- `Thread` + `ChatInput` +- Default tool registry (seeded with one `execute` card) +- Interactive send → fake assistant echo + +## Next + +- Wire a real store: see [`../jcode-ui-zustand`](../jcode-ui-zustand) +- Docs: https://www.j-code.net/chat-ui/docs diff --git a/examples/jcode-ui-minimal/index.html b/examples/jcode-ui-minimal/index.html new file mode 100644 index 00000000..c25f6918 --- /dev/null +++ b/examples/jcode-ui-minimal/index.html @@ -0,0 +1,12 @@ + + + + + + jcode-ui minimal + + +
+ + + diff --git a/examples/jcode-ui-minimal/package.json b/examples/jcode-ui-minimal/package.json new file mode 100644 index 00000000..aa78ccc0 --- /dev/null +++ b/examples/jcode-ui-minimal/package.json @@ -0,0 +1,24 @@ +{ + "name": "jcode-ui-minimal", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "jcode-ui": "file:../../packages/jcode-ui", + "jcode-ui-core": "file:../../packages/jcode-ui-core", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/examples/jcode-ui-minimal/pnpm-workspace.yaml b/examples/jcode-ui-minimal/pnpm-workspace.yaml new file mode 100644 index 00000000..43dae909 --- /dev/null +++ b/examples/jcode-ui-minimal/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# Standalone example (not part of the monorepo workspace). +packages: + - '.' +allowBuilds: + esbuild: true +onlyBuiltDependencies: + - esbuild diff --git a/examples/jcode-ui-minimal/src/App.tsx b/examples/jcode-ui-minimal/src/App.tsx new file mode 100644 index 00000000..a3aee860 --- /dev/null +++ b/examples/jcode-ui-minimal/src/App.tsx @@ -0,0 +1,116 @@ +/** + * Minimal jcode-ui example — mock runtime only, no backend. + * + * pnpm install + * pnpm dev + */ + +import { useMemo } from 'react' +import { + RuntimeProvider, + ToolRegistryProvider, + createDefaultToolRegistry, + createMockRuntime, + Thread, + ChatInput, +} from 'jcode-ui' +import type { ThreadItem } from 'jcode-ui-core' + +function seed(runtime: ReturnType) { + let seq = 0 + const push = (item: Omit & { seq?: number }) => { + runtime.push({ ...item, seq: ++seq } as ThreadItem) + } + + push({ + kind: 'message', + data: { + id: 'u1', + role: 'user', + content: 'Show me a minimal jcode-ui thread.', + timestamp: Date.now(), + }, + }) + push({ + kind: 'message', + data: { + id: 'a1', + role: 'assistant', + content: + 'This example uses `createMockRuntime` — no backend. Type below; actions are recorded in `runtime.calls`.', + timestamp: Date.now(), + durationMs: 800, + }, + }) + push({ + kind: 'tool', + data: { + id: 't1', + name: 'execute', + args: JSON.stringify({ command: 'echo hello' }), + status: 'done', + timestamp: Date.now(), + output: 'hello\n', + displayInfo: { title: 'execute', subtitle: 'echo hello' }, + }, + }) +} + +export function App() { + const registry = useMemo(() => createDefaultToolRegistry(), []) + const runtime = useMemo(() => { + const rt = createMockRuntime({ + actions: { + sendMessage: (text) => { + const items = rt.getState().items + const seq = items.reduce((m, i) => Math.max(m, i.seq), 0) + 1 + rt.push({ + kind: 'message', + seq, + data: { + id: `u_${Date.now()}`, + role: 'user', + content: text, + timestamp: Date.now(), + }, + }) + // Fake assistant reply + window.setTimeout(() => { + const s2 = rt.getState().items.reduce((m, i) => Math.max(m, i.seq), 0) + 1 + rt.push({ + kind: 'message', + seq: s2, + data: { + id: `a_${Date.now()}`, + role: 'assistant', + content: `Echo: ${text}`, + timestamp: Date.now(), + }, + }) + }, 400) + }, + }, + }) + seed(rt) + return rt + }, []) + + return ( +
+
+ jcode-ui + minimal · mock runtime +
+ + +
+ +
+
+ +
+
+
+
+ ) +} diff --git a/examples/jcode-ui-minimal/src/main.tsx b/examples/jcode-ui-minimal/src/main.tsx new file mode 100644 index 00000000..d79c6bee --- /dev/null +++ b/examples/jcode-ui-minimal/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import 'jcode-ui/styles.css' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/jcode-ui-minimal/src/styles.css b/examples/jcode-ui-minimal/src/styles.css new file mode 100644 index 00000000..0b3867f2 --- /dev/null +++ b/examples/jcode-ui-minimal/src/styles.css @@ -0,0 +1,45 @@ +* { + box-sizing: border-box; +} +html, +body, +#root { + height: 100%; + margin: 0; +} +body { + font-family: system-ui, -apple-system, sans-serif; + background: #0d0d0d; + color: #e4e4e7; +} + +.app { + height: 100%; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: baseline; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid #2e2e2e; + font-size: 14px; +} + +.app-header span { + color: #888; + font-size: 12px; +} + +.app-main { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.app-footer { + border-top: 1px solid #2e2e2e; + padding: 8px 12px 12px; +} diff --git a/examples/jcode-ui-minimal/tsconfig.json b/examples/jcode-ui-minimal/tsconfig.json new file mode 100644 index 00000000..13315a96 --- /dev/null +++ b/examples/jcode-ui-minimal/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/examples/jcode-ui-minimal/tsconfig.node.json b/examples/jcode-ui-minimal/tsconfig.node.json new file mode 100644 index 00000000..2ab3f37a --- /dev/null +++ b/examples/jcode-ui-minimal/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/jcode-ui-minimal/vite.config.ts b/examples/jcode-ui-minimal/vite.config.ts new file mode 100644 index 00000000..b9eeef04 --- /dev/null +++ b/examples/jcode-ui-minimal/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { port: 5177 }, +}) diff --git a/examples/jcode-ui-zustand/.gitignore b/examples/jcode-ui-zustand/.gitignore new file mode 100644 index 00000000..596a78df --- /dev/null +++ b/examples/jcode-ui-zustand/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +pnpm-lock.yaml +*.tsbuildinfo diff --git a/examples/jcode-ui-zustand/.npmrc b/examples/jcode-ui-zustand/.npmrc new file mode 100644 index 00000000..de14fd9a --- /dev/null +++ b/examples/jcode-ui-zustand/.npmrc @@ -0,0 +1 @@ +ignore-workspace=true diff --git a/examples/jcode-ui-zustand/README.md b/examples/jcode-ui-zustand/README.md new file mode 100644 index 00000000..d1c761ea --- /dev/null +++ b/examples/jcode-ui-zustand/README.md @@ -0,0 +1,42 @@ +# jcode-ui + Zustand + +Production-shaped integration: **your store owns the timeline**, jcode-ui renders via `createExternalStoreRuntime`. + +## Run + +```bash +# build packages once +cd packages/jcode-ui-core && pnpm build +cd ../jcode-ui && pnpm build + +cd ../../examples/jcode-ui-zustand +pnpm install +pnpm dev +``` + +Open http://localhost:5178 + +## Pattern + +```ts +const runtime = createExternalStoreRuntime({ + store: useChatStore, // Zustand: getState + subscribe + select: (s) => ({ + items: s.items, + isRunning: s.isRunning, + }), + actions: { + sendMessage: (text) => { /* mutate store + call API */ }, + // … + }, +}) +``` + +See also: [External store guide](https://www.j-code.net/chat-ui/docs/guides/external-store) + +## Files + +| File | Role | +|------|------| +| `src/store.ts` | Zustand timeline + mutations | +| `src/App.tsx` | Runtime wiring + UI | diff --git a/examples/jcode-ui-zustand/index.html b/examples/jcode-ui-zustand/index.html new file mode 100644 index 00000000..550e1654 --- /dev/null +++ b/examples/jcode-ui-zustand/index.html @@ -0,0 +1,12 @@ + + + + + + jcode-ui + Zustand + + +
+ + + diff --git a/examples/jcode-ui-zustand/package.json b/examples/jcode-ui-zustand/package.json new file mode 100644 index 00000000..55e4b199 --- /dev/null +++ b/examples/jcode-ui-zustand/package.json @@ -0,0 +1,25 @@ +{ + "name": "jcode-ui-zustand", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "jcode-ui": "file:../../packages/jcode-ui", + "jcode-ui-core": "file:../../packages/jcode-ui-core", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/examples/jcode-ui-zustand/pnpm-workspace.yaml b/examples/jcode-ui-zustand/pnpm-workspace.yaml new file mode 100644 index 00000000..43dae909 --- /dev/null +++ b/examples/jcode-ui-zustand/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# Standalone example (not part of the monorepo workspace). +packages: + - '.' +allowBuilds: + esbuild: true +onlyBuiltDependencies: + - esbuild diff --git a/examples/jcode-ui-zustand/src/App.tsx b/examples/jcode-ui-zustand/src/App.tsx new file mode 100644 index 00000000..8b667457 --- /dev/null +++ b/examples/jcode-ui-zustand/src/App.tsx @@ -0,0 +1,90 @@ +/** + * jcode-ui + Zustand — external store pattern. + * + * pnpm install && pnpm dev + */ + +import { useMemo, useEffect } from 'react' +import { + RuntimeProvider, + ToolRegistryProvider, + createDefaultToolRegistry, + createExternalStoreRuntime, + Thread, + ChatInput, +} from 'jcode-ui' +import { seedDemo, useChatStore } from './store' + +export function App() { + const registry = useMemo(() => createDefaultToolRegistry(), []) + + const runtime = useMemo( + () => + createExternalStoreRuntime({ + // Zustand stores are Redux-shaped: getState + subscribe + store: useChatStore, + select: (s) => ({ + items: s.items, + isRunning: s.isRunning, + }), + actions: { + sendMessage: (text) => { + const { appendUser, appendAssistant, appendTool, setRunning } = useChatStore.getState() + appendUser(text) + setRunning(true) + // Simulate an agent turn: tool then reply + window.setTimeout(() => { + appendTool({ + id: `t_${Date.now()}`, + name: 'execute', + args: JSON.stringify({ command: `echo ${JSON.stringify(text)}` }), + status: 'done', + timestamp: Date.now(), + output: text + '\n', + displayInfo: { title: 'execute', subtitle: 'echo' }, + }) + appendAssistant(`Zustand received: **${text}**`) + }, 500) + }, + enqueueMessage: (text) => { + // Type-ahead while running — still append as user for this demo + useChatStore.getState().appendUser(`[queued] ${text}`) + }, + removeQueuedMessage: () => undefined, + stop: () => useChatStore.getState().stop(), + resolveApproval: (id, approved, approveAll) => + useChatStore.getState().resolveApproval(id, approved, approveAll), + submitAskUser: (id, answers) => useChatStore.getState().submitAskUser(id, answers), + editMessage: (id, text) => useChatStore.getState().editMessage(id, text), + }, + }), + [], + ) + + useEffect(() => { + seedDemo() + }, []) + + return ( +
+
+ jcode-ui + external store · Zustand +
+ + +
+ +
+
+ +
+
+
+
+ ) +} diff --git a/examples/jcode-ui-zustand/src/main.tsx b/examples/jcode-ui-zustand/src/main.tsx new file mode 100644 index 00000000..d79c6bee --- /dev/null +++ b/examples/jcode-ui-zustand/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import 'jcode-ui/styles.css' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/jcode-ui-zustand/src/store.ts b/examples/jcode-ui-zustand/src/store.ts new file mode 100644 index 00000000..52a067d2 --- /dev/null +++ b/examples/jcode-ui-zustand/src/store.ts @@ -0,0 +1,135 @@ +/** + * Host chat store — Zustand + jcode-ui ThreadItem timeline. + * + * This is the production integration pattern: own the state, project it through + * createExternalStoreRuntime, bind actions that mutate + (optionally) hit an API. + */ + +import { create } from 'zustand' +import type { ThreadItem, Message, ToolCall } from 'jcode-ui-core' + +export interface ChatState { + items: ThreadItem[] + isRunning: boolean + seq: number + // mutations + appendUser: (text: string) => void + appendAssistant: (text: string) => void + appendTool: (tool: ToolCall) => void + setRunning: (v: boolean) => void + stop: () => void + resolveApproval: (id: string, approved: boolean, approveAll?: boolean) => void + submitAskUser: (id: string, answers: { question_header: string; answer: string; selected?: string[] }[]) => void + editMessage: (id: string, newText: string) => void +} + +function nextSeq(get: () => ChatState): number { + const seq = get().seq + 1 + return seq +} + +export const useChatStore = create((set, get) => ({ + items: [], + isRunning: false, + seq: 0, + + appendUser: (text) => { + const seq = nextSeq(get) + const msg: Message = { + id: `u_${seq}`, + role: 'user', + content: text, + timestamp: Date.now(), + } + set({ + seq, + items: [...get().items, { kind: 'message', data: msg, seq }], + }) + }, + + appendAssistant: (text) => { + const seq = nextSeq(get) + const msg: Message = { + id: `a_${seq}`, + role: 'assistant', + content: text, + timestamp: Date.now(), + durationMs: 600, + } + set({ + seq, + items: [...get().items, { kind: 'message', data: msg, seq }], + isRunning: false, + }) + }, + + appendTool: (tool) => { + const seq = nextSeq(get) + set({ + seq, + items: [...get().items, { kind: 'tool', data: { ...tool, id: tool.id || `t_${seq}` }, seq }], + }) + }, + + setRunning: (v) => set({ isRunning: v }), + + stop: () => set({ isRunning: false }), + + resolveApproval: (id, approved) => { + set({ + items: get().items.map((i) => + i.kind === 'approval' && i.data.id === id + ? { ...i, data: { ...i.data, resolved: true, approved } } + : i, + ), + }) + }, + + submitAskUser: (id, answers) => { + set({ + items: get().items.map((i) => + i.kind === 'tool' && (i.data.askUserId === id || i.data.id === id) + ? { + ...i, + data: { + ...i.data, + status: 'done', + askUserId: undefined, + askUserQuestions: undefined, + output: JSON.stringify(answers), + }, + } + : i, + ), + }) + }, + + editMessage: (id, newText) => { + set({ + items: get().items.map((i) => + i.kind === 'message' && i.data.id === id + ? { ...i, data: { ...i.data, content: newText } } + : i, + ), + }) + }, +})) + +/** Seed a short conversation for the demo. */ +export function seedDemo() { + const s = useChatStore.getState() + if (s.items.length > 0) return + s.appendUser('Wire jcode-ui to Zustand.') + s.appendTool({ + id: 't1', + name: 'read', + args: JSON.stringify({ path: 'store.ts' }), + status: 'done', + timestamp: Date.now(), + output: ' 1│export const useChatStore = create(…)', + displayInfo: { title: 'read', subtitle: 'store.ts' }, + }) + s.appendAssistant( + 'Use `createExternalStoreRuntime({ store: useChatStore, select, actions })`. The store owns the timeline; the UI only renders it.', + ) +} diff --git a/examples/jcode-ui-zustand/src/styles.css b/examples/jcode-ui-zustand/src/styles.css new file mode 100644 index 00000000..ef64f058 --- /dev/null +++ b/examples/jcode-ui-zustand/src/styles.css @@ -0,0 +1,40 @@ +* { + box-sizing: border-box; +} +html, +body, +#root { + height: 100%; + margin: 0; +} +body { + font-family: system-ui, -apple-system, sans-serif; + background: #0d0d0d; + color: #e4e4e7; +} +.app { + height: 100%; + display: flex; + flex-direction: column; +} +.app-header { + display: flex; + align-items: baseline; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid #2e2e2e; + font-size: 14px; +} +.app-header span { + color: #888; + font-size: 12px; +} +.app-main { + flex: 1; + min-height: 0; + overflow: hidden; +} +.app-footer { + border-top: 1px solid #2e2e2e; + padding: 8px 12px 12px; +} diff --git a/examples/jcode-ui-zustand/tsconfig.json b/examples/jcode-ui-zustand/tsconfig.json new file mode 100644 index 00000000..13315a96 --- /dev/null +++ b/examples/jcode-ui-zustand/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/examples/jcode-ui-zustand/vite.config.ts b/examples/jcode-ui-zustand/vite.config.ts new file mode 100644 index 00000000..fe4b9452 --- /dev/null +++ b/examples/jcode-ui-zustand/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { port: 5178 }, +}) diff --git a/packages/jcode-ui-core/README.md b/packages/jcode-ui-core/README.md index 22c2ab47..97568065 100644 --- a/packages/jcode-ui-core/README.md +++ b/packages/jcode-ui-core/README.md @@ -49,7 +49,7 @@ interface ChatRuntime { } ``` -`RuntimeState` carries `items` / `isRunning` / `tokenSnapshot` / `goal` / `todos` / `queued`. `RuntimeActions` exposes `sendMessage`, `enqueueMessage`, `stop`, `resolveApproval`, `submitAskUser`, `editMessage`, `removeQueuedMessage`. See the [runtime docs](https://www.j-code.net/docs/chat-ui/runtime). +`RuntimeState` carries `items` / `isRunning` / `tokenSnapshot` / `goal` / `todos` / `queued`. `RuntimeActions` exposes `sendMessage`, `enqueueMessage`, `stop`, `resolveApproval`, `submitAskUser`, `editMessage`, `removeQueuedMessage`. See the [runtime docs](https://www.j-code.net/chat-ui/docs/runtime). ## License diff --git a/packages/jcode-ui-core/package.json b/packages/jcode-ui-core/package.json index b5d9016f..ce486b47 100644 --- a/packages/jcode-ui-core/package.json +++ b/packages/jcode-ui-core/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "author": "jack", - "homepage": "https://www.j-code.net/docs/chat-ui", + "homepage": "https://www.j-code.net/chat-ui/docs", "repository": { "type": "git", "url": "https://github.com/cnjack/jcode", diff --git a/packages/jcode-ui-core/src/primitives/Composer.tsx b/packages/jcode-ui-core/src/primitives/Composer.tsx index b4d83683..168b62d1 100644 --- a/packages/jcode-ui-core/src/primitives/Composer.tsx +++ b/packages/jcode-ui-core/src/primitives/Composer.tsx @@ -27,10 +27,18 @@ export interface ComposerRenderSlots { 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. */ + /** 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). */ renderAttachments?: (imgs: ChatImage[], remove: (i: number) => void) => 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. + * Mirrors assistant-ui `ComposerAddAttachment`. + */ + renderAddAttachment?: (openPicker: () => void) => ReactNode /** Optional content rendered before the textarea inside the input row * (e.g. a "+" menu button). */ renderPrefix?: () => ReactNode @@ -47,6 +55,8 @@ export interface ComposerProps extends ComposerRenderSlots { slashCommands?: SlashCommand[] /** Whether image attachments are allowed (gated by model vision support). */ allowImages?: boolean + /** `accept` attribute for the file picker (default `image/*`). */ + acceptImages?: string /** Max image size in bytes (default 10MB). */ maxImageBytes?: number /** aria-label for the textarea. */ @@ -71,11 +81,37 @@ export interface SlashMenuState { const DEFAULT_MAX_ROWS_PX = 160 +function readImageFile(file: File): Promise { + return new Promise((resolve) => { + if (!file.type.startsWith('image/')) { + resolve(null) + return + } + const reader = new FileReader() + reader.onload = () => { + const dataUrl = String(reader.result ?? '') + const comma = dataUrl.indexOf(',') + if (comma < 0) { + resolve(null) + return + } + resolve({ + data: dataUrl.slice(comma + 1), + media_type: file.type, + name: file.name || undefined, + }) + } + reader.onerror = () => resolve(null) + reader.readAsDataURL(file) + }) +} + 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 = '', @@ -85,6 +121,7 @@ export function Composer({ renderQueue, renderSubmitButton, renderAttachments, + renderAddAttachment, renderPrefix, renderSuffix, }: ComposerProps): ReactNode { @@ -93,6 +130,7 @@ export function Composer({ const [text, setText] = useState(defaultValue) const [images, setImages] = useState([]) const textareaRef = useRef(null) + const fileInputRef = useRef(null) // --- Autosize: grow with content up to maxRows, then scroll. --- useLayoutEffect(() => { @@ -174,9 +212,9 @@ export function Composer({ [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. --- + // --- 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 @@ -187,14 +225,41 @@ export function Composer({ }, [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)) }, []) + 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 + return ( -
+
{renderQueue?.(queued)} {renderSlashMenu?.({ open: !!slashOpen && filteredCommands.length > 0, @@ -202,8 +267,10 @@ export function Composer({ activeIndex: slashOpen ? slashActive : -1, apply: applySlash, })} -
+ {allowImages && images.length > 0 && renderAttachments?.(images, removeImage)} +
{renderPrefix?.()} + {addAttachmentControl}