Skip to content

feat: Vue→React migration + reusable jcode-ui component library - #122

Merged
cnjack merged 8 commits into
mainfrom
feat/react-migration-jcode-ui
Jul 9, 2026
Merged

feat: Vue→React migration + reusable jcode-ui component library#122
cnjack merged 8 commits into
mainfrom
feat/react-migration-jcode-ui

Conversation

@cnjack

@cnjack cnjack commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Migrates the product UI from Vue 3 to React 18, built on a new reusable, npm-publishable component library. This is the full scaffold + working build chain; the Vue app stays as production until feature parity is verified.

What's added

packages/jcode-ui-core — framework-agnostic core

  • Types: `Message`, `ToolCall`, `Approval`, `ThreadItem` (discriminated union), `TokenSnapshot`, `Goal`, `TodoItem`, ask-user types.
  • Runtime: `ChatRuntime` contract + `createExternalStoreRuntime` (adapts any Redux-shaped store) + `createMockRuntime` (scriptable, for demos/tests) + `` + `useRuntimeState`/`useRuntimeSelector`/`useRuntimeActions` hooks.
  • Adapters: `ToolRendererRegistry` — the plugin seam for tool-call visualization.
  • Primitives (headless): `Thread` (TanStack Virtual + the "follow only when at bottom" streaming contract), `MessageView`, `Composer`, `ToolCallView`, `ApprovalBlock`, `AskUserBlock`.

`packages/jcode-ui` — styled components (→ npm: `jcode-ui`)

  • Token-driven wrappers around the primitives (Tailwind 4 + the existing `tokens.css`).
  • 9 default tool renderers: terminal, file-viewer, diff, search, todo, skill, team, browser-shot, generic.
  • Markdown pipeline (marked + highlight.js + DOMPurify), `ContextBar`, `ChatInput`.
  • Single CSS entry (`jcode-ui/styles.css`, 27KB).

`web-react/` — React product app

  • `lib/`: framework-agnostic ports of the Vue composables (api client 384 lines, apiBase dual-host contract, authToken, useDesktop Tauri bridge, ws singleton client, full type contract).
  • `app/store.ts`: RTK store split into 4 slices (chat/session/model/ui) + thunks — replaces the 1.2k-line Vue chat store with focused slices per the migration assessment.
  • `app/runtime.ts`: `createExternalStoreRuntime` adapter — the single seam between RTK and jcode-ui.
  • `app/wsBridge.ts`: WS events → Redux dispatches (replaces the Vue `App.vue` WS→store coupling).
  • `components/`: product shell (Sidebar, ChatView, ProjectHeader, GoalBanner, AutomationsView, ChannelsView, CommandPalette, AuthGate, SetupView).

`site/` — live demo + docs

  • `/chat-ui` showcase page with a live `ChatDemo` (the "website footprint" component) driven by a mock runtime — no backend needed.
  • `site/docs/chat-ui/`: runtime, primitives, tool-renderers, theming docs.

Verification (each step gated)

  • ✅ `jcode-ui-core`: typecheck + build (dist + .d.ts for all subpaths)
  • ✅ `jcode-ui`: typecheck + build (TS dist + 27KB CSS bundle)
  • ✅ `web-react`: typecheck + vite build (626 modules, 421KB gzip — code-splitting is a follow-up)
  • ✅ `site`: typecheck + vite build
  • ✅ `make build-web-react`: full chain green (generate → install → core → ui → CSS → web-react → dist-react)
  • ✅ `npm pack --dry-run`: publishes LICENSE/README/dist, excludes src
  • ✅ Go backend builds (embed.FS intact), Tauri still points at Vue dist

Migration status

Vue remains production. The React app builds to `internal/web/dist-react/` (parallel). The switch-over (`make build-web FRONTEND=web-react` + Go embed + Tauri frontendDist) happens once `web-react` reaches feature parity. Full plan + risk register in the conversation; design rationale in `packages/jcode-ui/README.md` + `site/docs/chat-ui/`.

The component library is the migration's organizing principle and is independently valuable — it's npm-publishable as `jcode-ui` + `jcode-ui-core` for anyone building an agent/copilot UI.

Test plan

  • `make build-web-react` reproduces green locally
  • `cd packages/jcode-ui && npm pack` produces a valid tarball
  • `pnpm dev` in `site/` renders the `/chat-ui` demo live
  • Visual regression: compare `web-react/dist-react` against `web/dist` for the chat view (acceptance: minor improvements allowed, no regressions in message/tool/approval rendering)

🤖 Generated with ZCode

Summary by CodeRabbit

  • New Features
    • Added a React-based chat UI experience (chat, automations, channels) with runtime-backed threading, a composer (send/queue/stop + slash commands), editable/copyable messages, and interactive tool visuals (including approvals and “ask user” prompts).
    • Introduced reusable React UI libraries (jcode-ui + headless jcode-ui-core) with default tool renderers and theme-aware styling.
  • Documentation
    • Added /chat-ui documentation and a dedicated docs area covering runtime, primitives, theming, and tool renderers.
  • Chores
    • Updated build/workspace setup to support the Vue→React migration, including React web and desktop build/lint targets.

cnjack added 4 commits July 9, 2026 00:22
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/.
- ChatDemo: scripted mock-runtime playground component (the 'website footprint')
  that streams a full conversation through message/tool/approval item kinds.
- /chat-ui page: hero, live demo, feature grid, quick-start code sample.
- site/docs/chat-ui/: runtime, primitives, tool-renderers, theming pages.
- SiteNav + routing wired. Site typechecks and builds clean (ChatUIPage bundles
  at 350KB gzip — highlight.js + marked dominate, expected for a live demo).
- jcode-ui core dep switched to file: so the isolated site workspace resolves it.
…ct shell

Replaces the Vue app's runtime layer + shell with a React equivalent that
consumes the jcode-ui component library:

- lib/ : framework-agnostic ports (api.ts 384-line client, apiBase.ts dual-host
  contract, authToken.ts, useDesktop.ts Tauri bridge, ws.ts singleton client,
  types.ts full backend contract). Nearly verbatim from web/src/composables.
- app/store.ts : RTK store split across 4 slices (chat/session/model/ui) with
  async thunks (sendMessage/stopAgent/resolveApproval/submitAskUser/editMessage).
- app/runtime.ts : createExternalStoreRuntime adapter — the single seam between
  RTK and jcode-ui's RuntimeState.
- app/wsBridge.ts : WS events → Redux dispatches (replaces Vue App.vue coupling).
- components/ : product shell (Sidebar, ChatView, ProjectHeader, GoalBanner,
  AutomationsView, ChannelsView, CommandPalette, AuthGate, SetupView).

typechecks + builds clean (626 modules, 421KB gzip bundle — code-splitting is a
follow-up). Splits the 1.2k-line Vue chat store into focused slices per the
migration assessment. The dual-host (browser/Tauri) contract is preserved.
- Makefile: add build-web-react (builds core → jcode-ui → CSS → web-react →
  dist-react), FRONTEND var to select web/web-react, lint-react target.
  Tolerates pnpm ERR_PNPM_IGNORED_BUILDS (esbuild/@parcel/watcher) so the build
  chain is non-fragile.
- pnpm-workspace.yaml: onlyBuiltDependencies for esbuild + @parcel/watcher.
- .npmrc: auto-install-peers for the monorepo.
- AGENTS.md: document the Vue→React migration, web-react/, packages/, and the
  build-web-react target; mark web/ as production-during-migration.
- packages/jcode-ui{,-core}/: README.md, LICENSE, .npmignore, publishConfig
  (access public), files array — npm pack dry-run verified (styles.css + dist
  included, src excluded).

Verified: make build-web-react end-to-end green; all 4 TS projects typecheck
(jcode-ui-core, jcode-ui, web-react, site); Go backend builds (embed intact);
Tauri frontendDist still points at the Vue dist (React is parallel).
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cnjack, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 35011b2e-6c7c-42e2-9f5c-9c0eb3f1a98e

📥 Commits

Reviewing files that changed from the base of the PR and between ad6daf2 and b300f2f.

📒 Files selected for processing (10)
  • internal/model/registry_generated.go
  • web-react/src/App.tsx
  • web-react/src/components/AutomationsView.tsx
  • web-react/src/components/ChannelsView.tsx
  • web-react/src/components/ChatInput.tsx
  • web-react/src/components/ChatView.tsx
  • web-react/src/components/ProjectHeader.tsx
  • web-react/src/components/SettingsDialog.tsx
  • web-react/src/components/ThemeToggle.tsx
  • web-react/src/lib/useTheme.ts
📝 Walkthrough

Walkthrough

This PR establishes a pnpm monorepo for a Vue→React migration, adding jcode-ui-core, jcode-ui, and web-react, with updated build tooling, docs, a live demo, generated React assets, and a standalone tool-search architecture draft.

Changes

Monorepo and build tooling

Layer / File(s) Summary
Workspace config and ignore rules
.gitignore, .npmrc, pnpm-workspace.yaml
Adds workspace membership, build-trust settings, peer-install config, and monorepo ignore rules for Node artifacts and build outputs.
Architecture docs
AGENTS.md
Describes the React frontend, packages/ workspace, migration status, and Vue production scope.
React build targets
Makefile
Adds React lint/build targets, frontend selection, and React desktop commands.

jcode-ui-core package

Layer / File(s) Summary
Package scaffolding
packages/jcode-ui-core/package.json, tsconfig*.json, .npmignore, LICENSE, README.md
Adds package metadata, TypeScript config, publishing ignores, license text, and package README.
Core data types
packages/jcode-ui-core/src/types/index.ts
Defines chat, tool, approval, thread, queue, token, goal, and todo types plus thread item guards.
Runtime abstraction
packages/jcode-ui-core/src/runtime/*
Implements the ChatRuntime contract, React context/hooks, external-store adapter, and mock runtime.
Adapters and hooks
packages/jcode-ui-core/src/adapters/index.ts, packages/jcode-ui-core/src/hooks/index.ts
Adds the tool renderer registry contract and reusable scroll, focus, and queued-message hooks.
Headless primitives
packages/jcode-ui-core/src/primitives/*, packages/jcode-ui-core/src/index.ts
Implements approval, ask-user, composer, message, thread, and tool-call primitives plus barrel exports.

jcode-ui package

Layer / File(s) Summary
Package scaffolding
packages/jcode-ui/package.json, tsconfig*.json, .npmignore, LICENSE, README.md
Adds package metadata, TypeScript config, publishing ignores, license text, and README.
Entrypoint and shared helpers
packages/jcode-ui/src/index.ts, packages/jcode-ui/src/lib/*
Re-exports the package surface and adds API base context plus Markdown rendering utilities.
Styled components
packages/jcode-ui/src/components/*
Implements the chat UI components and the tool registry context wiring.
Tool renderers
packages/jcode-ui/src/toolRenderers/*
Implements the default renderers for terminal, file, diff, grep, todo, skill, team, browser-shot, and generic tool output.
Theming and component CSS
packages/jcode-ui/src/styles/*
Adds token, animation, component, and entry stylesheets for the styled package.

web-react application

Layer / File(s) Summary
Project scaffolding
web-react/index.html, web-react/package.json, web-react/tsconfig*.json, web-react/vite.config.ts, web-react/src/styles.css
Sets up the React app entrypoint and build config.
API, types, and platform helpers
web-react/src/lib/*
Implements the API client, shared backend types, API base resolution, auth token storage, desktop helpers, automations types, and WebSocket client.
Store and runtime wiring
web-react/src/app/*, web-react/src/main.tsx
Implements Redux slices and thunks, runtime binding, WS bridge, typed hooks, and bootstrap entrypoint.
App shell and views
web-react/src/App.tsx, web-react/src/components/*
Implements boot/gating flow, sidebar navigation, command palette, and chat/automation/channel views.

Documentation site and demo

Layer / File(s) Summary
Chat UI documentation
site/docs/chat-ui/*
Adds overview, primitives, runtime, theming, and tool-renderers documentation pages plus docs parsing/routing.
Chat UI page and live demo
site/src/pages/ChatUIPage.tsx, site/src/pages/chatui.css, site/src/playground/*, site/src/App.tsx, site/src/components/SiteNav.tsx, site/package.json
Adds the Chat UI page, styling, scripted demo runtime, and route/nav integration.

Generated build output

Layer / File(s) Summary
Bundled React assets
internal/web/dist-react/*
Adds the compiled Tauri invoke wrapper, Tailwind CSS bundle, and React HTML entrypoint.

Tool-search architecture draft

Layer / File(s) Summary
Architecture draft content
docs/tool-search-architecture-draft.md
Documents a proposed tool-search/DynamicTools integration plan, including config, UI/API, rollout, and risks.

Estimated code review effort: 5 (Critical) | ~150 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WSClient
  participant wsBridge
  participant ReduxStore
  participant useChatRuntime
  participant ChatView
  WSClient->>wsBridge: deliver websocket event
  wsBridge->>ReduxStore: dispatch chat/model/session actions
  ReduxStore-->>useChatRuntime: updated RootState
  useChatRuntime-->>ChatView: RuntimeState via ChatRuntime
Loading

Possibly related PRs

  • cnjack/jcode#29: The React UI stack here adds ToolDisplayInfo/extractToolDisplayInfo() and wires tool metadata into rendering, matching that PR’s tool-display metadata enrichment work.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a Vue-to-React migration plus a reusable jcode-ui component library.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/react-migration-jcode-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cnjack cnjack left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as a full subsystem sweep (core runtime, styled components/markdown pipeline, WS/auth/store layer, product shell, build chain) since this is a large scaffold PR (107 files, ~12.8k lines). Vue stays production and nothing in the build chain switches the served frontend, which is good — but the new code itself has two critical, likely-blocking defects plus several real correctness/reliability gaps versus the Vue original it's porting from. Inline comments mark the 8 most load-bearing issues with exact fixes; full list below.

Overall Risk: High

Not high because of scope — high because of two defects that mean the new library/app doesn't actually work as shipped (infinite render loop, XSS), plus a packaging bug that would break the published npm package on the first npm install. None of this is caught by CI today (see the CI finding below) since the new workspace isn't wired into .github/workflows.

Top Findings

  1. Infinite re-render loop in useRuntimeState/useRuntimeSelector (packages/jcode-ui-core/src/runtime/context.tsx:93, and externalStore.ts) — getSnapshot calls normalizeState(runtime.getState()), which allocates a new object every call, violating useSyncExternalStore's "stable snapshot while unchanged" contract. This affects every component under RuntimeProvider — i.e., essentially the whole chat UI (Thread, Composer, etc.). This is a foundational break, not an edge case.
  2. Stored XSS via unsanitized dangerouslySetInnerHTML (packages/jcode-ui/src/components/ToolCallCard.tsx:78) — tool.displayInfo?.subtitle (LLM/tool-arg-derived content) is injected as raw HTML with no DOMPurify pass, unlike every other dangerouslySetInnerHTML use in the package. The headless sibling component renders the same value safely as escaped JSX text — this wrapper diverges and reopens the hole.
  3. Auth/setup gate ordering inverted (web-react/src/App.tsx:107-110) — needsSetup is checked before needsAuth, but /api/setup/* is itself auth-protected (per the Vue original's explicit comment on this exact ordering requirement). A server requiring both drops users into a broken, unusable SetupView instead of the login screen.
  4. sendMessage thunk has no error handling (store.ts:381-399) — a failed api.chat() call leaves isRunning: true forever with no recovery path and no user-visible error.
  5. editMessage silently drops history-truncation and timeline-splice logic (store.ts:431-438) — resends without truncating backend history (agent still sees the stale tail) and wipes the entire frontend timeline instead of just the edited tail.
  6. jcode-ui's dependency on jcode-ui-core uses file:../jcode-ui-core instead of workspace:* (packages/jcode-ui/package.json:69) — pnpm publish doesn't rewrite file: specifiers, so the published npm package would ship an unresolvable dependency path, breaking installs for every external consumer.
  7. WS client zombie-socket under React StrictMode (web-react/src/lib/ws.ts:132) — disconnect() doesn't neutralize the async onclose handler, so StrictMode's dev double-mount produces a duplicate reconnecting client that double-dispatches WS events into the shared store.
  8. resolveApproval drops the store-level re-entrancy guard (store.ts:406-417) — repeated clicks can now fire duplicate POSTs (only a UI disabled prop guards it, which is timing-dependent); failure is also silent (no user-visible error), unlike the Vue original.
  9. task_id echo for approval/ask-user resolution dropped, and no pending-gate recovery after reload/reconnect (app/wsBridge.ts, store.ts) — with concurrent tasks this can resolve/misroute the wrong task's gate; a reload while a gate is pending leaves the agent blocked with no way to unblock from the UI (the already-ported approvalPending/askPending endpoints are never called).
  10. DOMPurify ADD_ATTR: ['target'] without forcing rel="noopener noreferrer" (packages/jcode-ui/src/lib/markdown.ts:34) — reverse-tabnabbing via window.opener from untrusted markdown links.

Additional lower-severity findings (not inline, listed for completeness)

  • session.wsConnected and model.autoApprove reducers exist but are never dispatched from anywhere — the connection indicator is permanently stuck and any UI bound to autoApprove shows a stale value.
  • SetupView's provider→models fetch has no stale-response guard; rapid provider switching can submit a model belonging to the wrong provider.
  • AutomationsView/ChannelsView swallow fetch errors (.catch(() => {})) and render the same copy for "empty" and "failed to load" — no way to distinguish a real backend outage from an empty state.
  • internal/web/dist-react/ (committed Vite build output, ~1.3MB minified JS) has no .gitignore entry unlike the Vue dist/, and isn't referenced by the Go embed anywhere — dead, growing weight in git history that looks like an accidental git add -A.
  • The new workspace (packages/jcode-ui-core, packages/jcode-ui, web-react) has zero CI coverage — .github/workflows/ci.yml is unchanged, so the new lint-react/build-web-react Make targets are never invoked automatically and this code can silently bitrot.
  • useIsAtBottom (jcode-ui-core/src/hooks/index.ts) doesn't match its own docstring ("re-renders when the flag flips") — it never tracks/returns the flag, so any future consumer relying on the documented contract gets a silent no-op.
  • Thread virtualization: the auto-follow scroll effect can race TanStack Virtual's async row remeasurement during rapid streaming (under-scroll/jank), and the pending-row/overscan spacer aren't measured, so getTotalSize() can disagree with real DOM height.

Nothing found that changes what's served in production today — Vue remains the default build/embed target, and desktop (Tauri) config is untouched. The concerns above are all in the new, not-yet-switched-on code paths, which is the right time to fix them before this becomes the production surface.

(Note: submitted as COMMENT rather than "Request changes" — GitHub doesn't allow requesting changes on one's own PR — but findings #1, #2, and #6 above are blocking-severity and should be treated as such.)


Generated by Claude Code

const subscribe = runtime.subscribe
const getSnapshot = () => normalizeState(runtime.getState())
// Prime the cache on first read / after a store change useSyncExternalStore detected.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical — getSnapshot violates the useSyncExternalStore contract, causing an infinite re-render loop.

getSnapshot here is () => normalizeState(runtime.getState()), and normalizeState (runtime/index.ts) unconditionally builds a brand-new object literal on every call. React requires repeated getSnapshot() calls to return the same value (by Object.is) while the store hasn't changed. Since this never stabilizes, React's tearing-detection re-check after commit will always see a "changed" snapshot and force another re-render, which calls getSnapshot again, which again differs — an unconditional infinite loop (or at minimum React's dev-mode "getSnapshot should be cached" warning firing on every render) for every component under RuntimeProvider (Thread, Composer, etc. — essentially the whole chat UI).

This also affects createExternalStoreRuntime.getState (externalStore.ts), which has the same normalizeState(select(...)) pattern.

Fix: memoize getSnapshot's result — only recompute normalizeState(...) when the underlying runtime.getState()/host store reference actually changes, caching the last result otherwise (the standard pattern used by use-sync-external-store/with-selector / react-redux).


Generated by Claude Code

</span>
<span className="truncate text-[var(--color-foreground)]">{title}</span>
{subtitle && (
<span className="truncate text-[var(--color-muted-foreground)]" dangerouslySetInnerHTML={{ __html: subtitle }} />

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical — stored/DOM XSS via unsanitized dangerouslySetInnerHTML.

tool.displayInfo?.subtitle is documented (jcode-ui-core/src/types/index.ts) as "surfaced from the backend or extracted client-side from args" — i.e. it can contain LLM/tool-controlled strings (file paths, shell commands, grep patterns). Rendering it via dangerouslySetInnerHTML with no DOMPurify.sanitize() (unlike every other dangerouslySetInnerHTML use in this package, which goes through renderMarkdown()) is a direct XSS: a subtitle containing '&lt;img src=x onerror=...&gt;' executes in the app's origin.

Note the sibling headless component ToolCallView.tsx's DefaultToolHeader renders the exact same value safely as {subtitle} (JSX auto-escaping) — this wrapper diverges and reintroduces the hole, seemingly unintentionally.

Fix: render as plain text (<span>{subtitle}</span>), or if rich formatting is required, pipe through renderMarkdown() first.


Generated by Claude Code

Comment thread web-react/src/App.tsx
if (connectionError) {
return <ErrorScreen message={connectionError} />
}
if (needsSetup) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — auth/setup gate ordering is inverted relative to the Vue original, and a stuck/broken state results.

needsSetup is checked (and <SetupView/> rendered) before needsAuth. But /api/setup/* endpoints are themselves auth-protected — the Vue original (web/src/App.vue) explicitly checks auth first with the comment "Auth gate must run BEFORE the setup gate: /api/setup/* is itself protected, so without a valid token the wizard's own calls would 401." On a server requiring both auth and first-run setup, a user now lands on a broken SetupView (its provider-list fetch 401s silently) with no way to log in, instead of the login screen.

Separately: the loadSessions/loadTasks/loadSlashCommands effect below (line ~98) fires unconditionally on mount, not gated on boot() resolving or on these auth/setup checks passing — protected data requests go out before we know whether the user is even authenticated, and (combined with needsAuth/needsSetup defaulting to false) the authenticated Shell can flash briefly before boot resolves.

Fix: swap the two gate checks (auth before setup), and gate the sidebar-data-loading effect on boot completion + gates having passed.


Generated by Claude Code

}
dispatch(chatActions.addMessage({ role: 'user', content: payload.text, images: payload.images }))
dispatch(chatActions.setRunning(true))
const resp = await api.chat(payload.text, payload.mode, sessionId, payload.images)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — sendMessage has no error handling: a failed request leaves the UI permanently "running".

There's no try/catch around api.chat(...). If it rejects (network drop, 5xx, expired token), chat.isRunning stays true forever with no recovery short of a full reload, and the user sees no error. The Vue original wraps the equivalent call in try/catch and resets isRunning + surfaces the error as a system message on failure.

Fix: wrap in try/catch, dispatch setRunning(false) and an error message on failure.


Generated by Claude Code

},
)

export const editMessage = createAsyncThunk(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium/High — editMessage doesn't truncate backend history and wipes the entire frontend timeline, not just the edited tail.

payload.id is accepted but never used. This dispatches clearChat() (wiping the whole UI timeline, not just messages after the edit point) and resends into the same backend session without calling truncateHistory — so the backend agent still sees the stale original conversation tail alongside the new prompt. The Vue original (editAndResend in web/src/stores/chat.ts) locates the message by id, calls api.truncateHistory(...) server-side, and only splices the frontend timeline from that index onward.

Fix: port the original logic — locate the message index, truncate backend history via the existing api.truncateHistory, splice (not clear) the frontend timeline from that point, then resend.


Generated by Claude Code

Comment thread web-react/src/lib/ws.ts
this.send({ type: 'approval', data: { id, approved, approve_all: approveAll, task_id: taskId } })
}

disconnect(): void {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — disconnect() doesn't neutralize the async onclose handler, producing a zombie socket under React StrictMode's double-mount.

disconnect() closes the socket and clears local fields, but doesn't null this.ws.onclose. The browser's close event still fires asynchronously afterward, running the original onclose (line ~111), which re-arms a 3s reconnect on the now-orphaned client — one still wired to the shared Redux store via bridgeWS. Since App.tsx's WS effect creates/tears down a WSClient on every effect run, and main.tsx wraps the app in <StrictMode> (mount→cleanup→remount in dev), this zombie client reconnects ~3s after mount and double-dispatches every WS event alongside the real client, duplicating messages/tool calls in dev.

This latent pattern also exists in the Vue original, but Vue components aren't double-invoked the way StrictMode exercises this in React.

Fix: add a destroyed flag checked in onclose before scheduling a reconnect, and/or null out ws.onclose/onmessage/onerror in disconnect() before calling ws.close().


Generated by Claude Code

"@tailwindcss/typography": "^0.5.16",
"dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"jcode-ui-core": "file:../jcode-ui-core",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (packaging) — file: specifier will publish a broken dependency reference.

web-react/package.json correctly uses "jcode-ui-core": "workspace:*", which pnpm publish rewrites to a concrete semver range. This package instead uses "file:../jcode-ui-core", which pnpm/npm publish verbatim — it is not rewritten. Since the PR's stated goal is to publish jcode-ui to npm (publishConfig.access: "public" is already set), the published package.json would reference a local path that doesn't exist for any external consumer, breaking npm install jcode-ui entirely.

Fix: change to "jcode-ui-core": "workspace:*" (or "workspace:^").


Generated by Claude Code

export function renderMarkdown(text: string): string {
const raw = marked.parse(text) as string
return DOMPurify.sanitize(raw, {
ADD_ATTR: ['target'],

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low/Medium — ADD_ATTR: ['target'] without forcing rel="noopener noreferrer" re-enables reverse-tabnabbing.

Allowing target on sanitized anchors (reachable via raw <a target="_blank"> HTML that marked passes through) without also forcing rel="noopener noreferrer" lets a link opened in a new tab use window.opener to redirect the parent page — a phishing vector exploitable from untrusted markdown (tool output, rendered file content, etc.).

Fix: add a DOMPurify afterSanitizeAttributes hook that force-sets rel="noopener noreferrer" whenever target is present on <a> (the standard DOMPurify recipe), or drop ADD_ATTR: ['target'] if not needed.


Generated by Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (21)
pnpm-workspace.yaml-13-14 (1)

13-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove placeholder allowBuilds config block.

Line 14 contains esbuild: set this to true or false, which is instructional placeholder text mistakenly left as YAML configuration rather than a comment. The actual native-build allowlist is correctly defined below in onlyBuiltDependencies (lines 18–20). pnpm may silently ignore the unknown allowBuilds key, but this is confusing and could break on stricter pnpm versions.

🧹 Proposed fix
 packages:
   - 'packages/*'
   - 'web-react'
-allowBuilds:
-  esbuild: set this to true or false
 # Native build scripts to allow. esbuild (Vite's bundler) and `@parcel/watcher`
 # (dev-server file watching) are trusted toolchain deps — whitelisting them
 # avoids ERR_PNPM_IGNORED_BUILDS failing `pnpm install` (and the Makefile build).
 onlyBuiltDependencies:
   - esbuild
   - '`@parcel/watcher`'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pnpm-workspace.yaml` around lines 13 - 14, Remove the placeholder allowBuilds
block from pnpm-workspace.yaml and keep only the real native-build allowlist
under onlyBuiltDependencies. The stray instructional entry for esbuild should
not remain as YAML configuration, so delete that block entirely rather than
converting it into a comment. Use the existing onlyBuiltDependencies section as
the authoritative place for build अनुमति entries.
packages/jcode-ui/.npmignore-1-4 (1)

1-4: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

src in files contradicts src/ in .npmignore.

package.json lists "src" in the files array, but .npmignore excludes src/. The .npmignore denylist takes precedence over the files allowlist, so src/ will be excluded from the published tarball regardless. This is contradictory — either remove "src" from files (if you only want to ship dist/) or remove src/ from .npmignore (if you intend to publish source for debugging).

Given the exports field only points to ./dist/, removing "src" from files is likely the correct fix.

Proposed fix for package.json files field
   "files": [
     "dist",
-    "src",
     "README.md",
     "LICENSE"
   ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/.npmignore` around lines 1 - 4, The package publish config
is contradictory because .npmignore excludes src/ while package.json still lists
src in the files array, so the source will never be included in the tarball.
Update the publish settings by removing src from package.json’s files list,
since the package’s exports and runtime entrypoints already point to dist/; use
the package.json files field and .npmignore together to keep only the intended
build output.
packages/jcode-ui/src/components/ChatInput.tsx-85-104 (1)

85-104: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a stable key for image attachments instead of array index.

key={i} causes incorrect React reconciliation when an image is removed from the middle of the list — remaining items shift indices and React may reuse the wrong DOM node, producing a brief flash of the previous image. Use a stable identifier from the image data.

🐛 Proposed fix
           {imgs.map((img, i) => (
-            <div key={i} className="relative">
+            <div key={img.name ?? `${img.media_type}-${i}`} className="relative">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/components/ChatInput.tsx` around lines 85 - 104, The
attachment list in ChatInput’s renderAttachments uses the array index as the
React key, which can cause incorrect reuse when an item is removed. Update the
mapping to use a stable identifier derived from each image object in imgs
instead of key={i}, and keep remove(i) unchanged so deletion still targets the
correct item. If the attachment data model does not already expose a unique id,
derive one from stable image fields available in this renderAttachments path
rather than the item position.
packages/jcode-ui/src/styles/components.css-48-52 (1)

48-52: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace deprecated word-break: break-word with overflow-wrap.

word-break: break-word is deprecated per CSS spec. Use overflow-wrap: break-word (or anywhere for stricter wrapping) to maintain the same behavior in current and future browsers.

🔧 Proposed fix for both occurrences
 .jcode-diff-table td {
   padding: 0 0.5rem;
   white-space: pre-wrap;
-  word-break: break-word;
+  overflow-wrap: break-word;
 }
 .jcode-file-table td {
   padding: 0 0.5rem;
   white-space: pre-wrap;
-  word-break: break-word;
+  overflow-wrap: break-word;
 }

Also applies to: 72-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/styles/components.css` around lines 48 - 52, Replace
the deprecated word-breaking rule in the stylesheet by updating the
.jcode-diff-table td declaration to use overflow-wrap instead of word-break:
break-word, and make the same change in the other matching rule referenced in
the comment. Keep the existing wrapping behavior intact by using overflow-wrap:
break-word (or anywhere if tighter wrapping is desired) and remove the
deprecated property from both affected style blocks.

Source: Linters/SAST tools

web-react/src/components/Sidebar.tsx-81-95 (1)

81-95: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add aria-current to the active session button for screen reader accessibility.

The active session is only indicated by visual styling. Screen reader users cannot identify which session is currently selected. Add aria-current="true" to the active session button.

♿ Proposed fix: add aria-current
             <button
               key={s.uuid}
               type="button"
               onClick={() => openSession(s)}
+              aria-current={s.uuid === currentSessionId ? 'true' : undefined}
               className={`group flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1.5 text-left text-sm transition-colors ${
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/Sidebar.tsx` around lines 81 - 95, The active
session button in the sessions.map render is only visually highlighted, so
screen readers can’t identify the current selection. Update the button in
Sidebar so the one matching currentSessionId also sets aria-current="true" while
keeping the existing openSession(s) behavior and visual styles unchanged.
web-react/src/app/store.ts-406-417 (1)

406-417: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Surface approval resolution errors to the user.

The catch block only clears the resolving flag but shows no error message. The user can retry but has no idea why the previous attempt failed. This is inconsistent with submitAskUser (lines 419–429) which dispatches a system error message on failure.

🛡️ Proposed fix: add error message on approval failure
     try {
       await api.approval(payload.id, payload.approved, payload.approveAll ?? false)
       dispatch(chatActions.resolveApprovalItem({ id: payload.id, approved: payload.approved }))
     } catch {
       dispatch(chatActions.setApprovalResolving({ id: payload.id, resolving: false }))
+      dispatch(chatActions.addMessage({ role: 'system', content: 'Failed to resolve approval', level: 'error' }))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/app/store.ts` around lines 406 - 417, The resolveApproval thunk
currently swallows approval failures by only resetting the resolving state, so
surface the error to the user as well. Update the catch path in resolveApproval
to dispatch the same kind of system error message used by submitAskUser, while
keeping the resolving flag reset and preserving the existing resolveApprovalItem
success flow. Use the resolveApproval and submitAskUser async thunks, along with
chatActions.setApprovalResolving and the system error dispatch pattern, to
locate and mirror the behavior.
web-react/src/components/SetupView.tsx-26-35 (1)

26-35: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Silent error swallowing on provider/model fetch.

Both catch blocks on lines 27 and 34 discard errors entirely. If the API is unreachable, the user sees empty dropdowns with no indication of what went wrong. Consider setting an error message in state to surface the failure.

🛡️ Proposed fix: surface fetch errors
   useEffect(() => {
-    api.setupProviders().then(setProviders).catch(() => {})
+    api.setupProviders().then(setProviders).catch((e) => setError(e instanceof Error ? e.message : String(e)))
   }, [])

   useEffect(() => {
     if (!selected) return
     setModels([])
     setModel('')
-    api.setupProviderModels(selected.id).then(setModels).catch(() => {})
+    api.setupProviderModels(selected.id).then(setModels).catch((e) => setError(e instanceof Error ? e.message : String(e)))
   }, [selected])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/SetupView.tsx` around lines 26 - 35, The fetch logic
in SetupView silently swallows failures in both api.setupProviders() and
api.setupProviderModels(), leaving the UI empty with no feedback. Update the two
useEffect handlers in SetupView to catch the error object, store a user-visible
error message in component state, and surface it in the UI instead of using
empty catch blocks. Make sure the fix is applied around the setupProviders and
setupProviderModels calls so failures are visible when loading providers or
models.
web-react/src/components/Sidebar.tsx-28-40 (1)

28-40: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

newChat clears state before confirming session creation succeeds.

clearChat() and setCurrentSession('') are dispatched synchronously before the async api.newSession() call. If the API fails, the user is left with a blank chat and no active session with no visible error. Consider creating the session first, then clearing once successful.

🛡️ Proposed fix: create session before clearing
 async function newChat() {
-    dispatch(chatActions.clearChat())
-    dispatch(sessionActions.setCurrentSession(''))
-    dispatch(uiActions.setView('chat'))
     try {
       const resp = await api.newSession()
+      dispatch(chatActions.clearChat())
+      dispatch(sessionActions.setCurrentSession(resp.session_id))
+      dispatch(uiActions.setView('chat'))
       const fresh = await api.sessions()
       dispatch(sessionActions.setSessions(fresh))
     } catch {
-      // surfaced via health/gate
+      dispatch(chatActions.addMessage({ role: 'system', content: 'Failed to create new session', level: 'error' }))
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/Sidebar.tsx` around lines 28 - 40, The newChat flow
clears chat and session state before confirming api.newSession() succeeds, which
can leave the UI empty if the request fails. Update newChat in Sidebar.tsx to
create the session first, then only call chatActions.clearChat(),
sessionActions.setCurrentSession(), and uiActions.setView('chat') after the
session is successfully created and resp.session_id is available; keep the
existing api.sessions() refresh after that.
web-react/src/components/ChannelsView.tsx-30-45 (1)

30-45: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Enable/Disable buttons don't update status after the action.

channelEnable and channelDisable both return { status: string; state: string } but the response is discarded. The status display remains stale after toggling. Update status from the API response to reflect the new state immediately.

💚 Proposed fix
             <button
               type="button"
-              onClick={() => api.channelEnable().catch(() => {})}
+              onClick={() => api.channelEnable().then((r) => setStatus({ available: true, state: r.state })).catch(() => {})}
               className="rounded-[var(--radius-md)] bg-[var(--color-primary)] px-3 py-1 text-xs text-[var(--color-on-primary)]"
             >
               Enable
             </button>
             <button
               type="button"
-              onClick={() => api.channelDisable().catch(() => {})}
+              onClick={() => api.channelDisable().then((r) => setStatus({ available: false, state: r.state })).catch(() => {})}
               className="rounded-[var(--radius-md)] bg-[var(--color-muted)] px-3 py-1 text-xs"
             >
               Disable
             </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/ChannelsView.tsx` around lines 30 - 45, The
Enable/Disable handlers in ChannelsView discard the return value from
api.channelEnable and api.channelDisable, so the displayed status never
refreshes. Update the onClick logic to await the API response, read the returned
status/state from those calls, and set the component’s status from that response
so the UI updates immediately after toggling.
web-react/src/app/wsBridge.ts-51-59 (1)

51-59: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the unnecessary as never cast

AppDispatch is already typeof store.dispatch, so dispatch(sendMessage(...)) should typecheck without a cast. The same cast also appears in web-react/src/App.tsx; remove both instead of bypassing the type system.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/app/wsBridge.ts` around lines 51 - 59, The unnecessary type
cast in onAgentDone is bypassing the existing AppDispatch typing; update the
wsBridge.ts send flow so dispatch(sendMessage({ text: next.text, images:
next.images })) typechecks without any cast, and remove the same as never cast
in App.tsx as well. Use the existing sendMessage action and dispatch/AppDispatch
typing to resolve the mismatch rather than suppressing it.
web-react/src/components/AuthGate.tsx-27-28 (1)

27-28: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Network errors show "Invalid token" misleadingly.

The catch block treats all failures (including network timeouts or server unreachable) as "Invalid token". Consider distinguishing connectivity errors from auth failures to help users troubleshoot.

💡 Suggested improvement
     } catch {
-      setError('Invalid token')
+      setError('Unable to connect. Check your network and try again.')
     } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/AuthGate.tsx` around lines 27 - 28, The catch block
in AuthGate is treating every failure as an auth failure, so connectivity issues
are surfaced as “Invalid token.” Update the error handling in AuthGate to
distinguish network/server-unreachable cases from actual token validation
failures by inspecting the thrown error in the catch path, and set a different
message for connectivity problems while keeping “Invalid token” only for genuine
auth errors.
web-react/src/components/CommandPalette.tsx-42-70 (1)

42-70: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Modal lacks ARIA attributes and focus management.

The palette is a modal dialog but is missing role="dialog", aria-modal="true", and aria-label. There is also no focus trap — Tab can escape into background content. Since the comment notes this is a skeleton, consider adding a TODO or tracking this for when the palette is fleshed out.

♿ Suggested accessibility improvements
     <div
       className="fixed inset-0 z-[var(--z-modal)] flex items-start justify-center bg-[var(--backdrop)] pt-[15vh]"
       onClick={() => dispatch(uiActions.setPaletteOpen(false))}
+      role="dialog"
+      aria-modal="true"
+      aria-label="Command palette"
     >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/CommandPalette.tsx` around lines 42 - 70, The
CommandPalette modal is missing required accessibility semantics and focus
handling. Update the outer dialog container in CommandPalette to include
role="dialog", aria-modal="true", and an accessible aria-label, and add a focus
trap so keyboard navigation cannot escape to the page behind it. If this is
still a skeleton, leave a TODO in CommandPalette and/or the modal wrapper to
track the focus-management work for later.
web-react/src/styles.css-3-8 (1)

3-8: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move @import 'jcode-ui/styles.css' before @custom-variant to satisfy CSS @import ordering.

CSS spec requires @import to precede all other at-rules. The current order (@custom-variant then @import) triggers a Stylelint no-invalid-position-at-import-rule error. Reordering is a no-op functionally — @custom-variant produces no CSS output by itself.

🔧 Proposed fix
 `@import` 'tailwindcss';
 
+@import 'jcode-ui/styles.css';
+
 `@custom-variant` dark (&:where(.dark, .dark *));
-
-/* jcode-ui ships its own tokens + component styles via jcode-ui/styles.css.
-   We import it here (single import site for the product app). It brings in the
-   base :root/.dark tokens, animations, and component-local CSS. */
-@import 'jcode-ui/styles.css';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/styles.css` around lines 3 - 8, Move the jcode-ui/styles.css
import in the stylesheet so it comes before the `@custom-variant` dark
declaration, since `@import` must be the first at-rule in the file. Update the top
of the web-react/src/styles.css entrypoint accordingly, keeping the existing
comment and the `@custom-variant` definition in place after the import. This is a
ordering-only change in the main CSS entrypoint and should not affect styling
output.

Source: Linters/SAST tools

packages/jcode-ui-core/src/primitives/Composer.tsx-44-45 (1)

44-45: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rename maxRows to maxHeight — the value is pixels, not rows.

The prop is documented as "Max textarea height in px" and defaults to 160 (px), but the name maxRows strongly implies a row count. A consumer could reasonably pass 5 expecting five text rows and instead get a 5px-tall textarea.

♻️ Proposed rename
 export interface ComposerProps extends ComposerRenderSlots {
   /** Placeholder text. */
   placeholder?: string
-  /** Max textarea height in px before it scrolls internally. */
-  maxRows?: number
+  /** Max textarea height in px before it scrolls internally. */
+  maxHeight?: number
   /** Slash commands (fetched by the host). Empty/undefined disables the menu. */
   slashCommands?: SlashCommand[]
-const DEFAULT_MAX_ROWS_PX = 160
+const DEFAULT_MAX_HEIGHT_PX = 160

 export function Composer({
   placeholder = 'Send a message…',
-  maxRows = DEFAULT_MAX_ROWS_PX,
+  maxHeight = DEFAULT_MAX_HEIGHT_PX,
   slashCommands,
   useLayoutEffect(() => {
     const el = textareaRef.current
     if (!el) return
     el.style.height = 'auto'
-    el.style.height = `${Math.min(el.scrollHeight, maxRows)}px`
-  }, [text, maxRows])
+    el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
+  }, [text, maxHeight])

Also applies to: 72-72

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/primitives/Composer.tsx` around lines 44 - 45,
Rename the Composer textarea sizing prop from maxRows to maxHeight in the
Composer component API, since the value is pixel-based rather than row-based.
Update the prop declaration and any related references in Composer to use
maxHeight consistently, including the default value handling and any internal
logic tied to the current maxRows name. Keep the documentation/comments aligned
with the new name so consumers understand it expects pixels.
packages/jcode-ui-core/src/primitives/ToolCallView.tsx-121-141 (1)

121-141: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add aria-expanded to the default toggle button.

DefaultToolHeader renders a <button> that toggles expansion, but it lacks aria-expanded. Screen readers cannot announce whether the tool call is expanded or collapsed. Adding the attribute is a one-line fix that improves the out-of-box accessibility of the default header.

♿ Proposed fix
     <button type="button" onClick={onToggle} aria-expanded={expanded} style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', background: 'none', border: 'none', padding: 0, textAlign: 'left' }}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/primitives/ToolCallView.tsx` around lines 121 -
141, The default toggle button in DefaultToolHeader is missing
expanded/collapsed state for assistive tech; add an aria-expanded attribute to
the button and bind it to the expanded prop so screen readers can announce the
current state. Keep the change in the DefaultToolHeader component alongside the
existing onClick and button attributes, and ensure the value reflects the
current expanded boolean.
packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx-51-55 (1)

51-55: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset armed state when choosing "Allow once" or "Deny".

If the user clicks "Allow all…" (arming the two-step confirm), then selects "Allow once" or "Deny" instead, the armed state remains true. If the resolve request fails and the approval stays pending, the user sees the armed "Confirm allow all / Cancel" buttons rather than the normal pending controls.

🛡️ Proposed fix
-  const allowOnce = () => actions.resolveApproval(approval.id, true, false)
+  const allowOnce = () => { setArmed(false); 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)
+  const deny = () => { setArmed(false); actions.resolveApproval(approval.id, false, false) }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx` around lines 51 -
55, The ApprovalBlock action handlers leave the local armed state stuck on after
"Allow all…" is armed, so update the allowOnce and deny handlers to clear armed
before resolving the approval. Keep the change scoped to the ApprovalBlock
component and use the existing setArmed, allowOnce, deny, and
allowAllConfirm/allowAllCancel handlers so the normal pending controls are
restored whenever the user chooses "Allow once" or "Deny", even if
actions.resolveApproval fails.
packages/jcode-ui-core/src/primitives/MessageView.tsx-62-70 (1)

62-70: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Clear the copied timeout on unmount.

The setTimeout in copy is never cleared if the component unmounts within 1500ms. While React 18 no longer warns about state updates on unmounted components, clearing the timer is good hygiene and prevents the callback from holding a reference to stale state.

🛡️ Proposed fix
 export function MessageView({
   message,
   canEdit = false,
   showCopy = true,
   className,
   renderContent,
   renderAvatar,
 }: MessageViewProps): ReactNode {
   const actions = useRuntimeActions()
   const [editing, setEditing] = useState(false)
   const [draft, setDraft] = useState(message.content)
   const [copied, setCopied] = useState(false)
+  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+  useEffect(() => () => {
+    if (copyTimer.current) clearTimeout(copyTimer.current)
+  }, [])
   const copy = useCallback(async () => {
     try {
       await navigator.clipboard.writeText(message.content)
       setCopied(true)
-      setTimeout(() => setCopied(false), 1500)
+      if (copyTimer.current) clearTimeout(copyTimer.current)
+      copyTimer.current = setTimeout(() => setCopied(false), 1500)
     } catch {
       // clipboard unavailable
     }
   }, [message.content])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/primitives/MessageView.tsx` around lines 62 - 70,
The timeout created in MessageView’s copy callback is never cleaned up, so store
the timer id in a ref and clear it when the component unmounts. Update the
useCallback in MessageView to keep the timeout handle, and add a cleanup effect
that clears any pending copied-reset timer so setCopied(false) cannot fire after
unmount.
site/src/playground/mockScript.ts-109-174 (1)

109-174: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the scripted fix consistent with the rendered story.

wg.Add/wg.Done never actually waits, and the closing narration says the goroutine is “joined on shutdown” even though the script never calls wg.Wait() or models a shutdown hook. Either add the missing wait path or revise the copy so the demo matches the code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/src/playground/mockScript.ts` around lines 109 - 174, The mock script in
mockScript.ts is inconsistent with the narrated outcome: the edited server.go
snippet adds a WaitGroup in handle() but never models any shutdown path or calls
wg.Wait(), so the “joined on shutdown” copy is inaccurate. Either update the
scripted sequence around tool/approval steps to include a real wait/shutdown
action tied to the handle/process flow, or change the final appendText narration
so it only claims the goroutine is tracked, not joined.
site/src/playground/ChatDemo.tsx-70-88 (1)

70-88: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the body height conditional on chrome.

h-[calc(100%-2.25rem)] still subtracts the titlebar height when chrome={false}, so the demo leaves a blank strip and shortens the chat area.

Suggested fix
-            <div className="flex h-[calc(100%-2.25rem)] flex-col">
+            <div className={`flex flex-col ${chrome ? 'h-[calc(100%-2.25rem)]' : 'h-full'}`}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/src/playground/ChatDemo.tsx` around lines 70 - 88, The ChatDemo layout
still applies the chrome titlebar offset even when chrome is disabled, so the
main body is too short and leaves blank space. Update the wrapper in ChatDemo so
the height calculation is conditional on the chrome prop, and only subtract the
titlebar height when the chrome header is actually rendered; keep the adjustment
aligned with the existing chrome and runtime layout blocks.
site/src/playground/ChatDemo.tsx-67-85 (1)

67-85: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the inner height conditional on chrome
h-[calc(100%-2.25rem)] still subtracts the header height when chrome={false}, so the demo body renders too short. Subtract that offset only when the chrome bar is shown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/src/playground/ChatDemo.tsx` around lines 67 - 85, The inner layout in
ChatDemo is always using the chrome header offset, so the body height is too
small when chrome is disabled. Update the wrapper inside the
RuntimeProvider/ToolRegistryProvider block to make the height calculation
conditional on chrome, and only apply the h-[calc(100%-2.25rem)] subtraction
when the chrome bar is actually rendered. Use the existing chrome prop and the
surrounding flex container in ChatDemo to keep the demo body full-height without
the header.
packages/jcode-ui-core/.npmignore-1-4 (1)

1-4: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

src/ exclusion conflicts with package.json files array.

.npmignore excludes src/, but package.json lists "src" in its files array. npm's .npmignore takes precedence and can exclude entries from the files allowlist, so src/ will likely be omitted from the published tarball. Meanwhile, tsconfig.build.json enables declarationMap: true, which generates .d.ts.map files referencing ../src/... paths that would be broken without src published.

Decide the intent: either remove src/ from .npmignore (to publish source for declaration maps), or remove "src" from the files array (if source should not be shipped).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/.npmignore` around lines 1 - 4, The package publishing
config is conflicting: .npmignore excludes src/ while package.json’s files array
includes src, so the source may be dropped from the tarball and break
declarationMap references. Update the packaging setup by choosing one intent in
the relevant package.json/.npmignore pair: either stop excluding src/ in
.npmignore so the source ships with the package, or remove src from the files
allowlist if source should not be published. Recheck the package root settings
to keep the publish output consistent with tsconfig.build.json and the generated
declaration maps.
🧹 Nitpick comments (14)
Makefile (1)

70-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the build-web-react recipe into sub-targets.

checkmake flags this target body as exceeding its 5-line guideline (6 lines). While cosmetic, splitting the package builds (jcode-ui-core, jcode-ui, tailwind) into a reusable build-jcode-ui prerequisite target would reduce the recipe body and allow lint-react to share the same build step if needed later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 70 - 76, The build-web-react recipe is too long and
should be split into smaller reusable targets. Extract the package build steps
for jcode-ui-core, jcode-ui, and the Tailwind CSS generation into a new
prerequisite such as build-jcode-ui, then make build-web-react depend on it and
keep only the remaining frontend build steps there. Use the existing
build-web-react target and the package build commands as the main anchors when
refactoring so lint-react can reuse the shared build step later if needed.

Source: Linters/SAST tools

packages/jcode-ui/package.json (1)

48-51: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider enabling npm provenance for supply-chain integrity.

"provenance": false disables npm package provenance attestation, which links published artifacts to their source build. This is a supply-chain security posture gap. If the CI supports it (GitHub Actions with OIDC), consider enabling provenance to give consumers verifiable build provenance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/package.json` around lines 48 - 51, The package publish
configuration currently disables npm provenance, so update the publishConfig in
package.json to enable provenance if the CI/publish flow supports it. Keep the
existing public access setting, and change the provenance option so published
artifacts from this package can carry build attestation for supply-chain
integrity.
packages/jcode-ui/src/lib/apiBaseContext.tsx (1)

7-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import ReactNode explicitly instead of relying on the global React namespace.

React.ReactNode is used on line 14 but only createContext is imported from react. This works because @types/react declares a global React namespace, but relying on globals is fragile and inconsistent with the explicit import style used elsewhere in the file.

Proposed refactor
-import { createContext } from 'react'
+import { createContext, type ReactNode } from 'react'

And update the type reference:

-  children: React.ReactNode
+  children: ReactNode
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/lib/apiBaseContext.tsx` around lines 7 - 15, The
ApiBaseProviderProps type is relying on the global React namespace for
React.ReactNode instead of importing the type explicitly. Update the react
import in apiBaseContext to bring in ReactNode alongside createContext, and
change the children field in ApiBaseProviderProps to use that imported ReactNode
type so the file no longer depends on implicit globals.
packages/jcode-ui/src/lib/markdown.ts (1)

10-10: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use highlight.js/lib/common to reduce bundle size.

Importing the full highlight.js pulls in ~190+ language grammars, significantly bloating the bundle for a publishable library. The common export includes ~30 frequently-used languages and covers the vast majority of chat code blocks. This also speeds up highlightAuto() since it tests against fewer grammars.

♻️ Proposed refactor
- import hljs from 'highlight.js'
+ import hljs from 'highlight.js/lib/common'

If specific less-common languages are needed, register them individually:

+import hljs from 'highlight.js/lib/core'
+import typescript from 'highlight.js/lib/languages/typescript'
+import go from 'highlight.js/lib/languages/go'
+// ... register only what you need
+hljs.registerLanguage('typescript', typescript)
+hljs.registerLanguage('go', go)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/lib/markdown.ts` at line 10, The markdown highlighter
import in `markdown.ts` is pulling in the full `highlight.js` package, which
inflates the library bundle. Update the import used by the markdown
rendering/highlighting path to `highlight.js/lib/common` instead, and keep the
existing highlighting logic (including any `highlightAuto()` usage) wired
through that shared instance so the code still works with the smaller common
grammar set.
packages/jcode-ui/src/components/ContextBar.tsx (1)

43-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add accessible name to the SVG ring.

The occupancy ring conveys information visually but has no role or aria-label, making it inaccessible to screen reader users. The hover popover is also mouse-only (group-hover with pointer-events-none), so keyboard users can't reach the breakdown.

♿ Proposed accessibility improvements
       <svg width={size} height={size} className="-rotate-90"
+        role="img"
+        aria-label={`Context ${Math.round(pct * 100)}% full`}
       >

To make the popover keyboard-accessible, add focus-within alongside hover:

-        <div className="pointer-events-none absolute bottom-full right-0 mb-2 w-56 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 opacity-0 shadow-[var(--shadow-md)] transition-opacity group-hover:opacity-100">
+        <div className="pointer-events-none absolute bottom-full right-0 mb-2 w-56 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 opacity-0 shadow-[var(--shadow-md)] transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/components/ContextBar.tsx` around lines 43 - 64, The
occupancy ring in ContextBar is missing an accessible name and the breakdown
popover is only exposed on mouse hover, so screen reader and keyboard users
can’t access it. Update the SVG ring element in the ContextBar component to
include a proper accessible role and aria-label (or equivalent labeling) that
describes the occupancy state, and adjust the popover trigger/visibility logic
so it also appears on keyboard focus by using focus-within alongside hover
instead of relying only on group-hover and pointer-events-none.
packages/jcode-ui/src/toolRenderers/terminal.tsx (1)

9-16: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

command is parsed on every re-render without memoization.

When output streams in, the memoized component re-renders and re-parses args each time. Wrap the parse in useMemo keyed on args to avoid redundant JSON.parse calls.

♻️ Proposed refactor
 export const TerminalRenderer = memo(function TerminalRenderer({ args, output, error, status }: ToolRendererProps) {
-  let command = ''
-  try {
-    const parsed = JSON.parse(args)
-    command = parsed.command ?? ''
-  } catch {
-    // ignore
-  }
+  const command = useMemo(() => {
+    try {
+      return JSON.parse(args).command ?? ''
+    } catch {
+      return ''
+    }
+  }, [args])
   const isError = status === 'error' || !!error
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/toolRenderers/terminal.tsx` around lines 9 - 16, The
TerminalRenderer component is re-parsing args on every memoized re-render, which
causes redundant JSON.parse work as output updates. Update TerminalRenderer to
derive command with useMemo keyed only on args, and keep the parsing logic
inside that memo so the parsed command is reused unless args changes.
packages/jcode-ui/src/toolRenderers/diff.tsx (1)

63-82: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

buildDiff produces a before/after dump, not a real diff.

The current implementation marks every old line as del and every new line as add. For a 100-line edit where only one line changed, this renders 200 rows instead of ~3, creating excessive noise and poor performance on large edits.

Consider using a lightweight LCS-based diff (e.g., the diff npm package or a minimal Myers diff) to produce interleaved context/add/del rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/toolRenderers/diff.tsx` around lines 63 - 82,
`buildDiff` currently emits a full before/after dump by marking every
`old_string` line as `del` and every `new_string` line as `add`, which creates
noisy output and unnecessary rows for small edits. Update `buildDiff` in
`diff.tsx` to compute an actual line diff for each `EditSpec` instead of blindly
dumping both sides, using a lightweight LCS/Myers-style approach or a small diff
library, and keep the existing `path`/`rows` shape while producing interleaved
context, add, and delete rows.
packages/jcode-ui/src/toolRenderers/fileViewer.tsx (1)

16-24: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

path is parsed on every re-render while lines is memoized.

The path JSON.parse runs outside useMemo, so when output changes during streaming, the component re-renders and re-parses args unnecessarily. Consolidate both into a single useMemo for consistency and to avoid redundant parsing.

♻️ Proposed refactor
 export const FileViewerRenderer = memo(function FileViewerRenderer({ args, output }: ToolRendererProps) {
-  let path = ''
-  try {
-    const parsed = JSON.parse(args)
-    path = parsed.path ?? parsed.file_path ?? ''
-  } catch {
-    // ignore
-  }
-  const lines = useMemo(() => parseLines(output), [output])
+  const { path, lines } = useMemo(() => {
+    let p = ''
+    try {
+      const parsed = JSON.parse(args)
+      p = parsed.path ?? parsed.file_path ?? ''
+    } catch {
+      // ignore
+    }
+    return { path: p, lines: parseLines(output) }
+  }, [args, output])
   return (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/toolRenderers/fileViewer.tsx` around lines 16 - 24, The
FileViewerRenderer component is re-parsing args on every render while output
parsing is already memoized. Move the JSON.parse logic for args and the path
extraction into a useMemo alongside the existing parseLines(output) memoization
in FileViewerRenderer so both derived values are computed consistently and only
recomputed when their inputs change.
web-react/src/App.tsx (1)

126-126: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

useRef(createDefaultToolRegistry()) calls the factory on every render.

useRef(initialValue) evaluates initialValue on every render but only assigns it to .current on the first render. createDefaultToolRegistry() is invoked unnecessarily on all subsequent renders. Use useState with a lazy initializer instead.

♻️ Proposed fix
-  const registry = useRef(createDefaultToolRegistry()).current
+  const [registry] = useState(() => createDefaultToolRegistry())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/App.tsx` at line 126, The App component is eagerly invoking
createDefaultToolRegistry on every render via
useRef(createDefaultToolRegistry()). Replace this with a lazy initialization
approach using useState so the default tool registry is created only once on the
first render and then reused; keep the registry variable in App as the stable
reference used by the rest of the component.
web-react/src/components/AuthGate.tsx (1)

65-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

signOut taking dispatch as a parameter is an unusual pattern.

Exporting a standalone function that requires callers to pass dispatch manually is brittle and inconsistent with the hook-based patterns used elsewhere. Consider making it a custom hook or a plain action creator.

♻️ Option A: custom hook
-export function signOut(dispatch: ReturnType<typeof useAppDispatch>) {
+export function useSignOut() {
+  const dispatch = useAppDispatch()
+  return () => {
     clearAuthToken()
     dispatch(uiActions.setNeedsAuth(true))
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/AuthGate.tsx` around lines 65 - 69, The exported
signOut helper is using a manually passed dispatch argument, which is
inconsistent with the hook-based patterns in AuthGate. Refactor signOut into a
custom hook (or otherwise move dispatch access inside the helper) so callers no
longer need to pass dispatch explicitly, and update any call sites to use the
new hook-based API while keeping clear references to signOut, clearAuthToken,
and uiActions.setNeedsAuth.
web-react/src/lib/ws.ts (1)

111-118: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider exponential backoff for reconnection.

The fixed 3s retry runs indefinitely with no backoff or max-retry cap. For the desktop sidecar this is fine, but in browser mode against a remote server, a prolonged outage would produce relentless reconnection attempts. Consider exponential backoff (e.g., 3s → 6s → 12s, capped at 30s) and/or a max retry count with a connection-status dispatch to Redux.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/lib/ws.ts` around lines 111 - 118, The reconnect logic in the
WebSocket close handler uses a fixed 3s delay with no cap, so update the
reconnect flow in ws.ts (the onclose handler and connect retry logic) to use
exponential backoff with a maximum delay and optionally a max retry limit. Track
retry state in the WebSocket client class so each failed reconnect increases the
delay, reset it on successful connection, and dispatch a connection-status
update to Redux when retries are exhausted or the client is offline too long.
web-react/src/lib/authToken.ts (1)

22-24: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Token stored in localStorage is accessible to any script (CWE-312).

The comment explains the rationale (shared key with Vue, avoiding circular imports), and this is a ported pattern. Consider migrating to httpOnly cookie-based auth in a future PR to prevent XSS token exfiltration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/lib/authToken.ts` around lines 22 - 24, The auth token
persistence in setAuthToken uses localStorage, which leaves it readable by any
script and is a security risk. Update the auth flow to stop storing the token in
localStorage and migrate token handling toward an httpOnly cookie-based
approach, coordinating with the shared KEY/authToken usage so the Vue/shared-key
pattern is preserved without exposing the token to JavaScript.

Source: Linters/SAST tools

packages/jcode-ui-core/package.json (1)

69-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

@tanstack/react-virtual as a hard dependency contradicts the optional-React claim.

The package marks react/react-dom as optional peer dependencies and the README states non-React entries work in any TS project. However, @tanstack/react-virtual is a hard dependency — it will always be installed even by consumers who only use types, runtime, or adapters. Consider making it an optional peer dependency (grouped with react) or documenting that the package always pulls in the virtualization library.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/package.json` around lines 69 - 83, The package
currently treats react and react-dom as optional in the
peerDependencies/peerDependenciesMeta section, but `@tanstack/react-virtual` is
still installed unconditionally via dependencies, which conflicts with the
non-React usage claim. Update package.json so the virtualization package is
either moved into the same optional peer dependency setup as react-related
entries or clearly documented as always required, and make the dependency
grouping in the package manifest match the intended behavior for consumers of
the non-React APIs.
packages/jcode-ui-core/src/runtime/index.ts (1)

45-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use named ChatImage and AskUserAnswer types instead of inline duplicates.

RuntimeActions defines images as { data: string; media_type: string }[] and answers as { question_header: string; answer: string; selected?: string[] }[] — structurally identical to ChatImage and AskUserAnswer already exported from types/index.ts. If either type evolves, these inline definitions will silently drift.

♻️ Proposed refactor
 import type { ThreadItem, TokenSnapshot, Goal, TodoItem, QueuedMessage } from '../types/index.js'
+import type { ChatImage, AskUserAnswer } from '../types/index.js'

 // ...

 export interface RuntimeActions {
   /** Send a user-authored message. `images` are base64 payloads. */
-  sendMessage: (text: string, images?: { data: string; media_type: string }[]) => void
+  sendMessage: (text: string, images?: ChatImage[]) => void
   /** Enqueue a message while a turn is running (type-ahead). */
-  enqueueMessage: (text: string, images?: { data: string; media_type: string }[]) => void
+  enqueueMessage: (text: string, images?: ChatImage[]) => void
   /** Remove a queued message by id (before it is sent). */
   removeQueuedMessage: (id: string) => void
   /** Cancel the in-flight turn. */
   stop: () => void
   /** Resolve an approval gate. `approveAll` arms "allow all future" semantics. */
   resolveApproval: (id: string, approved: boolean, approveAll?: boolean) => void
   /** Answer an `ask_user` batch. */
-  submitAskUser: (id: string, answers: { question_header: string; answer: string; selected?: string[] }[]) => void
+  submitAskUser: (id: string, answers: AskUserAnswer[]) => void
   /** Edit a past user message and resend from that point. */
   editMessage: (id: string, newText: string) => void
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/runtime/index.ts` around lines 45 - 60, The
RuntimeActions interface is duplicating the shapes already defined by ChatImage
and AskUserAnswer, which can drift over time. Update the sendMessage,
enqueueMessage, and submitAskUser signatures in RuntimeActions to reference the
exported ChatImage and AskUserAnswer types from types/index.ts instead of inline
object arrays, keeping the existing method names and behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 30e04f68-473a-483d-bbca-2c5f370499eb

📥 Commits

Reviewing files that changed from the base of the PR and between 76a68d1 and 229466c.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • site/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (105)
  • .gitignore
  • .npmrc
  • AGENTS.md
  • Makefile
  • docs/tool-search-architecture-draft.md
  • internal/model/registry_generated.go
  • internal/web/dist-react/assets/core-DV6XEvTN.js
  • internal/web/dist-react/assets/index-Bg8rBW4i.js
  • internal/web/dist-react/assets/index-Cs-LfJ7j.css
  • internal/web/dist-react/index.html
  • packages/jcode-ui-core/.npmignore
  • packages/jcode-ui-core/LICENSE
  • packages/jcode-ui-core/README.md
  • packages/jcode-ui-core/package.json
  • packages/jcode-ui-core/src/adapters/index.ts
  • packages/jcode-ui-core/src/hooks/index.ts
  • packages/jcode-ui-core/src/index.ts
  • packages/jcode-ui-core/src/primitives/ApprovalBlock.tsx
  • packages/jcode-ui-core/src/primitives/AskUserBlock.tsx
  • packages/jcode-ui-core/src/primitives/Composer.tsx
  • packages/jcode-ui-core/src/primitives/MessageView.tsx
  • packages/jcode-ui-core/src/primitives/Thread.tsx
  • packages/jcode-ui-core/src/primitives/ToolCallView.tsx
  • packages/jcode-ui-core/src/primitives/index.ts
  • packages/jcode-ui-core/src/runtime/context.tsx
  • packages/jcode-ui-core/src/runtime/externalStore.ts
  • packages/jcode-ui-core/src/runtime/index.ts
  • packages/jcode-ui-core/src/runtime/mockRuntime.ts
  • packages/jcode-ui-core/src/types/index.ts
  • packages/jcode-ui-core/tsconfig.build.json
  • packages/jcode-ui-core/tsconfig.json
  • packages/jcode-ui/.npmignore
  • packages/jcode-ui/LICENSE
  • packages/jcode-ui/README.md
  • packages/jcode-ui/package.json
  • packages/jcode-ui/src/components/ApprovalBanner.tsx
  • packages/jcode-ui/src/components/AskUserCard.tsx
  • packages/jcode-ui/src/components/ChatInput.tsx
  • packages/jcode-ui/src/components/ContextBar.tsx
  • packages/jcode-ui/src/components/Message.tsx
  • packages/jcode-ui/src/components/Thread.tsx
  • packages/jcode-ui/src/components/ToolCallCard.tsx
  • packages/jcode-ui/src/components/ToolRegistryContext.tsx
  • packages/jcode-ui/src/index.ts
  • packages/jcode-ui/src/lib/apiBaseContext.tsx
  • packages/jcode-ui/src/lib/markdown.ts
  • packages/jcode-ui/src/styles/animations.css
  • packages/jcode-ui/src/styles/components.css
  • packages/jcode-ui/src/styles/entry.css
  • packages/jcode-ui/src/styles/tokens.css
  • packages/jcode-ui/src/toolRenderers/browserShot.tsx
  • packages/jcode-ui/src/toolRenderers/diff.tsx
  • packages/jcode-ui/src/toolRenderers/fileViewer.tsx
  • packages/jcode-ui/src/toolRenderers/generic.tsx
  • packages/jcode-ui/src/toolRenderers/index.ts
  • packages/jcode-ui/src/toolRenderers/search.tsx
  • packages/jcode-ui/src/toolRenderers/skill.tsx
  • packages/jcode-ui/src/toolRenderers/team.tsx
  • packages/jcode-ui/src/toolRenderers/terminal.tsx
  • packages/jcode-ui/src/toolRenderers/todo.tsx
  • packages/jcode-ui/tsconfig.build.json
  • packages/jcode-ui/tsconfig.json
  • pnpm-workspace.yaml
  • site/docs/chat-ui/index.md
  • site/docs/chat-ui/primitives.md
  • site/docs/chat-ui/runtime.md
  • site/docs/chat-ui/theming.md
  • site/docs/chat-ui/tool-renderers.md
  • site/package.json
  • site/src/App.tsx
  • site/src/components/SiteNav.tsx
  • site/src/pages/ChatUIPage.tsx
  • site/src/pages/chatui.css
  • site/src/playground/ChatDemo.tsx
  • site/src/playground/mockScript.ts
  • site/tsconfig.app.tsbuildinfo
  • web-react/index.html
  • web-react/package.json
  • web-react/src/App.tsx
  • web-react/src/app/hooks.ts
  • web-react/src/app/runtime.ts
  • web-react/src/app/store.ts
  • web-react/src/app/wsBridge.ts
  • web-react/src/components/AuthGate.tsx
  • web-react/src/components/AutomationsView.tsx
  • web-react/src/components/ChannelsView.tsx
  • web-react/src/components/ChatView.tsx
  • web-react/src/components/CommandPalette.tsx
  • web-react/src/components/GoalBanner.tsx
  • web-react/src/components/ProjectHeader.tsx
  • web-react/src/components/SetupView.tsx
  • web-react/src/components/Sidebar.tsx
  • web-react/src/lib/api.ts
  • web-react/src/lib/apiBase.ts
  • web-react/src/lib/authToken.ts
  • web-react/src/lib/automation.ts
  • web-react/src/lib/types.ts
  • web-react/src/lib/useDesktop.ts
  • web-react/src/lib/ws.ts
  • web-react/src/main.tsx
  • web-react/src/styles.css
  • web-react/tsconfig.app.json
  • web-react/tsconfig.json
  • web-react/tsconfig.node.json
  • web-react/vite.config.ts

import type { ComponentType } from 'react'
import type { ToolCall, ToolDisplayInfo, ToolStatus } from '../types/index.js'

export type { ToolStatus }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove export type { ToolStatus } — it creates an ambiguous barrel re-export.

ToolStatus is already exported from ../types/index.js. When index.ts does export * from './types/index.js' and export * from './adapters/index.js', TypeScript silently drops ToolStatus from the barrel because it's ambiguous. This means import { type ToolStatus } from 'jcode-ui-core' will fail with "Module has no exported member 'ToolStatus'."

🐛 Proposed fix
 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 {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type { ToolStatus }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/adapters/index.ts` at line 14, Remove the
redundant ToolStatus type re-export from the adapters barrel so the root barrel
no longer has an ambiguous export. Update the adapters/index.ts barrel to stop
exporting ToolStatus, since it is already exposed through types/index.js and the
top-level index.ts re-exports both barrels. Keep the shared type available only
from the types barrel so import { type ToolStatus } from 'jcode-ui-core'
resolves correctly.

Comment on lines +56 to +59
export function useIsAtBottom<T extends HTMLElement>(threshold = 80) {
const { ref, onScroll, scrollToBottom } = useAutoScroll<T>(threshold)
return { ref, onScroll, scrollToBottom }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

useIsAtBottom doesn't re-render as documented — implementation is incomplete.

The doc comment claims it "re-renders the component when the flag flips" and "intentionally tracks a coarse boolean," but the implementation has no useState, returns no isAtBottom value, and is just a subset of useAutoScroll's return. Consumers using this hook will get no re-renders and no flag to read.

🐛 Proposed fix
 export function useIsAtBottom<T extends HTMLElement>(threshold = 80) {
-  const { ref, onScroll, scrollToBottom } = useAutoScroll<T>(threshold)
-  return { ref, onScroll, scrollToBottom }
+  const { ref, onScroll: baseOnScroll, scrollToBottom, getIsAtBottom } = useAutoScroll<T>(threshold)
+  const [isAtBottom, setIsAtBottom] = useState(true)
+
+  const onScroll = useCallback(() => {
+    baseOnScroll()
+    const next = getIsAtBottom()
+    setIsAtBottom((prev) => (prev !== next ? next : prev))
+  }, [baseOnScroll, getIsAtBottom])
+
+  return { ref, onScroll, scrollToBottom, isAtBottom }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function useIsAtBottom<T extends HTMLElement>(threshold = 80) {
const { ref, onScroll, scrollToBottom } = useAutoScroll<T>(threshold)
return { ref, onScroll, scrollToBottom }
}
export function useIsAtBottom<T extends HTMLElement>(threshold = 80) {
const { ref, onScroll: baseOnScroll, scrollToBottom, getIsAtBottom } = useAutoScroll<T>(threshold)
const [isAtBottom, setIsAtBottom] = useState(true)
const onScroll = useCallback(() => {
baseOnScroll()
const next = getIsAtBottom()
setIsAtBottom((prev) => (prev !== next ? next : prev))
}, [baseOnScroll, getIsAtBottom])
return { ref, onScroll, scrollToBottom, isAtBottom }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/hooks/index.ts` around lines 56 - 59, The
`useIsAtBottom` hook is only forwarding `useAutoScroll` and never tracks or
exposes the bottom state, so it cannot re-render as documented. Update
`useIsAtBottom` to include local state and scroll handling logic that derives a
coarse boolean flag, returns that flag (for example alongside `ref` and
`scrollToBottom`), and triggers re-renders when the bottom status flips; use the
existing `useIsAtBottom` and `useAutoScroll` symbols to keep the API aligned
with the docs.

Comment on lines +66 to +70
const followDep = useMemo(() => {
if (!last) return items.length
if (last.kind === 'message') return `${items.length}:${last.data.content.length}`
return items.length
}, [items, last])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify ThreadItem type definition and kind variants
rg -n 'kind.*tool|kind.*message|kind.*approval' packages/jcode-ui-core/src/types/index.ts -C3

Repository: cnjack/jcode

Length of output: 1019


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Thread.tsx =='
ast-grep outline packages/jcode-ui-core/src/primitives/Thread.tsx --view expanded || true
echo
nl -ba packages/jcode-ui-core/src/primitives/Thread.tsx | sed -n '1,240p'

echo
echo '== useStreamFollow references =='
rg -n "useStreamFollow|followDep|scrollIntoView|autoScroll" packages/jcode-ui-core/src -C3

echo
echo '== ToolCall shape =='
rg -n "type ToolCall|interface ToolCall|export type ToolCall|output:" packages/jcode-ui-core/src/types -C4

Repository: cnjack/jcode

Length of output: 1184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== packages/jcode-ui-core/src/primitives/Thread.tsx (relevant slice) =='
sed -n '1,260p' packages/jcode-ui-core/src/primitives/Thread.tsx

echo
echo '== useStreamFollow / related hook implementations =='
rg -n "function useStreamFollow|const useStreamFollow|export function useStreamFollow|useStreamFollow" packages/jcode-ui-core/src -C4

echo
echo '== ToolCall type definition =='
rg -n "export type ToolCall|interface ToolCall|type ToolCall" packages/jcode-ui-core/src/types -C8

Repository: cnjack/jcode

Length of output: 11717


Track tool output in followDep packages/jcode-ui-core/src/primitives/Thread.tsx:66-70
followDep only reacts to message text length. When the last item is a tool call, changes to ToolCall.output won't retrigger useStreamFollow, so the thread can stop auto-scrolling during streamed tool output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/primitives/Thread.tsx` around lines 66 - 70, The
followDep memo in Thread should also depend on streamed tool output, not just
message content length. Update the logic in Thread.tsx around
followDep/useStreamFollow so that when the last item is a tool call it
incorporates ToolCall.output (or a stable representation of it) into the
dependency value, ensuring changes to tool output retrigger auto-follow during
streaming.

Comment on lines +89 to +104
const cacheRef = useRef<{ snapshot: RuntimeState; value: T } | null>(null)
const subscribe = runtime.subscribe
const getSnapshot = () => normalizeState(runtime.getState())
// Prime the cache on first read / after a store change useSyncExternalStore detected.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const cache = cacheRef.current
if (!cache || cache.snapshot !== snapshot) {
const value = selector(snapshot)
if (!cache || !isEqual(cache.value, value)) {
cacheRef.current = { snapshot, value }
} else {
// keep old value identity, just refresh the snapshot stamp
cacheRef.current = { snapshot, value: cache.value }
}
}
return cacheRef.current!.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

getSnapshot returns a new object on every call — causes infinite re-render loop with useSyncExternalStore.

normalizeState(runtime.getState()) creates a fresh object each invocation. React's useSyncExternalStore requires getSnapshot to return a referentially stable value when the store hasn't changed; otherwise its internal useLayoutEffect detects a "change" on every render, calls forceStore, and loops infinitely. The cacheRef layer below runs after useSyncExternalStore returns, so it cannot prevent the loop.

Additionally, getSnapshot is a new arrow function on every render, which re-triggers the effect's [subscribe, getSnapshot] dependency check each time.

Two fixes are needed (the second is in externalStore.ts):

  1. Remove the redundant normalizeState call — ChatRuntime.getState() already returns RuntimeState.
  2. Memoize getSnapshot with useCallback so the function reference is stable.
  3. Cache getState() in createExternalStoreRuntime so it returns a stable RuntimeState reference when the underlying store hasn't changed.
🐛 Proposed fix for context.tsx
 import { createContext, useContext, useMemo, useRef, useSyncExternalStore } from 'react'
+import { useCallback } from 'react'
 import type { ReactNode } from 'react'
 import type { ChatRuntime, RuntimeState } from './index.js'
-import { normalizeState } from './index.js'
 function useRuntimeSelectorInternal<T>(
   runtime: ChatRuntime,
   selector: (state: RuntimeState) => T,
   isEqual: (a: T, b: T) => boolean,
 ): T {
   const cacheRef = useRef<{ snapshot: RuntimeState; value: T } | null>(null)
   const subscribe = runtime.subscribe
-  const getSnapshot = () => normalizeState(runtime.getState())
+  const getSnapshot = useCallback(() => runtime.getState(), [runtime])
   // Prime the cache on first read / after a store change useSyncExternalStore detected.
   const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const cacheRef = useRef<{ snapshot: RuntimeState; value: T } | null>(null)
const subscribe = runtime.subscribe
const getSnapshot = () => normalizeState(runtime.getState())
// Prime the cache on first read / after a store change useSyncExternalStore detected.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const cache = cacheRef.current
if (!cache || cache.snapshot !== snapshot) {
const value = selector(snapshot)
if (!cache || !isEqual(cache.value, value)) {
cacheRef.current = { snapshot, value }
} else {
// keep old value identity, just refresh the snapshot stamp
cacheRef.current = { snapshot, value: cache.value }
}
}
return cacheRef.current!.value
const cacheRef = useRef<{ snapshot: RuntimeState; value: T } | null>(null)
const subscribe = runtime.subscribe
const getSnapshot = useCallback(() => runtime.getState(), [runtime])
// Prime the cache on first read / after a store change useSyncExternalStore detected.
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const cache = cacheRef.current
if (!cache || cache.snapshot !== snapshot) {
const value = selector(snapshot)
if (!cache || !isEqual(cache.value, value)) {
cacheRef.current = { snapshot, value }
} else {
// keep old value identity, just refresh the snapshot stamp
cacheRef.current = { snapshot, value: cache.value }
}
}
return cacheRef.current!.value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui-core/src/runtime/context.tsx` around lines 89 - 104, The
`useRuntimeState` hook in `context.tsx` is causing `useSyncExternalStore` to see
a changing snapshot on every render because `getSnapshot` wraps
`runtime.getState()` in `normalizeState` and is recreated as a new arrow
function each time. Update `getSnapshot` to return the runtime state directly,
memoize it with `useCallback`, and keep the existing `cacheRef` logic only for
selector/value stabilization. Also adjust `createExternalStoreRuntime` in
`externalStore.ts` so `getState()` returns the same `RuntimeState` reference
when the store has not changed, ensuring the snapshot stays referentially
stable.

Comment on lines +64 to +72
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tailwindcss/typography": "^0.5.16",
"dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"jcode-ui-core": "file:../jcode-ui-core",
"marked": "^18.0.0",
"marked-highlight": "^2.2.2"
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

file:../jcode-ui-core will break for npm consumers.

"jcode-ui-core": "file:../jcode-ui-core" only resolves inside the pnpm workspace. When jcode-ui is published to npm, the file: protocol is not rewritten by pnpm's publish step (only workspace:* is). External consumers running npm install jcode-ui will fail because ../jcode-ui-core doesn't exist on their machine.

Use workspace:* (pnpm replaces it with the real version at publish time) or a concrete version range:

Proposed fix
   "dependencies": {
     "`@heroicons/react`": "^2.2.0",
     "`@tailwindcss/typography`": "^0.5.16",
     "dompurify": "^3.2.4",
     "highlight.js": "^11.11.1",
-    "jcode-ui-core": "file:../jcode-ui-core",
+    "jcode-ui-core": "workspace:*",
     "marked": "^18.0.0",
     "marked-highlight": "^2.2.2"
   },

If you prefer a pinned range instead of workspace:*, use "jcode-ui-core": "^0.1.0" matching the core package's published version.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"dependencies": {
"@heroicons/react": "^2.2.0",
"@tailwindcss/typography": "^0.5.16",
"dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"jcode-ui-core": "file:../jcode-ui-core",
"marked": "^18.0.0",
"marked-highlight": "^2.2.2"
},
"dependencies": {
"`@heroicons/react`": "^2.2.0",
"`@tailwindcss/typography`": "^0.5.16",
"dompurify": "^3.2.4",
"highlight.js": "^11.11.1",
"jcode-ui-core": "workspace:*",
"marked": "^18.0.0",
"marked-highlight": "^2.2.2"
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/package.json` around lines 64 - 72, The dependency
declaration for jcode-ui-core is workspace-only and will break for published npm
consumers. Update the jcode-ui/package.json dependency entry in the dependencies
block to use a publish-safe reference such as workspace:* or a concrete semver
range matching the published jcode-ui-core version, so the package can be
installed outside the pnpm workspace.

Comment on lines +431 to +438
export const editMessage = createAsyncThunk(
'chat/edit',
async (payload: { id: string; text: string }, { dispatch }) => {
// Trim the timeline up to (and including) the edited message, then resend.
dispatch(chatActions.clearChat())
await dispatch(sendMessage({ text: payload.text }))
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

editMessage clears the entire timeline instead of trimming to the edited message.

The comment says "Trim the timeline up to (and including) the edited message, then resend," but clearChat() wipes the full timeline, goal, todos, and queued messages. The id parameter is accepted but never used. Additionally, images from the original message are not passed to sendMessage, so they are silently lost on edit.

If this is an intentional simplification for the migration skeleton, the comment should reflect the actual behavior. If not, the thunk should use id to find the message index, truncate the timeline up to that point, and forward the original images.

🔧 Proposed fix: trim timeline to edited message
 export const editMessage = createAsyncThunk(
   'chat/edit',
-  async (payload: { id: string; text: string }, { dispatch }) => {
-    // Trim the timeline up to (and including) the edited message, then resend.
-    dispatch(chatActions.clearChat())
-    await dispatch(sendMessage({ text: payload.text }))
+  async (payload: { id: string; text: string }, { dispatch, getState }) => {
+    const state = getState() as RootState
+    const idx = state.chat.timeline.findIndex(
+      (i) => i.kind === 'message' && i.data.id === payload.id,
+    )
+    if (idx === -1) return
+    // Trim everything after (and including) the edited message.
+    dispatch(chatActions.trimTimeline(idx))
+    // Forward original images if present.
+    const orig = state.chat.timeline[idx]
+    const images = orig.kind === 'message' ? orig.data.images : undefined
+    await dispatch(sendMessage({ text: payload.text, images }))
   },
 )

This requires adding a trimTimeline reducer to the chat slice:

+    trimTimeline(s, a: { payload: number }) {
+      s.timeline = s.timeline.slice(0, a.payload)
+      s.isRunning = false
+      streamingText = ''
+      streamingMsgId = ''
+    },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const editMessage = createAsyncThunk(
'chat/edit',
async (payload: { id: string; text: string }, { dispatch }) => {
// Trim the timeline up to (and including) the edited message, then resend.
dispatch(chatActions.clearChat())
await dispatch(sendMessage({ text: payload.text }))
},
)
export const editMessage = createAsyncThunk(
'chat/edit',
async (payload: { id: string; text: string }, { dispatch, getState }) => {
const state = getState() as RootState
const idx = state.chat.timeline.findIndex(
(i) => i.kind === 'message' && i.data.id === payload.id,
)
if (idx === -1) return
// Trim everything after (and including) the edited message.
dispatch(chatActions.trimTimeline(idx))
// Forward original images if present.
const orig = state.chat.timeline[idx]
const images = orig.kind === 'message' ? orig.data.images : undefined
await dispatch(sendMessage({ text: payload.text, images }))
},
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/app/store.ts` around lines 431 - 438, The editMessage thunk is
wiping the whole chat state via chatActions.clearChat() instead of trimming back
to the edited message, and it ignores the payload.id and any original images.
Update editMessage to locate the message by id, truncate the timeline only up to
that message, preserve the other chat state that should remain, and pass the
original images through to sendMessage; use the existing editMessage and
sendMessage symbols to keep the behavior aligned with the comment.

dispatch(modelActions.setProvider(d.provider))
dispatch(modelActions.setModel(d.model))
},
onModeChanged: (d) => dispatch(modelActions.setMode(d as never)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

onModeChanged passes the data object instead of the mode string — runtime bug.

d is typed { mode: string } per the WSHandlers interface, but modelActions.setMode expects an AgentMode string (confirmed by App.tsx line 59 which calls normalizeMode(h.mode)). The as never cast masks the type mismatch — at runtime the reducer receives { mode: "plan" } instead of "plan", breaking all downstream mode comparisons like state.mode === 'plan'.

Compare with onModelChanged (lines 82-85) which correctly extracts d.provider and d.model from its data object.

🐛 Proposed fix
 import { api } from '../lib/api'
+import { normalizeMode } from '../lib/types'
 import type { Goal } from 'jcode-ui-core'

@@
-    onModeChanged: (d) => dispatch(modelActions.setMode(d as never)),
+    onModeChanged: (d) => dispatch(modelActions.setMode(normalizeMode(d.mode))),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onModeChanged: (d) => dispatch(modelActions.setMode(d as never)),
import { normalizeMode } from '../lib/types'
onModeChanged: (d) => dispatch(modelActions.setMode(normalizeMode(d.mode))),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/app/wsBridge.ts` at line 86, The onModeChanged handler in
wsBridge.ts is forwarding the entire data object to modelActions.setMode, which
expects the mode string. Update the onModeChanged callback to extract the mode
field from the WSHandlers payload (matching how onModelChanged pulls
provider/model) and pass that string through instead of using the as never cast.
Remove the unsafe cast so the reducer receives an AgentMode value and downstream
mode comparisons continue to work correctly.

Comment on lines +63 to +99
<label className="mt-4 block text-xs font-medium text-[var(--color-muted-foreground)]">Provider</label>
<select
value={selected?.id ?? ''}
onChange={(e) => setSelected(providers.find((p) => p.id === e.target.value) ?? null)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Select…</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>

<label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">API key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>

{models.length > 0 && (
<>
<label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">Model</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Default</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Associate labels with form controls for screen reader accessibility.

The <label> elements are not linked to their <select>/<input> counterparts via htmlFor/id attributes. Screen reader users cannot determine which label belongs to which control. Add id attributes to the controls and htmlFor to the labels, or wrap each control inside its <label>.

♿ Proposed fix: add htmlFor/id associations
-        <label className="mt-4 block text-xs font-medium text-[var(--color-muted-foreground)]">Provider</label>
+        <label htmlFor="setup-provider" className="mt-4 block text-xs font-medium text-[var(--color-muted-foreground)]">Provider</label>
         <select
+          id="setup-provider"
           value={selected?.id ?? ''}
           onChange={(e) => setSelected(providers.find((p) => p.id === e.target.value) ?? null)}
           className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
         >
           <option value="">Select…</option>
           {providers.map((p) => (
             <option key={p.id} value={p.id}>
               {p.name}
             </option>
           ))}
         </select>

-        <label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">API key</label>
+        <label htmlFor="setup-apikey" className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">API key</label>
         <input
+          id="setup-apikey"
           type="password"
           value={apiKey}
           onChange={(e) => setApiKey(e.target.value)}
           className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
         />

         {models.length > 0 && (
           <>
-            <label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">Model</label>
+            <label htmlFor="setup-model" className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">Model</label>
             <select
+              id="setup-model"
               value={model}
               onChange={(e) => setModel(e.target.value)}
               className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<label className="mt-4 block text-xs font-medium text-[var(--color-muted-foreground)]">Provider</label>
<select
value={selected?.id ?? ''}
onChange={(e) => setSelected(providers.find((p) => p.id === e.target.value) ?? null)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Select…</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">API key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
{models.length > 0 && (
<>
<label className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">Model</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Default</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<label htmlFor="setup-provider" className="mt-4 block text-xs font-medium text-[var(--color-muted-foreground)]">Provider</label>
<select
id="setup-provider"
value={selected?.id ?? ''}
onChange={(e) => setSelected(providers.find((p) => p.id === e.target.value) ?? null)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Select…</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<label htmlFor="setup-apikey" className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">API key</label>
<input
id="setup-apikey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
{models.length > 0 && (
<>
<label htmlFor="setup-model" className="mt-3 block text-xs font-medium text-[var(--color-muted-foreground)]">Model</label>
<select
id="setup-model"
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-muted)] px-3 py-2 text-sm"
>
<option value="">Default</option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/components/SetupView.tsx` around lines 63 - 99, The form
controls in SetupView are missing accessible label associations, so link each
label to its control by adding matching id/htmlFor pairs (or wrapping the
control inside the label) for the Provider select, API key input, and Model
select. Update the relevant JSX in SetupView so the existing label/select/input
blocks use unique identifiers and remain functionally unchanged.

Comment thread web-react/src/lib/ws.ts
Comment on lines +58 to +61
/** Update the handler set (e.g. when the active task changes). */
setHandlers(handlers: WSHandlers): void {
this.handlers = handlers
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

setHandlers() doesn't take effect — handlerMap captures the old handlers object.

handlerFor() builds handlerMap lazily with const h = this.handlers, capturing the handlers reference at build time. When setHandlers() replaces this.handlers with a new object, the closures still call methods on the old object. The setHandlers comment says "Update the handler set (e.g. when the active task changes)" — so this is intended to be called at runtime, but the update is silently ignored.

Fix: null out this.handlerMap in setHandlers() to force a rebuild on the next message. (Included in the diff above.)

Also applies to: 140-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/lib/ws.ts` around lines 58 - 61, `setHandlers()` in `WS` is
replacing `this.handlers` but leaving the cached `handlerMap` intact, so
`handlerFor()` keeps invoking closures bound to the old handlers object. Update
`setHandlers()` to clear `this.handlerMap` whenever the handler set changes, so
the next call to `handlerFor()` rebuilds the map against the new `WSHandlers`
instance and the runtime update takes effect.

Comment thread web-react/src/lib/ws.ts
Comment on lines +68 to +120
connect(): void {
if (this.ws) {
this.ws.close()
this.ws = null
}
const token = getAuthToken()
this.ws = token
? new WebSocket(`${wsBase()}/api/ws`, ['jcode-auth', token])
: new WebSocket(`${wsBase()}/api/ws`)

this.ws.onopen = () => {
this.connected = true
if (this.pingTimer) clearInterval(this.pingTimer)
this.pingTimer = setInterval(() => this.send({ type: 'ping' }), 30000)
}

this.ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data)
const active = this.handlers.activeTaskId?.()
if (msg.task_id && active && msg.task_id !== active) return
const handler = this.handlerFor(msg.type)
if (handler) {
let data = msg.data
if (
msg.task_id &&
(msg.type === 'approval_request' || msg.type === 'ask_user_request') &&
data &&
typeof data === 'object'
) {
data = { ...(data as Record<string, unknown>), task_id: msg.task_id }
}
handler(data)
}
} catch {
// parse error — drop
}
}

this.ws.onerror = () => {
this.connected = false
}

this.ws.onclose = () => {
this.connected = false
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
this.ws = null
this.retryTimer = setTimeout(() => this.connect(), 3000)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

disconnect() does not prevent reconnection; connect() doesn't clear pending retry timer.

Two related bugs in the reconnection logic:

  1. disconnect() → auto-reconnect: this.ws?.close() in disconnect() triggers onclose asynchronously, which unconditionally sets this.retryTimer = setTimeout(() => this.connect(), 3000). The client reconnects 3s after "disconnecting" — defeating the method's purpose.

  2. connect() → stale retry timer: If connect() is called while a retry is pending, the old timer still fires and calls connect() again, closing the just-established connection.

Fix: add a disconnected flag and clear the retry timer at the start of connect().

🔒 Proposed fix
 export class WSClient {
   private ws: WebSocket | null = null
   private retryTimer: ReturnType<typeof setTimeout> | null = null
   private pingTimer: ReturnType<typeof setInterval> | null = null
   private connected = false
   private handlers: WSHandlers
+  private disconnected = false
 
   constructor(handlers: WSHandlers) {
     this.handlers = handlers
   }
 
   /** Update the handler set (e.g. when the active task changes). */
   setHandlers(handlers: WSHandlers): void {
     this.handlers = handlers
+    this.handlerMap = null
   }
 
   /** True when the WS is open. */
   isConnected(): boolean {
     return this.connected
   }
 
   connect(): void {
+    this.disconnected = false
+    if (this.retryTimer) {
+      clearTimeout(this.retryTimer)
+      this.retryTimer = null
+    }
     if (this.ws) {
       this.ws.close()
       this.ws = null
     }
     const token = getAuthToken()
     this.ws = token
       ? new WebSocket(`${wsBase()}/api/ws`, ['jcode-auth', token])
       : new WebSocket(`${wsBase()}/api/ws`)
 
     this.ws.onopen = () => {
       this.connected = true
       if (this.pingTimer) clearInterval(this.pingTimer)
       this.pingTimer = setInterval(() => this.send({ type: 'ping' }), 30000)
     }
 
     this.ws.onmessage = (event) => {
       try {
         const msg: WSMessage = JSON.parse(event.data)
         const active = this.handlers.activeTaskId?.()
         if (msg.task_id && active && msg.task_id !== active) return
         const handler = this.handlerFor(msg.type)
         if (handler) {
           let data = msg.data
           if (
             msg.task_id &&
             (msg.type === 'approval_request' || msg.type === 'ask_user_request') &&
             data &&
             typeof data === 'object'
           ) {
             data = { ...(data as Record<string, unknown>), task_id: msg.task_id }
           }
           handler(data)
         }
       } catch {
         // parse error — drop
       }
     }
 
     this.ws.onerror = () => {
       this.connected = false
     }
 
     this.ws.onclose = () => {
       this.connected = false
       if (this.pingTimer) {
         clearInterval(this.pingTimer)
         this.pingTimer = null
       }
       this.ws = null
-      this.retryTimer = setTimeout(() => this.connect(), 3000)
+      if (!this.disconnected) {
+        this.retryTimer = setTimeout(() => this.connect(), 3000)
+      }
     }
   }
 
   send(msg: WSMessage): void {
     if (this.ws && this.ws.readyState === WebSocket.OPEN) {
       this.ws.send(JSON.stringify(msg))
     }
   }
 
   sendApproval(id: string, approved: boolean, approveAll = false, taskId?: string): void {
     this.send({ type: 'approval', data: { id, approved, approve_all: approveAll, task_id: taskId } })
   }
 
   disconnect(): void {
+    this.disconnected = true
     if (this.retryTimer) clearTimeout(this.retryTimer)
     if (this.pingTimer) clearInterval(this.pingTimer)
     this.ws?.close()
     this.ws = null
     this.connected = false
   }

Also applies to: 132-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-react/src/lib/ws.ts` around lines 68 - 120, The WebSocket reconnection
flow in connect() and disconnect() allows unintended reconnects and stale retry
timers. Add a disconnected state check so ws.onclose only schedules retryTimer
when the client is not intentionally disconnected, and clear any pending
retryTimer at the start of connect() before opening a new WebSocket. Update
disconnect() to set the flag before closing the socket, and ensure connect()
resets it when a real connection attempt begins.

cnjack added 3 commits July 9, 2026 02:09
…replay + separated chat-ui docs

Runtime fixes (found via headless-browser testing against the live Go backend):
- externalStore.ts: cache the normalized RuntimeState keyed on the host state
  reference so getState() returns a stable identity between dispatches. Without
  this, useSyncExternalStore infinite-looped (Maximum update depth) and crashed
  every page that rendered <Thread>. This was the root cause of the blank app.
- context.tsx: trust the runtime's snapshot stability (remove the redundant and
  buggy double-cache); useRuntimeState/useRuntimeSelector now pass
  runtime.getState directly.
- Thread.tsx VirtualizedThread: the scroll container resolved to height 0 inside
  flex parents, so the virtualizer rendered 0 rows. Restructured to flex:1 +
  min-height:0 so the height resolves through the chain. Verified the demo now
  streams its scripted conversation.

web-react (product app):
- loadSession thunk: replay a session's JSONL history into the timeline (was a
  TODO — the app booted to an empty chat). Walks entries, rebuilds messages +
  tool calls, matches tool_call_id, falls back to most-recent session on 404.
- App.tsx boot: load current session, fall back to most-recent if empty.
- toolInfo.ts: ported extractToolDisplayInfo (mirrors backend, for replay).
- Sidebar openSession now loads the session via the thunk.
- vite.config.ts: pin port 5173 (matches Tauri devUrl).

site (component library docs + showcase):
- Separated chat-ui docs from jcode product docs. New /chat-ui/docs/* route
  with its own pipeline (chatUiDocs.ts), nav tree, ChatUiDocsLayout, index, and
  DocPage. The sidebar now lists ONLY jcode-ui docs — no mixing with the jcode
  product docs (Agent/Plan Mode/Browser). Back-link to the chat-ui landing.
- components.md: new component reference page with an assistant-ui→jcode-ui
  mapping table + props tables for every component (closes the assistant-ui
  feature-parity gap).
- ChatDemo: rewrote the layout to flex/flex-col + min-h-0 so the virtualized
  Thread gets a concrete height (was rendering empty).

Verified end-to-end with headless Chrome:
- web-react: boots, loads session timeline (real history), streams a live
  prompt (model replied), tool cards render (✓ shell/read/edit +N/-M), Stop
  button swaps correctly, theme tokens applied, no JS errors.
- site /chat-ui: ChatDemo streams the full scripted conversation (message→tool
  →approval) with virtualization; docs index + sub-pages render standalone.

Build artifacts (jcode-new binary, *.tsbuildinfo) gitignored.
…ui parity); wire ⌘K

Closes the assistant-ui component-catalog gaps found in the parity audit. Every
assistant-ui component now has a jcode-ui equivalent, documented in components.md
with a full mapping table (✅ library / 🟡 product-level / field-driven).

New components (jcode-ui):
- Reasoning: collapsible model thinking block ('Thought for Ns'), markdown,
  driven by message.reasoning. Mirrors assistant-ui Reasoning.
- Sources: citation chip list with snippet popovers, driven by
  message.sources (MessageSource[]). Mirrors assistant-ui Sources.
- Attachment + AttachmentList: standalone image-attachment thumbnails (also
  embedded in ChatInput). Mirrors assistant-ui Attachment.

Message now renders Reasoning (before body) + Sources (after body) automatically
when those fields are present. Added reasoning/sources/MessageSource to the core
Message type.

components.md: rewrote the assistant-ui→jcode-ui mapping to cover ALL 16 catalog
entries (Thread, ThreadList, Composer, Attachment, Markdown, DiffViewer, Image,
Context Display, Message Timing, Reasoning, Sources, Tool Fallback, Tool Group,
Assistant Modal/Sidebar, Model Selector, makeAssistantToolUI) with status +
notes. Added reference sections for Reasoning/Sources/Attachment with props.

web-react:
- App.tsx: wired global keyboard shortcuts (⌘K command palette, ⌘N new chat,
  Esc closes overlays) — was missing entirely.
- main.tsx: dev-only window.__jcodeStore exposure for testing (stripped in prod).
- vite-env.d.ts: added (vite/client types for import.meta.env).

Demo (site/playground): the scripted conversation now ends with a message that
has reasoning + sources, so the ChatUIPage live demo showcases those components.

Verified end-to-end (headless Chrome):
- web-react: all 6 views render (ChatView, Automations w/ real data, Channels,
  SetupView, AuthGate, CommandPalette via ⌘K + store toggle). No JS errors.
- site: all 8 pages render without regression (Home/Desktop/CLI/Showcase/Docs/
  Chat-UI/Chat-UI Docs/Privacy).
- Demo single-play: Reasoning ('Thought process') + Sources (2 chips) render on
  the final assistant message; production build bundles them.
…cts it)

Tauri 2 strictly validates the config schema and rejects unknown fields. The
override config had a $comment field for documentation, which crashed
desktop-react-dev on startup:
  Error: Additional properties are not allowed (`$comment` was unexpected)

Removed the $comment (and the redundant $schema). The build block alone is the
only override needed; everything else is inherited from tauri.conf.json via deep
merge. Verified: make desktop-react-dev now boots the Tauri window + Go sidecar
+ Vite dev server end-to-end.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/jcode-ui/src/components/Reasoning.tsx`:
- Around line 27-34: The toggle button in Reasoning should expose its
open/closed state to assistive tech by adding the missing aria-expanded
attribute. Update the button in the Reasoning component so the disclosure
control reflects the expanded state using the existing expanded value, keeping
the rest of the onClick and ChevronDownIcon behavior unchanged.

In `@packages/jcode-ui/src/components/Sources.tsx`:
- Around line 24-31: The source toggle button in Sources.tsx is missing the
expanded-state accessibility attribute. Update the button in the Sources
component so it exposes aria-expanded based on whether openId matches s.id,
alongside the existing onClick toggle logic, to let screen readers announce the
open/closed state of each source snippet.
- Around line 34-38: The link in Sources.tsx renders s.url directly in the
anchor href, which can allow javascript: URLs to execute; update the Sources
component to validate or sanitize the URL before rendering. In the Sources
render block around the s.url check, use a safe URL helper or protocol whitelist
so only http/https (or other approved schemes) are allowed, and fall back to not
rendering the link when the value is unsafe. Keep the existing anchor styling
and behavior for valid URLs, but make the href assignment depend on the
sanitized result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9aa64423-5322-480f-8aae-bdae5d74a839

📥 Commits

Reviewing files that changed from the base of the PR and between edafe33 and ad6daf2.

📒 Files selected for processing (13)
  • desktop/src-tauri/tauri.react.conf.json
  • packages/jcode-ui-core/src/types/index.ts
  • packages/jcode-ui/src/components/Attachment.tsx
  • packages/jcode-ui/src/components/Message.tsx
  • packages/jcode-ui/src/components/Reasoning.tsx
  • packages/jcode-ui/src/components/Sources.tsx
  • packages/jcode-ui/src/index.ts
  • site/docs/chat-ui/components.md
  • site/src/playground/ChatDemo.tsx
  • site/src/playground/mockScript.ts
  • web-react/src/App.tsx
  • web-react/src/main.tsx
  • web-react/src/vite-env.d.ts
💤 Files with no reviewable changes (1)
  • desktop/src-tauri/tauri.react.conf.json
✅ Files skipped from review due to trivial changes (2)
  • web-react/src/vite-env.d.ts
  • site/docs/chat-ui/components.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • web-react/src/main.tsx
  • packages/jcode-ui/src/index.ts
  • site/src/playground/ChatDemo.tsx
  • packages/jcode-ui/src/components/Message.tsx
  • packages/jcode-ui-core/src/types/index.ts
  • site/src/playground/mockScript.ts
  • web-react/src/App.tsx

Comment on lines +27 to +34
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1.5 rounded-[var(--radius-md)] px-2 py-1 text-[0.75rem] italic text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
<ChevronDownIcon className={`h-3 w-3 transition-transform ${expanded ? 'rotate-180' : ''}`} />
{label}
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add aria-expanded to the toggle button.

The disclosure button lacks aria-expanded, so screen reader users can't determine whether the reasoning content is open or collapsed. Adding aria-expanded={expanded} is a one-attribute fix.

♿ Proposed fix
       <button
         type="button"
+        aria-expanded={expanded}
         onClick={() => setExpanded((e) => !e)}
         className="flex items-center gap-1.5 rounded-[var(--radius-md)] px-2 py-1 text-[0.75rem] italic text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1.5 rounded-[var(--radius-md)] px-2 py-1 text-[0.75rem] italic text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
<ChevronDownIcon className={`h-3 w-3 transition-transform ${expanded ? 'rotate-180' : ''}`} />
{label}
</button>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1.5 rounded-[var(--radius-md)] px-2 py-1 text-[0.75rem] italic text-[var(--color-muted-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
<ChevronDownIcon className={`h-3 w-3 transition-transform ${expanded ? 'rotate-180' : ''}`} />
{label}
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/components/Reasoning.tsx` around lines 27 - 34, The
toggle button in Reasoning should expose its open/closed state to assistive tech
by adding the missing aria-expanded attribute. Update the button in the
Reasoning component so the disclosure control reflects the expanded state using
the existing expanded value, keeping the rest of the onClick and ChevronDownIcon
behavior unchanged.

Comment on lines +24 to +31
<button
type="button"
onClick={() => setOpenId((id) => (id === s.id ? null : s.id))}
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-muted)] px-2 py-0.5 text-[0.7rem] text-[var(--color-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
{s.url && <LinkIcon className="h-2.5 w-2.5" />}
<span className="max-w-[180px] truncate">{i + 1}. {s.title}</span>
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add aria-expanded to the source toggle button.

The source chip button lacks aria-expanded, so screen reader users can't determine whether the snippet dropdown is open. Adding aria-expanded={openId === s.id} is a one-attribute fix.

♿ Proposed fix
           <button
             type="button"
+            aria-expanded={openId === s.id}
             onClick={() => setOpenId((id) => (id === s.id ? null : s.id))}
             className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-muted)] px-2 py-0.5 text-[0.7rem] text-[var(--color-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
           >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => setOpenId((id) => (id === s.id ? null : s.id))}
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-muted)] px-2 py-0.5 text-[0.7rem] text-[var(--color-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
{s.url && <LinkIcon className="h-2.5 w-2.5" />}
<span className="max-w-[180px] truncate">{i + 1}. {s.title}</span>
</button>
<button
type="button"
aria-expanded={openId === s.id}
onClick={() => setOpenId((id) => (id === s.id ? null : s.id))}
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-[var(--color-border)] bg-[var(--color-muted)] px-2 py-0.5 text-[0.7rem] text-[var(--color-foreground)] transition-colors hover:bg-[var(--neutral-wash-soft)]"
>
{s.url && <LinkIcon className="h-2.5 w-2.5" />}
<span className="max-w-[180px] truncate">{i + 1}. {s.title}</span>
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/components/Sources.tsx` around lines 24 - 31, The
source toggle button in Sources.tsx is missing the expanded-state accessibility
attribute. Update the button in the Sources component so it exposes
aria-expanded based on whether openId matches s.id, alongside the existing
onClick toggle logic, to let screen readers announce the open/closed state of
each source snippet.

Comment on lines +34 to +38
{s.url && (
<a href={s.url} target="_blank" rel="noreferrer" className="mb-1 block truncate font-medium text-[var(--color-primary)]">
{s.title}
</a>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate s.url to prevent javascript: protocol XSS.

href={s.url} renders the URL without protocol validation. If MessageSource.url contains a javascript: URI (e.g., via manipulated model/backend output), clicking the link executes arbitrary JavaScript. React 18 does not block javascript: URLs in href attributes.

🔒️ Proposed fix
               {s.url && (
-                <a href={s.url} target="_blank" rel="noreferrer" className="mb-1 block truncate font-medium text-[var(--color-primary)]">
+                <a href={s.url && /^https?:\/\//i.test(s.url) ? s.url : undefined} target="_blank" rel="noreferrer" className="mb-1 block truncate font-medium text-[var(--color-primary)]">
                   {s.title}
                 </a>
               )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{s.url && (
<a href={s.url} target="_blank" rel="noreferrer" className="mb-1 block truncate font-medium text-[var(--color-primary)]">
{s.title}
</a>
)}
{s.url && (
<a href={s.url && /^https?:\/\//i.test(s.url) ? s.url : undefined} target="_blank" rel="noreferrer" className="mb-1 block truncate font-medium text-[var(--color-primary)]">
{s.title}
</a>
)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/jcode-ui/src/components/Sources.tsx` around lines 34 - 38, The link
in Sources.tsx renders s.url directly in the anchor href, which can allow
javascript: URLs to execute; update the Sources component to validate or
sanitize the URL before rendering. In the Sources render block around the s.url
check, use a safe URL helper or protocol whitelist so only http/https (or other
approved schemes) are allowed, and fall back to not rendering the link when the
value is unsafe. Keep the existing anchor styling and behavior for valid URLs,
but make the href assignment depend on the sanitized result.

…le + Automations/Channels parity

Closes the major feature gaps vs the Vue app. Each ported file was read in full
from the Vue source and typechecks individually.

ChatInput.tsx (product composer, ~1.2k lines):
- Full port of the Vue 2.2k-line composer: autosizing textarea, send/queue/stop
  (IME-safe), slash-command menu, MODE picker (approval/plan/full_access), MODEL
  picker (current/favorites/recent/all-providers with capability dots + context
  limit + manage-models dialog), EFFORT picker, '+' menu (attach images, slash
  insert, Goal arming), image attachments (paste + file picker + thumbnails),
  type-ahead queue chips, ⌘L focus, click-outside. ChatView now uses this
  product ChatInput instead of the library's minimal one.

SettingsDialog.tsx (~1.5k lines):
- Full port of the Vue 2.7k-line settings: 8 tabs (Providers/Models/MCP/Skills/
  Appearance/Browser/Remote/Usage). Providers tab fully ported (CRUD + catalog
  + advanced config + custom models). MCP has OAuth-login polling. Browser has
  site-permissions editor. Usage has totals + trend chart. Opened via ⌘, and
  the header gear button.

useTheme.ts + ThemeToggle.tsx:
- Theme system ported (system/light/dark + 7 named themes, useSyncExternalStore,
  localStorage persistence, applies data-theme + .dark class). Toggle button in
  the header (sun/moon flip + swatch dropdown).

AutomationsView.tsx: full CRUD (create/edit form with schedule/mode/project,
run history with filter, templates picker, enable/disable, run-now, delete).
ChannelsView.tsx: WeChat QR-login flow (login → QR → 2s poll → online → logout)
+ enable/disable + BLE card.

ProjectHeader: added settings gear + ThemeToggle.
App.tsx: renders SettingsDialog, ⌘, shortcut.

Verified via headless Chrome: model+mode pickers render, Settings opens with all
8 tabs + real provider data, theme toggle flips .dark class, Automations shows
real automation + templates + create, Channels shows WeChat card. No JS errors.
typecheck + production build pass.

Also: untracked the accidentally-committed internal/web/dist-react build output
and gitignored it.
@cnjack
cnjack merged commit cfddd78 into main Jul 9, 2026
3 checks passed
@cnjack
cnjack deleted the feat/react-migration-jcode-ui branch July 9, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant