Skip to content

feat(web): Redesign frontend with Pencil-based design system#61

Merged
cnjack merged 14 commits into
mainfrom
feat/web-redesign-pencil
May 21, 2026
Merged

feat(web): Redesign frontend with Pencil-based design system#61
cnjack merged 14 commits into
mainfrom
feat/web-redesign-pencil

Conversation

@cnjack
Copy link
Copy Markdown
Owner

@cnjack cnjack commented May 21, 2026

Summary

Complete web frontend redesign based on the Pencil design specification.

Changes

Design System

  • New CSS custom property-based token system (replaces hardcoded Tailwind dark:/light classes)
  • Primary color: #FF8400 (orange), replacing emerald green
  • Font stack: Geist (UI) + JetBrains Mono (code) via Google Fonts
  • Consistent border radius, shadow, and spacing tokens

Components

  • New TopBar.vue with lucide-vue-next icons (menu, terminal, diff, files, panel toggles)
  • Refactored App.vue shell layout with proper sidebar/main/panel structure
  • Restyled all core components: ChatMessage, ChatInput, Sidebar, ToolCallCard
  • Restyled auxiliary components: ApprovalBanner, TodoPanel, SettingsDialog, TerminalPanel

Theme

  • Dark and light modes both work via CSS variable switching (no more dark: Tailwind variants for colors)
  • Verified in browser — both modes render correctly

Design Assets

  • Added DESIGN.md specification document
  • Added Pencil design source files (design/jcode-web.pen, design/landing-page.pen)

Testing

  • npx vite build passes (no errors)
  • vue-tsc --noEmit passes (no type errors)
  • Browser visual testing verified (both dark and light modes)

Screenshots

Dark mode and light mode both verified working with new design system.

Summary by CodeRabbit

  • New Features

    • Top header with panel toggles, new keyboard shortcuts, a resizable right panel (Files/Changes) with file browser and in-panel previews; terminal now supports tabs and multiple terminal instances.
  • Documentation

    • Added a comprehensive design system covering theme, tokens, components, layout, and prompt templates.
  • Style

    • Sitewide shift to CSS-variable theming, refreshed typography, updated animations, and consistent component visuals (chat, banners, settings, messages, diff/tool views).
  • Chores

    • Added an icon library dependency; build cache added to .gitignore.

Review Change Stack

- Rebuild design tokens (CSS custom properties) with new color palette (#FF8400 primary)
- Add Geist + JetBrains Mono font loading from Google Fonts
- Create TopBar component with lucide-vue-next icons
- Refactor App.vue shell layout with sidebar + top bar + panel toggles
- Restyle all core components (ChatMessage, ChatInput, Sidebar, ToolCallCard)
- Restyle auxiliary components (ApprovalBanner, TodoPanel, SettingsDialog, TerminalPanel)
- Update animations to use new primary color
- Add DESIGN.md specification and Pencil design source files
- Both dark and light themes verified working via CSS variable system
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Warning

Rate limit exceeded

@cnjack has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 54 minutes and 15 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 18e406ab-1192-47dc-b08b-b00f1c7c6d88

📥 Commits

Reviewing files that changed from the base of the PR and between 36c54b4 and 4c13a2f.

⛔ Files ignored due to path filters (1)
  • web/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • web/package.json
📝 Walkthrough

Walkthrough

Adds a formal design system and Penpot visuals, replaces Tailwind utility theming with a CSS-variable token system, introduces RightPanel/FileTree, TerminalInstance and tabbed TerminalPanel, and restyles many UI components to use token-driven inline styles.

Changes

Design System & Theme Implementation

Layer / File(s) Summary
Design System Documentation & Visual Design
DESIGN.md, design/landing-page.pen
DESIGN.md defines the complete JCODE design system: visual theme, token palette with semantic roles, typography scale, component specs, layout rules, and Agent Prompt templates. design/landing-page.pen provides Penpot visual designs for landing/docs and theme variables.
CSS Token System & Global Theming
web/src/styles/tokens.css, web/src/style.css, web/src/styles/animations.css
Introduces light/dark token palette, font/radius/shadow tokens, hljs syntax tokens, and migrates global prose, tool, diff, and animation colors from hardcoded values to CSS variables.
App Shell & Top Navigation
web/src/components/TopBar.vue, web/src/App.vue, web/index.html, web/package.json
Adds TopBar.vue, refactors App.vue to use RightPanel for files/changes, updates root styling to tokens, switches primary font to Geist in index.html, and adds lucide-vue-next to dependencies.
Chat Interface Theme Migration
web/src/components/ChatInput.vue, web/src/components/ChatMessage.vue, web/src/components/ApprovalBanner.vue
Restyles ChatInput (slash-menu, model picker, manage models dialog, toolbar, image preview, send/stop UI), ChatMessage (badges, actions, edit UI), and ApprovalBanner to use token-driven inline styles and component classnames.
Sidebar, File/Right Panel & DiffViewer
web/src/components/Sidebar.vue, web/src/components/RightPanel.vue, web/src/components/FileTreePanel.vue, web/src/components/DiffViewer.vue
Sidebar simplified and restyled to token variables; adds RightPanel (resizable, tabs for Files/Changes) and FileTreePanel (breadcrumb, preview); DiffViewer adjusts file-list layout to bottom split and right-aligns additions/deletions badges.
Terminal Instances & Panel Tabs
web/src/components/TerminalInstance.vue, web/src/components/TerminalPanel.vue
Adds TerminalInstance to manage xterm + PTY WebSocket sessions with lifecycle and theme observers; TerminalPanel refactored to support multiple terminal tabs and per-tab close behavior, emitting close when last tab removed.
Tool Cards & Remaining Components
web/src/components/ToolCallCard.vue, web/src/components/TodoPanel.vue, web/src/composables/toolInfo.ts
ToolCallCard adds a grep/search expanded viewer and parsing helpers; TodoPanel status-to-UI strike-through styling migrated; TOOL_ICONS['terminal'] changed from 💻 to .
Settings Dialog Theming
web/src/components/SettingsDialog.vue
Migrates SettingsDialog modal and all tabs (General, Providers, MCP Servers, SSH, Channels, Shortcuts) to token-based inline styles and conditional color logic.
Build & Configuration
.gitignore
Added .vite/ to ignore Vite build/cache output.

Sequence Diagram(s):

sequenceDiagram
  participant Browser as Browser UI
  participant TerminalInstance as TerminalInstance.vue
  participant API as api.ptyCreate / api.ptyKill
  participant PTYws as PTY WebSocket
  Browser->>TerminalInstance: mount & request create PTY
  TerminalInstance->>API: api.ptyCreate()
  API-->>TerminalInstance: sessionId
  TerminalInstance->>PTYws: open WebSocket -> WS URL(sessionId)
  PTYws-->>TerminalInstance: binary/text output
  TerminalInstance->>PTYws: user input (term.onData)
  TerminalInstance->>API: api.ptyKill(sessionId) on cleanup
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • cnjack/jcode#38: Direct overlap on TodoPanel.vue status-to-UI rendering changes.
  • cnjack/jcode#29: Related to tool-call display wiring and display_info/display_output metadata used by tool cards.
  • cnjack/jcode#11: Related to tool-call lifecycle / toolCallID tracking that ties into tool UI plumbing.

Poem

🐰 I hop through tokens, colors in my paws,

Swapping classes for variables without a pause.
Panels stretch and terminals hum bright,
Design docs whisper day and night.
A rabbit celebrates the UI's new light.

🚥 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 directly and clearly describes the main change: a frontend redesign implementing a design system, which aligns with the substantial component refactoring, new design tokens, and visual system updates throughout the changeset.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-redesign-pencil

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 and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7b1e9affc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/src/App.vue Outdated
Comment on lines +162 to +164
if (panel === 'right-panel') {
rightPanelOpen.value = !rightPanelOpen.value
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Implement right-panel toggle behavior

Clicking the new PanelRight button emits toggle-panel('right-panel'), but this branch only flips rightPanelOpen and that state is never consumed by the template (and active-panel never maps to 'right-panel'). In practice the button is inert: it opens no UI and never becomes active, which leaves a non-functional control in the top bar. Either render a panel from rightPanelOpen or remove the button until the panel exists.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/components/TerminalPanel.vue (1)

101-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Disconnect the theme MutationObserver during teardown to prevent observer accumulation on reconnect/unmount.

web/src/components/TerminalPanel.vue’s initTerminal() creates a local const observer = new MutationObserver(...) and starts observing document.documentElement, but cleanup() only disconnects resizeObserver—so reconnect() (cleanup + initTerminal) creates additional theme observers.

Suggested fix
 let term: Terminal | null = null
 let fitAddon: FitAddon | null = null
 let ws: WebSocket | null = null
 let resizeObserver: ResizeObserver | null = null
+let themeObserver: MutationObserver | null = null

@@
-  const observer = new MutationObserver(() => {
+  themeObserver = new MutationObserver(() => {
     if (term) {
       term.options.theme = isDarkMode() ? darkTheme : lightTheme
     }
   })
-  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
+  themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })

@@
 function cleanup() {
+  themeObserver?.disconnect()
+  themeObserver = null
   resizeObserver?.disconnect()
   resizeObserver = null
   if (ws) {
🤖 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/src/components/TerminalPanel.vue` around lines 101 - 107, The
MutationObserver created in initTerminal() (const observer = new
MutationObserver(...)) is never disconnected in cleanup(), causing duplicate
observers after reconnect(); move the observer variable to component scope
(e.g., a let observer variable alongside resizeObserver), assign it in
initTerminal(), and call observer.disconnect() inside cleanup() (in addition to
disconnecting resizeObserver) so the theme observer is properly torn down on
unmount/reconnect.
web/src/components/SettingsDialog.vue (1)

148-167: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop channel polling when the dialog closes (and avoid overlapping polls).

pollChannelState() creates interval/setTimeout as local variables and the watch(() => props.open, ...) close (else) path only clears channelQRContent (and a few other fields) — it never clears the polling timers. As a result, polling can keep running after the dialog is closed and continue updating channelState, channelQRContent, and store.channelEnabled until it reaches an enabled/disabled state or the 180s timeout.

Suggested fix
 const channelLoginReminder = ref(false)
+let channelPollInterval: ReturnType<typeof setInterval> | null = null
+let channelPollTimeout: ReturnType<typeof setTimeout> | null = null

+function stopChannelPolling() {
+  if (channelPollInterval) clearInterval(channelPollInterval)
+  if (channelPollTimeout) clearTimeout(channelPollTimeout)
+  channelPollInterval = null
+  channelPollTimeout = null
+}

 watch(() => props.open, async (isOpen) => {
   if (isOpen) {
@@
   } else {
+    stopChannelPolling()
     channelQRContent.value = ''
@@
 function pollChannelState() {
+  stopChannelPolling()
   const previousState = channelState.value
-  const interval = setInterval(async () => {
+  channelPollInterval = setInterval(async () => {
@@
-        clearInterval(interval)
+        stopChannelPolling()
       }
     } catch { /* ignore */ }
   }, 2000)
-  setTimeout(() => clearInterval(interval), 180000)
+  channelPollTimeout = setTimeout(() => stopChannelPolling(), 180000)
 }
🤖 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/src/components/SettingsDialog.vue` around lines 148 - 167,
pollChannelState creates local interval/timeout timers that never get cleared
when the dialog closes, causing polls to continue and overlap; refactor by
moving the interval and timeout IDs to outer-scope refs/variables (e.g.,
pollIntervalId and pollTimeoutId), update pollChannelState to first clear any
existing pollIntervalId/pollTimeoutId before starting a new
setInterval/setTimeout, and in the watch(() => props.open, ...) else/close
branch clear and null out those stored timers and reset any state
(channelQRContent/channelState/channelLoginReminder) so polling stops
immediately when dialog closes and cannot overlap with a new poll.
🧹 Nitpick comments (1)
web/src/components/TopBar.vue (1)

20-24: ⚡ Quick win

Use semantic CSS tokens instead of hardcoded status hex values.

Lines 21–23 hardcode colors while the rest of the component is token-driven. Switching these to semantic vars (e.g. warning/success/muted) will keep dark/light theming consistent.

🤖 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/src/components/TopBar.vue` around lines 20 - 24, The computed status
function currently returns hardcoded hex colors; update the status computed
(function name: status) to return semantic CSS token values instead of hex
codes—use the component/theme variables for warning, success and muted (e.g.
var(--color-warning), var(--color-success), var(--color-muted) or your project's
token names) when props.isRunning, props.wsConnected and the fallback offline
branch are chosen so theming (dark/light) follows the rest of the component.
🤖 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 `@DESIGN.md`:
- Around line 53-55: Update the font-role mappings to remove the inconsistency
by choosing a single font for display/headings and making the type scale match
it: revise the bullets "Primary / Display: JetBrains Mono", "Secondary / UI:
Geist", and "Fallback: Inter" so the "Primary / Display" entry matches the type
scale’s declared display/headings font (or update the type scale to use
JetBrains Mono), and also apply the same correction where the type scale is
defined (the section that lists display/headings and body/heading fonts) so the
spec consistently references the same font for display/headings and the same
fonts for UI/body.
- Around line 338-349: The fenced ASCII-layout block in DESIGN.md is missing a
language identifier which triggers markdown lint warnings; update the fenced
code block around the ASCII layout (the block showing the Sidebar/Topbar/Right
Panel diagram) to include a language hint (e.g., "text") after the opening
triple backticks so the fence reads as a language-labelled code block and
resolves the linting/rendering issue.

In `@design/landing-page.pen`:
- Line 2180: The JSON string assigned to "content" contains a typo in the curl
URL hostname (ra1w.githubusercontent.com); update that string so the hostname is
raw.githubusercontent.com (i.e., fix the install snippet in the "content" value
to use raw.githubusercontent.com) to restore a valid install command.

In `@web/src/App.vue`:
- Around line 161-169: The right-panel toggle updates rightPanelOpen in
togglePanel but that state is never bound to the UI; update the component so
rightPanelOpen controls the visible/active UI: either set activePanel when
toggling the 'right-panel' branch (e.g., togglePanel should set activePanel =
rightPanelOpen ? null : 'right-panel') or add a computed property that maps
rightPanelOpen to the active-panel prop used by the TopBar and the template;
ensure the template has a branch/slot that consumes activePanel (or
rightPanelOpen) to render the right panel content and that the TopBar
right-panel button emits/reads the same state so the button visibly opens/closes
the panel (also mirror the same pattern used for fileViewerOpen/file viewer).

---

Outside diff comments:
In `@web/src/components/SettingsDialog.vue`:
- Around line 148-167: pollChannelState creates local interval/timeout timers
that never get cleared when the dialog closes, causing polls to continue and
overlap; refactor by moving the interval and timeout IDs to outer-scope
refs/variables (e.g., pollIntervalId and pollTimeoutId), update pollChannelState
to first clear any existing pollIntervalId/pollTimeoutId before starting a new
setInterval/setTimeout, and in the watch(() => props.open, ...) else/close
branch clear and null out those stored timers and reset any state
(channelQRContent/channelState/channelLoginReminder) so polling stops
immediately when dialog closes and cannot overlap with a new poll.

In `@web/src/components/TerminalPanel.vue`:
- Around line 101-107: The MutationObserver created in initTerminal() (const
observer = new MutationObserver(...)) is never disconnected in cleanup(),
causing duplicate observers after reconnect(); move the observer variable to
component scope (e.g., a let observer variable alongside resizeObserver), assign
it in initTerminal(), and call observer.disconnect() inside cleanup() (in
addition to disconnecting resizeObserver) so the theme observer is properly torn
down on unmount/reconnect.

---

Nitpick comments:
In `@web/src/components/TopBar.vue`:
- Around line 20-24: The computed status function currently returns hardcoded
hex colors; update the status computed (function name: status) to return
semantic CSS token values instead of hex codes—use the component/theme variables
for warning, success and muted (e.g. var(--color-warning), var(--color-success),
var(--color-muted) or your project's token names) when props.isRunning,
props.wsConnected and the fallback offline branch are chosen so theming
(dark/light) follows the rest of the component.
🪄 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: 78865c3a-7be2-485b-b9d0-1efd70c70e50

📥 Commits

Reviewing files that changed from the base of the PR and between 7bfb7aa and b7b1e9a.

⛔ Files ignored due to path filters (1)
  • web/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (19)
  • .gitignore
  • DESIGN.md
  • design/jcode-web.pen
  • design/landing-page.pen
  • web/index.html
  • web/package.json
  • web/src/App.vue
  • web/src/components/ApprovalBanner.vue
  • web/src/components/ChatInput.vue
  • web/src/components/ChatMessage.vue
  • web/src/components/SettingsDialog.vue
  • web/src/components/Sidebar.vue
  • web/src/components/TerminalPanel.vue
  • web/src/components/TodoPanel.vue
  • web/src/components/ToolCallCard.vue
  • web/src/components/TopBar.vue
  • web/src/style.css
  • web/src/styles/animations.css
  • web/src/styles/tokens.css

Comment thread DESIGN.md
Comment on lines +53 to +55
- **Primary / Display:** JetBrains Mono — used for the logo, code blocks, terminal previews, and any developer-facing content
- **Secondary / UI:** Geist — used for headings, body text, navigation, and all interface elements
- **Fallback:** Inter — used in documentation page content
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Resolve font-role inconsistency in the spec.

The document says “Primary / Display: JetBrains Mono” but the type scale defines display/headings as Geist/Inter. This creates ambiguity for implementation.

Suggested wording adjustment
-- **Primary / Display:** JetBrains Mono — used for the logo, code blocks, terminal previews, and any developer-facing content
-- **Secondary / UI:** Geist — used for headings, body text, navigation, and all interface elements
+- **Primary / UI Display:** Geist — used for headings, body text, navigation, and interface elements
+- **Code / Monospace:** JetBrains Mono — used for logo accents, code blocks, terminal previews, and code-facing content

Also applies to: 61-68

🤖 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 `@DESIGN.md` around lines 53 - 55, Update the font-role mappings to remove the
inconsistency by choosing a single font for display/headings and making the type
scale match it: revise the bullets "Primary / Display: JetBrains Mono",
"Secondary / UI: Geist", and "Fallback: Inter" so the "Primary / Display" entry
matches the type scale’s declared display/headings font (or update the type
scale to use JetBrains Mono), and also apply the same correction where the type
scale is defined (the section that lists display/headings and body/heading
fonts) so the spec consistently references the same font for display/headings
and the same fonts for UI/body.

Comment thread DESIGN.md
Comment on lines +338 to +349
```
+------------------+-------------------------------+------------------+
| Sidebar | Topbar (56px) | Right Panel |
| (260px) +-------------------------------+ (optional) |
| | | (320-400px) |
| - User row | Main Content | |
| - New conv btn | | |
| - Tabs | - Chat / Terminal / | - Agents |
| - Session list | Changes / File Editor | - Changes |
| - Status row | | |
+------------------+-------------------------------+------------------+
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language hint to the fenced layout block.

The code fence is missing a language identifier, which triggers markdown linting and reduces readability in renderers.

Suggested fix
-```
+```text
 +------------------+-------------------------------+------------------+
 |   Sidebar        |        Topbar (56px)          |   Right Panel    |
 ...
-```
+```
📝 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
```
+------------------+-------------------------------+------------------+
| Sidebar | Topbar (56px) | Right Panel |
| (260px) +-------------------------------+ (optional) |
| | | (320-400px) |
| - User row | Main Content | |
| - New conv btn | | |
| - Tabs | - Chat / Terminal / | - Agents |
| - Session list | Changes / File Editor | - Changes |
| - Status row | | |
+------------------+-------------------------------+------------------+
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 338-338: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@DESIGN.md` around lines 338 - 349, The fenced ASCII-layout block in DESIGN.md
is missing a language identifier which triggers markdown lint warnings; update
the fenced code block around the ASCII layout (the block showing the
Sidebar/Topbar/Right Panel diagram) to include a language hint (e.g., "text")
after the opening triple backticks so the fence reads as a language-labelled
code block and resolves the linting/rendering issue.

Comment thread design/landing-page.pen
"type": "text",
"id": "M7Yq4",
"fill": "#ffffff",
"content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the install URL hostname typo.

The command uses ra1w.githubusercontent.com instead of raw.githubusercontent.com, so the install snippet is invalid.

Suggested fix
-"content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
+"content": "curl -fsSL https://raw.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
📝 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
"content": "curl -fsSL https://ra1w.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
"content": "curl -fsSL https://raw.githubusercontent.com/cnjack/jcode/main/script/install.sh | sh",
🤖 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 `@design/landing-page.pen` at line 2180, The JSON string assigned to "content"
contains a typo in the curl URL hostname (ra1w.githubusercontent.com); update
that string so the hostname is raw.githubusercontent.com (i.e., fix the install
snippet in the "content" value to use raw.githubusercontent.com) to restore a
valid install command.

Comment thread web/src/App.vue Outdated
cnjack added 4 commits May 21, 2026 17:46
- TopBar: replace breadcrumb with session title/subtitle, add search pill, status dot only
- Sidebar: remove Sessions/Files tabs, flat session list with 2-line items, pencil icon new chat button
- ChatInput: single card layout, taller textarea, paperclip attach, toggle switch for auto-approve, orange Send button with text
- ChatInput: remove card wrapper, borderless design with subtle separator
- TopBar: 3 functional icons (Terminal, Files, Changes)
- RightPanel: new component with Files/Changes tabs, slide animation
- FileTreePanel: directory browser with breadcrumbs and syntax-highlighted file preview
- DiffViewer: adapted to vertical layout for narrow right panel
- App.vue: replaced modal FileViewer with inline RightPanel, keyboard shortcuts ⌘⇧E/⌘⇧G
…e button

- ChatInput: card-style container with rounded border, more padding, taller textarea
- Sidebar: remove border-right and footer border-top for cleaner look
- TerminalPanel: add X close button in header, emit close event
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@web/src/components/ChatInput.vue`:
- Around line 404-418: The model row currently nests a favorite <button> inside
the outer row <button> (the row that calls selectModel), which is invalid;
change the inner favorite control (class "fav-star") to a non-button interactive
element (e.g., a <span> or <div>) with role="button", tabindex="0", keep the
`@click.stop` handler calling store.toggleFavorite(p.id, m.id), and add keyboard
support (handle Enter/Space via `@keydown`) and the same :class binding using
store.isFavorite(p.id, m.id); alternatively, if you prefer, convert the outer
model row element (that calls selectModel) to a non-button clickable element
with role="button" so the favorite can remain a button — ensure either approach
removes nested <button> tags and preserves selectModel(p.id, m.id),
store.toggleFavorite(p.id, m.id), and accessibility/focus behavior.

In `@web/src/components/RightPanel.vue`:
- Around line 35-37: The close button using the X icon (button with class
"close-btn" and click handler emit('close')) lacks an accessible name; update
the button to include an explicit accessible label (for example add
aria-label="Close" or aria-label bound to a localized string) or include
visually-hidden text inside the button so screen readers announce its purpose,
ensuring the label matches your app's localization pattern if any.
🪄 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: df61b342-ede8-4f8b-9b3f-076695d84ec9

📥 Commits

Reviewing files that changed from the base of the PR and between b7b1e9a and f159df6.

📒 Files selected for processing (8)
  • web/src/App.vue
  • web/src/components/ChatInput.vue
  • web/src/components/DiffViewer.vue
  • web/src/components/FileTreePanel.vue
  • web/src/components/RightPanel.vue
  • web/src/components/Sidebar.vue
  • web/src/components/TerminalPanel.vue
  • web/src/components/TopBar.vue

Comment on lines 404 to 418
<button
v-for="m in p.models"
:key="m.id"
class="w-full px-3 py-1.5 text-xs text-left cursor-pointer select-none transition-colors group"
:class="store.providerName === p.id && store.modelName === m.id
? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 hover:text-zinc-700 dark:hover:text-zinc-200'"
class="dropdown-item group"
:class="{ active: store.providerName === p.id && store.modelName === m.id }"
@click="selectModel(p.id, m.id)"
>
<span class="truncate">{{ m.name || m.id }}</span>
<span v-if="m.recommended" class="ml-1 text-[9px] text-emerald-500 dark:text-emerald-400">recommended</span>
<span v-if="m.recommended" class="recommend-badge">recommended</span>
<button
class="ml-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer inline"
:class="store.isFavorite(p.id, m.id) ? 'text-amber-400 opacity-100' : 'text-zinc-300 dark:text-zinc-600'"
class="fav-star"
:class="{ 'is-fav': store.isFavorite(p.id, m.id) }"
@click.stop="store.toggleFavorite(p.id, m.id)"
:title="store.isFavorite(p.id, m.id) ? 'Remove from favorites' : 'Add to favorites'"
>★</button>
</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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid nested <button> elements in model rows.

The model option row is a button that contains another button for favorites. This is invalid interactive nesting and can cause inconsistent click/keyboard behavior.

Suggested fix
-                  <button
+                  <div
                     v-for="m in p.models"
                     :key="m.id"
                     class="dropdown-item group"
                     :class="{ active: store.providerName === p.id && store.modelName === m.id }"
                     `@click`="selectModel(p.id, m.id)"
+                    role="button"
+                    tabindex="0"
+                    `@keydown.enter.prevent`="selectModel(p.id, m.id)"
+                    `@keydown.space.prevent`="selectModel(p.id, m.id)"
                   >
                     <span class="truncate">{{ m.name || m.id }}</span>
                     <span v-if="m.recommended" class="recommend-badge">recommended</span>
                     <button
+                      type="button"
                       class="fav-star"
                       :class="{ 'is-fav': store.isFavorite(p.id, m.id) }"
                       `@click.stop`="store.toggleFavorite(p.id, m.id)"
                     >★</button>
-                  </button>
+                  </div>
📝 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
v-for="m in p.models"
:key="m.id"
class="w-full px-3 py-1.5 text-xs text-left cursor-pointer select-none transition-colors group"
:class="store.providerName === p.id && store.modelName === m.id
? 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 hover:text-zinc-700 dark:hover:text-zinc-200'"
class="dropdown-item group"
:class="{ active: store.providerName === p.id && store.modelName === m.id }"
@click="selectModel(p.id, m.id)"
>
<span class="truncate">{{ m.name || m.id }}</span>
<span v-if="m.recommended" class="ml-1 text-[9px] text-emerald-500 dark:text-emerald-400">recommended</span>
<span v-if="m.recommended" class="recommend-badge">recommended</span>
<button
class="ml-1 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer inline"
:class="store.isFavorite(p.id, m.id) ? 'text-amber-400 opacity-100' : 'text-zinc-300 dark:text-zinc-600'"
class="fav-star"
:class="{ 'is-fav': store.isFavorite(p.id, m.id) }"
@click.stop="store.toggleFavorite(p.id, m.id)"
:title="store.isFavorite(p.id, m.id) ? 'Remove from favorites' : 'Add to favorites'"
>★</button>
</button>
<div
v-for="m in p.models"
:key="m.id"
class="dropdown-item group"
:class="{ active: store.providerName === p.id && store.modelName === m.id }"
`@click`="selectModel(p.id, m.id)"
role="button"
tabindex="0"
`@keydown.enter.prevent`="selectModel(p.id, m.id)"
`@keydown.space.prevent`="selectModel(p.id, m.id)"
>
<span class="truncate">{{ m.name || m.id }}</span>
<span v-if="m.recommended" class="recommend-badge">recommended</span>
<button
type="button"
class="fav-star"
:class="{ 'is-fav': store.isFavorite(p.id, m.id) }"
`@click.stop`="store.toggleFavorite(p.id, m.id)"
>★</button>
</div>
🤖 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/src/components/ChatInput.vue` around lines 404 - 418, The model row
currently nests a favorite <button> inside the outer row <button> (the row that
calls selectModel), which is invalid; change the inner favorite control (class
"fav-star") to a non-button interactive element (e.g., a <span> or <div>) with
role="button", tabindex="0", keep the `@click.stop` handler calling
store.toggleFavorite(p.id, m.id), and add keyboard support (handle Enter/Space
via `@keydown`) and the same :class binding using store.isFavorite(p.id, m.id);
alternatively, if you prefer, convert the outer model row element (that calls
selectModel) to a non-button clickable element with role="button" so the
favorite can remain a button — ensure either approach removes nested <button>
tags and preserves selectModel(p.id, m.id), store.toggleFavorite(p.id, m.id),
and accessibility/focus behavior.

Comment on lines +35 to +37
<button class="close-btn" @click="emit('close')">
<X :size="14" />
</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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an accessible name to the close icon button.

The icon-only close button should include an explicit accessible name so screen readers can announce its purpose reliably.

Suggested fix
-      <button class="close-btn" `@click`="emit('close')">
+      <button type="button" class="close-btn" aria-label="Close panel" `@click`="emit('close')">
         <X :size="14" />
       </button>
📝 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 class="close-btn" @click="emit('close')">
<X :size="14" />
</button>
<button type="button" class="close-btn" aria-label="Close panel" `@click`="emit('close')">
<X :size="14" />
</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/src/components/RightPanel.vue` around lines 35 - 37, The close button
using the X icon (button with class "close-btn" and click handler emit('close'))
lacks an accessible name; update the button to include an explicit accessible
label (for example add aria-label="Close" or aria-label bound to a localized
string) or include visually-hidden text inside the button so screen readers
announce its purpose, ensuring the label matches your app's localization pattern
if any.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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 `@web/src/components/ChatInput.vue`:
- Around line 450-461: The channel toggle button (class "channel-btn") lacks
accessible labeling and state; update the button rendered when
store.channelAvailable to include an explicit label for screen readers (e.g.,
aria-label or a visually-hidden span) describing its purpose and add
aria-pressed bound to store.channelEnabled to expose ON/OFF state, and ensure
the click still calls store.toggleChannel(!store.channelEnabled); keep the
existing title if desired but make the accessible name and aria-pressed the
primary mechanism for assistive tech.

In `@web/src/components/TerminalInstance.vue`:
- Around line 89-96: The ptyCreate() call can resolve after the component
unmounts and then set sessionId and call connectWS(), leaving a server PTY
running; to fix, add an "isMounted" (or "alive") boolean/AbortController checked
immediately after awaiting api.ptyCreate() and before assigning sessionId or
calling connectWS() in the block that contains ptyCreate(), and similarly guard
the other async spots (the blocks around lines 133-145 and 165-166) so they
no-op if unmounted; ensure cleanup() clears the flag/aborts the controller and
that connectWS() and any session setup only run when isMounted is true to
prevent post-unmount side-effects.

In `@web/src/components/TerminalPanel.vue`:
- Around line 53-70: Replace the non-focusable span used for closing tabs with a
real button element so keyboard and screen-reader users can activate it: change
the tab-close span inside the tab button to a <button type="button"> that keeps
the `@click.stop`="closeTab(tab.id)" behavior, add an accessible name like
:aria-label="`Close ${tab.label}`" (or aria-label="Close tab") and preserve the
"tab-close" class and SVG child so styling remains; ensure the outer tab button
still responds to clicks for activation (the inner button must use
stopPropagation) and update any references to closeTab in TerminalPanel.vue if
needed.
- Around line 30-40: closeTab() unconditionally changes activeId after removing
a tab, causing the visible terminal to switch when a background tab is closed;
modify closeTab so it first records whether the closed tab was the active one
(compare id to activeId.value), then splice the tab, keep the existing
emit('close') behavior if tabs become empty, and only compute newIdx and assign
activeId.value when the closed tab was active; reference closeTab, tabs.value,
activeId.value, and emit in your change.

In `@web/src/components/ToolCallCard.vue`:
- Around line 56-71: The file/line parsing in the searchResults computed
currently uses /^([^:]+):(\d+):(.*)$/ which fails for file paths containing
colons; change the parsing to match the right-most ":<line>:" pair (e.g. use
/^(.*):(\d+):(.*)$/ or find the last ":<digits>:" via lastIndexOf) inside the
map callback that builds { file, lineNum, content, isRef } so file can contain
colons (Windows drive letters) and lineNum is still parsed as the numeric group;
update the match logic in the searchResults computed accordingly.
🪄 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: f4ca57a6-1637-4af8-9c69-a9380f8299a2

📥 Commits

Reviewing files that changed from the base of the PR and between f159df6 and 0262a91.

📒 Files selected for processing (6)
  • web/src/components/ChatInput.vue
  • web/src/components/RightPanel.vue
  • web/src/components/Sidebar.vue
  • web/src/components/TerminalInstance.vue
  • web/src/components/TerminalPanel.vue
  • web/src/components/ToolCallCard.vue

Comment on lines 450 to 461
<button
v-if="store.channelAvailable"
class="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition-colors cursor-pointer"
:class="store.channelEnabled
? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-200 dark:hover:bg-emerald-500/25'
: 'bg-zinc-100 dark:bg-zinc-700/60 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600/60'"
class="channel-btn"
:class="{ active: store.channelEnabled }"
:title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'"
@click="store.toggleChannel(!store.channelEnabled)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" />
</svg>
WeChat
</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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Expose the inline channel toggle state explicitly.

This new icon-only toggle currently relies on title, so screen readers won't reliably get its purpose or on/off state. Add an explicit label and aria-pressed.

Suggested fix
             <button
               v-if="store.channelAvailable"
+              type="button"
               class="channel-btn"
               :class="{ active: store.channelEnabled }"
+              :aria-pressed="store.channelEnabled"
+              :aria-label="store.channelEnabled ? 'Disable WeChat notifications' : 'Enable WeChat notifications'"
               :title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'"
               `@click`="store.toggleChannel(!store.channelEnabled)"
             >
📝 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
v-if="store.channelAvailable"
class="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition-colors cursor-pointer"
:class="store.channelEnabled
? 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-200 dark:hover:bg-emerald-500/25'
: 'bg-zinc-100 dark:bg-zinc-700/60 text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600/60'"
class="channel-btn"
:class="{ active: store.channelEnabled }"
:title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'"
@click="store.toggleChannel(!store.channelEnabled)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" />
</svg>
WeChat
</button>
<button
v-if="store.channelAvailable"
type="button"
class="channel-btn"
:class="{ active: store.channelEnabled }"
:aria-pressed="store.channelEnabled"
:aria-label="store.channelEnabled ? 'Disable WeChat notifications' : 'Enable WeChat notifications'"
:title="store.channelEnabled ? 'WeChat notifications ON' : 'WeChat notifications OFF'"
`@click`="store.toggleChannel(!store.channelEnabled)"
>
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767A2 2 0 0011 16h2l3 3v-3h1a2 2 0 002-2V9a2 2 0 00-2-2h-2z" />
</svg>
</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/src/components/ChatInput.vue` around lines 450 - 461, The channel toggle
button (class "channel-btn") lacks accessible labeling and state; update the
button rendered when store.channelAvailable to include an explicit label for
screen readers (e.g., aria-label or a visually-hidden span) describing its
purpose and add aria-pressed bound to store.channelEnabled to expose ON/OFF
state, and ensure the click still calls
store.toggleChannel(!store.channelEnabled); keep the existing title if desired
but make the accessible name and aria-pressed the primary mechanism for
assistive tech.

Comment on lines +89 to +96
try {
const result = await api.ptyCreate()
sessionId = result.id
connectWS(result.id)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
term.writeln(`\r\n\x1b[31mFailed to create terminal: ${msg}\x1b[0m`)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard ptyCreate() against unmount races.

If the component unmounts while api.ptyCreate() is in flight, the promise can still resolve after cleanup(), set sessionId, and leave a PTY running server-side because connectWS() returns once term is gone.

Suggested fix
 let ws: WebSocket | null = null
 let sessionId = ''
 let resizeObserver: ResizeObserver | null = null
 let themeObserver: MutationObserver | null = null
+let disposed = false

 async function init() {
   if (!termEl.value) return
+  disposed = false

   term = new Terminal({
@@
   try {
     const result = await api.ptyCreate()
+    if (disposed) {
+      api.ptyKill(result.id).catch(() => {})
+      return
+    }
     sessionId = result.id
     connectWS(result.id)
   } catch (err: unknown) {
@@
 function cleanup() {
+  disposed = true
   resizeObserver?.disconnect()
   resizeObserver = null

Also applies to: 133-145, 165-166

🤖 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/src/components/TerminalInstance.vue` around lines 89 - 96, The
ptyCreate() call can resolve after the component unmounts and then set sessionId
and call connectWS(), leaving a server PTY running; to fix, add an "isMounted"
(or "alive") boolean/AbortController checked immediately after awaiting
api.ptyCreate() and before assigning sessionId or calling connectWS() in the
block that contains ptyCreate(), and similarly guard the other async spots (the
blocks around lines 133-145 and 165-166) so they no-op if unmounted; ensure
cleanup() clears the flag/aborts the controller and that connectWS() and any
session setup only run when isMounted is true to prevent post-unmount
side-effects.

Comment on lines +30 to +40
function closeTab(id: string) {
const idx = tabs.value.findIndex(t => t.id === id)
if (idx === -1) return
tabs.value.splice(idx, 1)
if (tabs.value.length === 0) {
emit('close')
return
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
connected.value = false
// Select adjacent tab
const newIdx = Math.min(idx, tabs.value.length - 1)
activeId.value = tabs.value[newIdx].id
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't change the active terminal when closing a different tab.

closeTab() always selects an adjacent tab after removal, so closing a background tab can unexpectedly switch the visible terminal.

Suggested fix
 function closeTab(id: string) {
   const idx = tabs.value.findIndex(t => t.id === id)
   if (idx === -1) return
+  const wasActive = activeId.value === id
   tabs.value.splice(idx, 1)
   if (tabs.value.length === 0) {
     emit('close')
     return
   }
-  // Select adjacent tab
-  const newIdx = Math.min(idx, tabs.value.length - 1)
-  activeId.value = tabs.value[newIdx].id
+  if (wasActive) {
+    const newIdx = Math.min(idx, tabs.value.length - 1)
+    activeId.value = tabs.value[newIdx].id
+  }
 }
📝 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
function closeTab(id: string) {
const idx = tabs.value.findIndex(t => t.id === id)
if (idx === -1) return
tabs.value.splice(idx, 1)
if (tabs.value.length === 0) {
emit('close')
return
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
connected.value = false
// Select adjacent tab
const newIdx = Math.min(idx, tabs.value.length - 1)
activeId.value = tabs.value[newIdx].id
function closeTab(id: string) {
const idx = tabs.value.findIndex(t => t.id === id)
if (idx === -1) return
const wasActive = activeId.value === id
tabs.value.splice(idx, 1)
if (tabs.value.length === 0) {
emit('close')
return
}
if (wasActive) {
const newIdx = Math.min(idx, tabs.value.length - 1)
activeId.value = tabs.value[newIdx].id
}
}
🤖 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/src/components/TerminalPanel.vue` around lines 30 - 40, closeTab()
unconditionally changes activeId after removing a tab, causing the visible
terminal to switch when a background tab is closed; modify closeTab so it first
records whether the closed tab was the active one (compare id to
activeId.value), then splice the tab, keep the existing emit('close') behavior
if tabs become empty, and only compute newIdx and assign activeId.value when the
closed tab was active; reference closeTab, tabs.value, activeId.value, and emit
in your change.

Comment on lines +53 to +70
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-btn"
:class="{ 'tab-active': tab.id === activeId }"
@click="activeId = tab.id"
>
<span class="tab-label">{{ tab.label }}</span>
<span
v-if="tabs.length > 1"
class="tab-close"
@click.stop="closeTab(tab.id)"
>
<svg class="w-2.5 h-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the per-tab close control a real button.

The clickable span isn't focusable or named, so keyboard and screen-reader users can't close individual tabs. This needs to be a separate button, not an ad-hoc click target inside the tab 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/src/components/TerminalPanel.vue` around lines 53 - 70, Replace the
non-focusable span used for closing tabs with a real button element so keyboard
and screen-reader users can activate it: change the tab-close span inside the
tab button to a <button type="button"> that keeps the
`@click.stop`="closeTab(tab.id)" behavior, add an accessible name like
:aria-label="`Close ${tab.label}`" (or aria-label="Close tab") and preserve the
"tab-close" class and SVG child so styling remains; ensure the outer tab button
still responds to clicks for activation (the inner button must use
stopPropagation) and update any references to closeTab in TerminalPanel.vue if
needed.

Comment on lines +56 to +71
const searchResults = computed(() => {
const output = props.tool.output || ''
if (!output) return { lines: [], count: null }
const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/)
const count = countMatch ? parseInt(countMatch[1]) : null
const lines = output.split('\n')
.filter(l => {
const t = l.trim()
return t && !t.startsWith('(')
})
.map(line => {
const m = line.match(/^([^:]+):(\d+):(.*)$/)
if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true }
return { file: '', lineNum: 0, content: line, isRef: false }
})
return { lines, count }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Parse search refs from the right-most :<line>: pair.

The current matcher breaks on paths containing colons, especially Windows drive letters, so valid hits won't render as file references.

Suggested fix
-      const m = line.match(/^([^:]+):(\d+):(.*)$/)
+      const m = line.match(/^(.*):(\d+):(.*)$/)
📝 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 searchResults = computed(() => {
const output = props.tool.output || ''
if (!output) return { lines: [], count: null }
const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/)
const count = countMatch ? parseInt(countMatch[1]) : null
const lines = output.split('\n')
.filter(l => {
const t = l.trim()
return t && !t.startsWith('(')
})
.map(line => {
const m = line.match(/^([^:]+):(\d+):(.*)$/)
if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true }
return { file: '', lineNum: 0, content: line, isRef: false }
})
return { lines, count }
const searchResults = computed(() => {
const output = props.tool.output || ''
if (!output) return { lines: [], count: null }
const countMatch = output.match(/\((\d+) (?:matches found|results?)\)/)
const count = countMatch ? parseInt(countMatch[1]) : null
const lines = output.split('\n')
.filter(l => {
const t = l.trim()
return t && !t.startsWith('(')
})
.map(line => {
const m = line.match(/^(.*):(\d+):(.*)$/)
if (m) return { file: m[1], lineNum: parseInt(m[2]), content: m[3], isRef: true }
return { file: '', lineNum: 0, content: line, isRef: false }
})
return { lines, count }
🤖 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/src/components/ToolCallCard.vue` around lines 56 - 71, The file/line
parsing in the searchResults computed currently uses /^([^:]+):(\d+):(.*)$/
which fails for file paths containing colons; change the parsing to match the
right-most ":<line>:" pair (e.g. use /^(.*):(\d+):(.*)$/ or find the last
":<digits>:" via lastIndexOf) inside the map callback that builds { file,
lineNum, content, isRef } so file can contain colons (Windows drive letters) and
lineNum is still parsed as the numeric group; update the match logic in the
searchResults computed accordingly.

@cnjack cnjack merged commit 8ccbf88 into main May 21, 2026
@cnjack cnjack deleted the feat/web-redesign-pencil branch May 21, 2026 13:15
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