From db89068a2d232905b421a3dfb514d0ff507e602a Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 10:22:06 -0600 Subject: [PATCH 01/13] =?UTF-8?q?refactor:=20rename=20"JSON=20mode"=20?= =?UTF-8?q?=E2=86=92=20"Chat"=20+=20"xterm=20Claude"=20=E2=86=92=20"Termin?= =?UTF-8?q?al"=20in=20user-facing=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the React-chat interface from its experimental name. The internal identifiers (`json-claude` tab type, `JsonModeChat` component, `jsonClaude/*` events, `defaultClaudeTabType: 'xterm' | 'json'` field, `json-mode-cards/` directory, `plans/json-mode-native-chat.md`, etc.) all stay unchanged — only display strings shift. User-visible changes: - Tab labels: `Claude (JSON)` → `Chat` - Right-click menu (desktop + mobile): `Convert to JSON-mode chat` → `Switch to Chat`; `Convert to terminal mode` → `Switch to Terminal` - Sparkles tooltip: `New Claude (JSON) tab` → `New Chat tab`; `⇧-click for Claude (JSON, experimental)` → `⇧-click for Chat`; `⇧-click for Claude Code (xterm)` → `⇧-click for Terminal` - ActivityCosts empty-state copy - CLAUDE.md / CONTRIBUTING.md user-facing phrasing Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 10 ++--- CONTRIBUTING.md | 2 +- src/main/panes-fsm.ts | 4 +- src/renderer/components/ActivityCosts.tsx | 2 +- src/renderer/components/MobileApp.tsx | 11 +++-- src/renderer/components/TerminalPanel.tsx | 53 ++++++++++------------- src/renderer/hooks/useTabHandlers.ts | 6 +-- src/shared/state/terminals.test.ts | 18 ++++---- 8 files changed, 50 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 317c7c58..be7b7fb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ navigation. - **lucide-react** v1.x for icons (note: brand icons like `Github` are NOT exported in this version — use `GitPullRequest` etc.) - **electron-builder** for packaging, signed with the user's personal Developer ID, notarized - **electron-updater** for OTA updates from GitHub releases -- **`@anthropic-ai/claude-code`** is bundled as a dep (pinned native binary) and used by json-mode tabs only. xterm Claude tabs continue to spawn the user's PATH `claude` so power users on bleeding-edge / beta builds keep that experience. Both share `~/.claude/` for auth + MCP config. +- **`@anthropic-ai/claude-code`** is bundled as a dep (pinned native binary) and used by Chat tabs (internally `json-mode`) only. Terminal tabs (internally xterm-hosted) continue to spawn the user's PATH `claude` so power users on bleeding-edge / beta builds keep that experience. Both share `~/.claude/` for auth + MCP config. ## Architecture (read this before touching state) @@ -429,10 +429,10 @@ hard dependency on `gh`. happens via the chip strip's `+` button (or `File → Add Backend…` if/when wired). Tokens encrypted in `secrets.enc` keyed `backend-token:`; connections list lives in `userData/config.json`. -- **Dual-claude model** — Harness ships two Claude Code binaries. **xterm - Claude tabs** spawn `/bin/zsh -ilc claude` so the user's PATH `claude` - is what runs (lets bleeding-edge / beta testers stay on their own - build). **json-mode tabs** spawn the bundled +- **Dual-claude model** — Harness ships two Claude Code binaries. **Terminal + tabs** (internally xterm-hosted) spawn `/bin/zsh -ilc claude` so the user's + PATH `claude` is what runs (lets bleeding-edge / beta testers stay on their + own build). **Chat tabs** (internally `json-mode`) spawn the bundled `@anthropic-ai/claude-code` native binary directly — pinned per Harness release so the `--permission-prompt-tool` round trip and stream-json schema can't drift between npm publishes. Both share `~/.claude/` for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40c86d46..8020a452 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,7 @@ The deep architecture documentation lives in [CLAUDE.md](CLAUDE.md). It's the or - **Anti-patterns to avoid in slices and derivers** — common mistakes that look fine in isolation but cause perf problems at scale (subscriber sweeps, reducer `.map()` allocations, etc.). - **How performance debugging works** — the perf log + HUD that we lean on when something feels slow. -If you're touching the json-mode chat specifically, the `plans/json-mode-native-chat.md` document has the live feature backlog and design notes. +If you're touching the Chat interface specifically (internally referred to as "json-mode"), the `plans/json-mode-native-chat.md` document has the live feature backlog and design notes. ## Code style diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index e2625e4d..51c74d61 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -229,7 +229,7 @@ export class PanesFSM { agentTab = { id: sessionId, type: 'json-claude', - label: 'Claude (JSON)', + label: 'Chat', sessionId, mode: 'awake', model @@ -418,7 +418,7 @@ export class PanesFSM { newType === 'json-claude' ? sessionId : `agent-${wtPath.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}` - const newLabel = newType === 'json-claude' ? 'Claude (JSON)' : agentDisplayName('claude') + const newLabel = newType === 'json-claude' ? 'Chat' : agentDisplayName('claude') this.store.dispatch({ type: 'terminals/tabTypeChanged', payload: { worktreePath: wtPath, tabId, newId, newType, newLabel } diff --git a/src/renderer/components/ActivityCosts.tsx b/src/renderer/components/ActivityCosts.tsx index c63eca92..efad8d1d 100644 --- a/src/renderer/components/ActivityCosts.tsx +++ b/src/renderer/components/ActivityCosts.tsx @@ -302,7 +302,7 @@ export function ActivityCosts(): JSX.Element { {!loading && repos.length === 0 && (
- No json-mode sessions in the selected period. + No Chat sessions in the selected period.
)} diff --git a/src/renderer/components/MobileApp.tsx b/src/renderer/components/MobileApp.tsx index 7edbca15..4cd7af57 100644 --- a/src/renderer/components/MobileApp.tsx +++ b/src/renderer/components/MobileApp.tsx @@ -253,8 +253,7 @@ interface HeaderProps { pickerOpen: boolean onTogglePicker: () => void onSelectTab: (tabId: string) => void - /** Optional convert callback. Defined only when JSON-mode is on; if - * set, tapping the *active* tab opens a swap menu. */ + /** Tap the active tab to open a Terminal/Chat swap menu. */ onConvertTabType?: (tabId: string, newType: 'agent' | 'json-claude') => void rightPanelOpen: boolean onToggleRightPanel?: () => void @@ -333,8 +332,8 @@ interface TabChipProps { status: PtyStatus shellActivity?: { active: boolean; processName?: string } onSelect: () => void - /** Optional: when defined, tapping the active tab opens a swap menu - * to convert between xterm Claude and JSON-mode Claude. */ + /** Tapping the active tab opens a swap menu to convert between + * Terminal and Chat. */ onConvertTabType?: (newType: 'agent' | 'json-claude') => void } @@ -391,7 +390,7 @@ function TabChip({ tab, active, status, shellActivity, onSelect, onConvertTabTyp onConvertTabType('json-claude') }} > - Convert to JSON-mode chat + Switch to Chat ) : ( )} diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index 1213e478..b8964313 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -68,18 +68,14 @@ interface TerminalPanelProps { onAddTab: () => void onAddAgentTab: (agentKind?: AgentKind) => void onAddBrowserTab: () => void - /** Optional: when defined, alt-clicking the Sparkles button opens a - * json-claude tab (experimental, gated by settings.jsonModeClaudeTabs). */ + /** Shift-clicking the Sparkles button opens the non-default Claude + * interface (Terminal if Chat is the default, Chat if Terminal is). */ onAddJsonClaudeTab?: () => void - /** When the JSON-mode flag is on, controls which tab type is the - * *default* (plain click) vs. the *modifier* (shift-click). Lets a - * user who lives in JSON mode flip the button so plain click spawns - * json-claude and shift forces the xterm TUI. Undefined when - * `onAddJsonClaudeTab` is undefined (json-mode flag off). */ + /** Controls which Claude interface plain-click on Sparkles spawns vs. + * what shift-click flips to. Values are unchanged internal identifiers + * — UI labels them "Terminal" and "Chat". */ defaultClaudeTabType?: 'xterm' | 'json' - /** Optional: convert a tab between xterm Claude and JSON-mode Claude - * in place. Only relevant when the json-mode feature flag is on; the - * parent omits it otherwise so the per-tab right-click menu hides. */ + /** Convert a Claude tab between Terminal and Chat in place. */ onConvertTabType?: (tabId: string, newType: 'agent' | 'json-claude') => void defaultAgent: AgentKind onSleepTab: (tabId: string) => void @@ -105,10 +101,9 @@ interface SortableTabProps { showClose: boolean onSelect: () => void onClose: () => void - /** Optional: when provided, right-clicking the tab opens a small menu - * to convert between xterm Claude and JSON-mode Claude. Only passed - * in when the source tab is convertible (Claude agent or json-claude) - * and the JSON-mode feature flag is on. */ + /** When provided, right-clicking the tab opens a small menu to convert + * between Terminal and Chat. Passed in only for Claude tabs (agent + * with agentKind=claude, or json-claude). */ onConvertTabType?: (newType: 'agent' | 'json-claude') => void /** Optional: when provided AND the tab is an awake json-claude tab, * the right-click menu shows a "Sleep" item. Sleeping tears down @@ -266,7 +261,7 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect onConvertTabType('json-claude') }} > - Convert to JSON-mode chat + Switch to Chat )} {onConvertTabType && tab.type === 'json-claude' && ( @@ -278,7 +273,7 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect onConvertTabType('agent') }} > - Convert to terminal mode + Switch to Terminal )} @@ -326,9 +321,9 @@ export function TerminalPanel({ }, [pane.id, registerSlot]) const activeTab = pane.tabs.find((t) => t.id === pane.activeTabId) - // Spectator chip only makes sense for xterm-backed tabs. JSON-mode - // agent tabs (when they land) re-render per client, so the controller/ - // spectator concept doesn't apply. + // Spectator chip only makes sense for terminal-backed tabs. Chat tabs + // re-render per client, so the controller/spectator concept doesn't + // apply. const showSpectatorChip = !!activeTab && (activeTab.type === 'agent' || activeTab.type === 'shell') @@ -377,18 +372,18 @@ export function TerminalPanel({ { - const jsonIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' - const plain = jsonIsDefault - ? 'New Claude (JSON) tab' + const chatIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' + const plain = chatIsDefault + ? 'New Chat tab' : `New ${agentDisplayName(defaultAgent)} tab` const altPart = AGENT_REGISTRY.length > 1 ? ` · ⌥-click for ${agentDisplayName(AGENT_REGISTRY.find((a) => a.kind !== defaultAgent)?.kind)}` : '' const shiftPart = onAddJsonClaudeTab - ? jsonIsDefault - ? ` · ⇧-click for ${agentDisplayName('claude')} (xterm)` - : ' · ⇧-click for Claude (JSON, experimental)' + ? chatIsDefault + ? ` · ⇧-click for Terminal` + : ' · ⇧-click for Chat' : '' return plain + altPart + shiftPart })()} @@ -397,11 +392,11 @@ export function TerminalPanel({ onClick={(e) => { // Modifier precedence: // alt → other registered agent (Codex when default is - // Claude, vice versa) — independent of json/xterm. - // shift → "the other Claude tab type" relative to the + // Claude, vice versa) — independent of Chat/Terminal. + // shift → "the other Claude interface" relative to the // user's defaultClaudeTabType setting. - // plain → the default Claude tab type. - const jsonIsDefault = + // plain → the default Claude interface. + const chatIsDefault = !!onAddJsonClaudeTab && defaultClaudeTabType === 'json' if (e.altKey && AGENT_REGISTRY.length > 1) { const other = AGENT_REGISTRY.find((a) => a.kind !== defaultAgent) diff --git a/src/renderer/hooks/useTabHandlers.ts b/src/renderer/hooks/useTabHandlers.ts index 57437e64..5447927d 100644 --- a/src/renderer/hooks/useTabHandlers.ts +++ b/src/renderer/hooks/useTabHandlers.ts @@ -83,8 +83,8 @@ export function useTabHandlers({ const handleAddJsonClaudeTab = useCallback( (worktreePath: string, paneId?: string) => { - // JSON-mode Claude tabs use a UUID for both tab id and session id — - // the manager passes it to `claude --session-id` directly so the + // Chat (json-claude) tabs use a UUID for both tab id and session id + // — the manager passes it to `claude --session-id` directly so the // session jsonl reuses the same identifier and survives a reload. const sessionId = crypto.randomUUID() appendTabToPane( @@ -92,7 +92,7 @@ export function useTabHandlers({ { id: sessionId, type: 'json-claude', - label: 'Claude (JSON)', + label: 'Chat', sessionId }, paneId diff --git a/src/shared/state/terminals.test.ts b/src/shared/state/terminals.test.ts index 62c9c615..cb90336a 100644 --- a/src/shared/state/terminals.test.ts +++ b/src/shared/state/terminals.test.ts @@ -560,7 +560,7 @@ describe('terminalsReducer', () => { tabId: 'agent-1', newId: 'sess-1', newType: 'json-claude', - newLabel: 'Claude (JSON)' + newLabel: 'Chat' } }) const leaves = getLeaves(next.panes['/wt/a']) @@ -568,7 +568,7 @@ describe('terminalsReducer', () => { expect(tab.type).toBe('json-claude') expect(tab.id).toBe('sess-1') expect(tab.sessionId).toBe('sess-1') - expect(tab.label).toBe('Claude (JSON)') + expect(tab.label).toBe('Chat') expect(leaves[0].activeTabId).toBe('sess-1') // Other tabs untouched. expect(leaves[0].tabs[1].id).toBe('shell-1') @@ -578,7 +578,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -617,7 +617,7 @@ describe('terminalsReducer', () => { tabId: 'agent-1', newId: 'sess-1', newType: 'json-claude', - newLabel: 'Claude (JSON)' + newLabel: 'Chat' } }) const tab = getLeaves(next.panes['/wt/a'])[0].tabs[0] @@ -630,8 +630,8 @@ describe('terminalsReducer', () => { type: 'leaf', id: 'p1', tabs: [ - { id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'awake' }, - { id: 'sess-2', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-2', mode: 'awake' } + { id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'awake' }, + { id: 'sess-2', type: 'json-claude', label: 'Chat', sessionId: 'sess-2', mode: 'awake' } ], activeTabId: 'sess-1' } @@ -651,7 +651,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'asleep' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'asleep' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -691,7 +691,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'asleep' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'asleep' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } @@ -706,7 +706,7 @@ describe('terminalsReducer', () => { const tree: PaneNode = { type: 'leaf', id: 'p1', - tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Claude (JSON)', sessionId: 'sess-1', mode: 'awake' }], + tabs: [{ id: 'sess-1', type: 'json-claude', label: 'Chat', sessionId: 'sess-1', mode: 'awake' }], activeTabId: 'sess-1' } const start: TerminalsState = { ...initialTerminals, panes: { '/wt/a': tree } } From b5f581bdc1e03dc652bf74850659e1b4a42d4c23 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 10:22:25 -0600 Subject: [PATCH 02/13] feat: nest Chat/Terminal toggle under Claude in Settings + onboarding + tab header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the Chat-vs-Terminal interface choice well-placed and discoverable without changing the default (Terminal stays default for new installs). Settings restructure: - Removes the standalone `Experimental → JSON-mode Claude tabs` sub-card. The Interface picker (Terminal/Chat radio) now lives nested under the Default Agent → Claude row in Agent → General, rendered with the new `InterfaceToggle` component. The picker only shows when Claude is the selected default agent (Codex has no Chat-mode equivalent). - Chat-specific config (default permission mode for new chats, auto-sleep idle minutes, chat density) moves to a `Chat interface` card under Agent → Claude — alongside the Claude command, env vars, etc. `settings.jsonModeClaudeTabs` removed entirely (11 call sites, under the spec's 10-site threshold but functionally a pure gate that no longer has a "behind-a-flag" reason to exist). Default behavior is now "Chat is always available; Terminal is still the default." Persistence and IPC handler dropped along with the field; existing configs with the key set are harmlessly ignored. `defaultClaudeTabType` field stays unchanged — values continue to be `'xterm' | 'json'` (no churn) but Settings now labels them `Terminal` and `Chat`. Onboarding: - New `choose-interface` QuestStep, inserted between `hidden` and `spawn-second`. The QuestCard renders the same `InterfaceToggle` the Settings panel uses (size="compact"), with a Continue button that advances to `spawn-second`. Skip / dismiss defaults to Terminal (the existing `defaultClaudeTabType` default). Per-tab switch chip: - Every Claude tab header (agent.claude + json-claude) gets a small icon button (MessageSquare for Chat-bound switch, TerminalIcon for Terminal-bound) right before the close button, with a `Switch to Chat` / `Switch to Terminal` tooltip. Wires into the existing `convertTabType` IPC — no new plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/build-initial-state.ts | 1 - src/main/index.ts | 17 -- src/main/panes-fsm.ts | 7 +- src/main/persistence.ts | 9 +- src/renderer/App.tsx | 19 +- src/renderer/build-backend.ts | 1 - src/renderer/components/InterfaceToggle.tsx | 84 ++++++ src/renderer/components/MobileApp.tsx | 3 +- src/renderer/components/QuestCard.tsx | 67 ++++- src/renderer/components/Settings.tsx | 291 +++++++++----------- src/renderer/components/TerminalPanel.tsx | 39 ++- src/renderer/components/WorkspaceView.tsx | 10 +- src/renderer/types.ts | 9 +- src/shared/state/onboarding.test.ts | 10 +- src/shared/state/onboarding.ts | 8 +- src/shared/state/settings.test.ts | 18 +- src/shared/state/settings.ts | 14 +- 17 files changed, 357 insertions(+), 250 deletions(-) create mode 100644 src/renderer/components/InterfaceToggle.tsx diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index c1ce179f..57ef48dd 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -88,7 +88,6 @@ export function buildInitialAppState( wsTransportHost: config.wsTransportHost ?? '127.0.0.1', browserToolsEnabled: config.browserToolsEnabled !== false, browserToolsMode: config.browserToolsMode === 'view' ? 'view' : 'full', - jsonModeClaudeTabs: config.jsonModeClaudeTabs === true, defaultClaudeTabType: config.defaultClaudeTabType === 'json' ? 'json' : 'xterm', autoApprovePermissions: config.autoApprovePermissions === true, autoApproveSteerInstructions: config.autoApproveSteerInstructions || '', diff --git a/src/main/index.ts b/src/main/index.ts index fe1ce574..f73ff63f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -754,9 +754,6 @@ const panesFSM = new PanesFSM(store, { getDefaultAgentKind: () => toAgentKind(store.getSnapshot().state.settings.defaultAgent), getDefaultClaudeTabType: () => { const s = store.getSnapshot().state.settings - // The json-mode flag gates everything — when it's off, behave as if - // the default is xterm regardless of the per-type setting. - if (!s.jsonModeClaudeTabs) return 'xterm' return s.defaultClaudeTabType === 'json' ? 'json' : 'xterm' }, // Authoritative PTY teardown when tabs leave the tree. The renderer @@ -2648,20 +2645,6 @@ function registerIpcHandlers(): void { } ) - transport.onRequest('config:setJsonModeClaudeTabs', (_ctx, enabled: boolean) => { - if (enabled) { - config.jsonModeClaudeTabs = true - } else { - delete config.jsonModeClaudeTabs - } - saveConfig(config) - store.dispatch({ - type: 'settings/jsonModeClaudeTabsChanged', - payload: enabled - }) - return true - }) - transport.onRequest( 'config:setDefaultClaudeTabType', (_ctx, value: 'xterm' | 'json') => { diff --git a/src/main/panes-fsm.ts b/src/main/panes-fsm.ts index 51c74d61..d63a1d40 100644 --- a/src/main/panes-fsm.ts +++ b/src/main/panes-fsm.ts @@ -24,10 +24,9 @@ interface PanesFSMOptions { getRepoRootForWorktree: (worktreePath: string) => string | undefined getLatestClaudeSessionId: (worktreePath: string) => Promise getDefaultAgentKind?: () => AgentKind - /** Read the JSON-mode Claude feature flag + default-tab-type setting. - * When the flag is on AND default is 'json', a default Claude agent - * tab gets spawned as a json-claude tab instead. Always returns - * 'xterm' (or undefined) when the feature flag is off. */ + /** Read the default Claude interface setting. When this returns 'json', + * a default Claude agent tab spawns as a json-claude tab instead of + * an xterm-hosted one. */ getDefaultClaudeTabType?: () => 'xterm' | 'json' /** Tear down the PTY backing a closed tab. Called for agent + shell * tabs when they're removed from the tree (closeTab, restartAgentTab, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index cc47a2f3..364934b1 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -173,13 +173,8 @@ export interface Config { // 'view' = inspect tabs + spawn/navigate, but no clicking, typing, or // scrolling. 'full' = everything. Default 'full' (undefined treated as 'full'). browserToolsMode?: 'view' | 'full' - // Experimental: enable the JSON-streamed React chat tab type as an - // alternative to xterm-hosted Claude tabs. Default off; gated by the - // jsonModeClaudeTabs setting in the renderer. - jsonModeClaudeTabs?: boolean - // When jsonModeClaudeTabs is on, controls whether the default Claude - // tab spawned by panes-fsm is the xterm TUI or the JSON-mode chat. - // Ignored when jsonModeClaudeTabs is off. + // Controls whether new Claude tabs spawn as the terminal-hosted TUI + // ('xterm') or the React chat interface ('json'). Default 'xterm'. defaultClaudeTabType?: 'xterm' | 'json' // When true, JSON-mode tabs delegate per-tool approval decisions to a // Haiku oneshot for obviously-safe tool calls. Productivity feature diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index eedfaf87..46c82775 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -289,7 +289,7 @@ const setQuestStep = useCallback((next: QuestStep) => { useEffect(() => { if (questStep === 'done' || questStep === 'finale') return if (questStep === 'hidden' && agentWorktreeCount >= 1) { - setQuestStep(agentWorktreeCount >= 2 ? 'switch-between' : 'spawn-second') + setQuestStep(agentWorktreeCount >= 2 ? 'switch-between' : 'choose-interface') return } if (questStep === 'spawn-second' && agentWorktreeCount >= 2) { @@ -1268,15 +1268,9 @@ const setQuestStep = useCallback((next: QuestStep) => { defaultAgent={defaultAgent ?? 'claude'} onAddAgentTab={(wt, kind, paneId) => handleAddAgentTab(wt, kind ?? defaultAgent ?? 'claude', paneId)} onAddBrowserTab={handleAddBrowserTab} - onAddJsonClaudeTab={ - settings.jsonModeClaudeTabs ? handleAddJsonClaudeTab : undefined - } - onConvertTabType={ - settings.jsonModeClaudeTabs ? handleConvertTabType : undefined - } - defaultClaudeTabType={ - settings.jsonModeClaudeTabs ? settings.defaultClaudeTabType : undefined - } + onAddJsonClaudeTab={handleAddJsonClaudeTab} + onConvertTabType={handleConvertTabType} + defaultClaudeTabType={settings.defaultClaudeTabType} onSleepTab={handleSleepTab} onCloseTab={handleCloseTab} onRestartAgentTab={handleRestartAgentTab} @@ -1401,6 +1395,11 @@ const setQuestStep = useCallback((next: QuestStep) => { step={questStep} onDismiss={() => setQuestStep('done')} onFinish={() => setQuestStep('done')} + claudeTabType={settings.defaultClaudeTabType} + onSelectClaudeTabType={(value) => { + void backend.setDefaultClaudeTabType(value) + }} + onAdvanceFromInterface={() => setQuestStep('spawn-second')} /> {/* Right panel — hidden on the new-worktree screen so the form gets the full width */} {!showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && !showReview && reportIssueState === null && !rightColumnHidden && ( diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 34ada6b6..91bfd949 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -239,7 +239,6 @@ export function buildBackend( getLanAddresses: () => req('net:getLanAddresses'), setBrowserToolsEnabled: (enabled: boolean) => req('config:setBrowserToolsEnabled', enabled), setBrowserToolsMode: (mode: 'view' | 'full') => req('config:setBrowserToolsMode', mode), - setJsonModeClaudeTabs: (enabled: boolean) => req('config:setJsonModeClaudeTabs', enabled), setDefaultClaudeTabType: (value: 'xterm' | 'json') => req('config:setDefaultClaudeTabType', value), setJsonModeChatDensity: (value: 'compact' | 'comfy') => diff --git a/src/renderer/components/InterfaceToggle.tsx b/src/renderer/components/InterfaceToggle.tsx new file mode 100644 index 00000000..eb8d1514 --- /dev/null +++ b/src/renderer/components/InterfaceToggle.tsx @@ -0,0 +1,84 @@ +// Reused in Settings + Onboarding — single source of UI truth for the +// Terminal/Chat choice. The underlying setting value stays `'xterm' | 'json'` +// to keep internal code paths untouched. +import type { JSX } from 'react' +import { Terminal as TerminalIcon, MessageSquare } from 'lucide-react' + +export type ClaudeTabType = 'xterm' | 'json' + +interface InterfaceToggleProps { + value: ClaudeTabType + onChange: (next: ClaudeTabType) => void + /** Rendered inside Settings (wider, descriptions visible) or inside the + * QuestCard (narrower, descriptions trimmed). */ + size?: 'normal' | 'compact' +} + +const OPTIONS: Array<{ + value: ClaudeTabType + label: string + description: string + Icon: typeof TerminalIcon +}> = [ + { + value: 'xterm', + label: 'Terminal', + description: "Claude Code's TUI in a shell tab.", + Icon: TerminalIcon + }, + { + value: 'json', + label: 'Chat', + description: + 'Native interface with inline tool cards, approval flows, and a cost meter.', + Icon: MessageSquare + } +] + +export function InterfaceToggle({ + value, + onChange, + size = 'normal' +}: InterfaceToggleProps): JSX.Element { + return ( +
+ {OPTIONS.map((opt) => { + const selected = value === opt.value + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/components/MobileApp.tsx b/src/renderer/components/MobileApp.tsx index 4cd7af57..2c9f34dd 100644 --- a/src/renderer/components/MobileApp.tsx +++ b/src/renderer/components/MobileApp.tsx @@ -46,7 +46,6 @@ export function MobileApp(): JSX.Element { const panes = usePanes() const terminals = useTerminals() const prs = usePrs() - const settings = useSettings() const snoozeState = useSnooze() const snoozedPaths = useMemo(() => { const m: Record = {} @@ -157,7 +156,7 @@ export function MobileApp(): JSX.Element { pickerOpen={pickerOpen} onTogglePicker={() => setPickerOpen((v) => !v)} onSelectTab={handleSelectTab} - onConvertTabType={settings.jsonModeClaudeTabs ? handleConvertTabType : undefined} + onConvertTabType={handleConvertTabType} rightPanelOpen={rightPanelOpen} onToggleRightPanel={activeWorktree ? () => setRightPanelOpen((v) => !v) : undefined} /> diff --git a/src/renderer/components/QuestCard.tsx b/src/renderer/components/QuestCard.tsx index d144f250..7c643723 100644 --- a/src/renderer/components/QuestCard.tsx +++ b/src/renderer/components/QuestCard.tsx @@ -1,23 +1,80 @@ -import { X, Sparkles, Zap, PartyPopper } from 'lucide-react' +import { X, Sparkles, Zap, PartyPopper, SlidersHorizontal } from 'lucide-react' import type { QuestStep } from '../types' import { HotkeyBadge } from './HotkeyBadge' +import { InterfaceToggle, type ClaudeTabType } from './InterfaceToggle' interface QuestCardProps { step: QuestStep onDismiss: () => void onFinish: () => void + /** Required when `step === 'choose-interface'`. */ + claudeTabType: ClaudeTabType + onSelectClaudeTabType: (next: ClaudeTabType) => void + onAdvanceFromInterface: () => void } -export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.Element | null { +export function QuestCard({ + step, + onDismiss, + onFinish, + claudeTabType, + onSelectClaudeTabType, + onAdvanceFromInterface +}: QuestCardProps): JSX.Element | null { if (step === 'hidden' || step === 'done') return null + if (step === 'choose-interface') { + return ( +
+
+
+
+
+
+ + + Setup + +
+ +
+
+ Pick your Claude interface. +
+
+ You can change this per-tab any time from the tab’s + right-click menu or the switch chip in the header. +
+ + +
+
+
+ ) + } + const content = (() => { if (step === 'spawn-second') { return { icon: , eyebrow: 'Step 1 of 2', - title: 'One agent running. Don\u2019t wait \u2014 spawn another.', - body: 'The whole point of Harness is that you don\u2019t have to sit and watch. While this one works, fork a second worktree and give it a different task.', + title: 'One agent running. Don’t wait — spawn another.', + body: 'The whole point of Harness is that you don’t have to sit and watch. While this one works, fork a second worktree and give it a different task.', hint: ( <> Hit or use the sidebar to create another. @@ -44,7 +101,7 @@ export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.El icon: , eyebrow: 'Harnessed up', title: 'You just ran two agents in parallel.', - body: 'Do it with ten next time. Harness is happiest when it has a lot to juggle \u2014 the status dots keep you honest so nothing slips.', + body: 'Do it with ten next time. Harness is happiest when it has a lot to juggle — the status dots keep you honest so nothing slips.', hint: null } })() diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 2e71ec01..13b2308d 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -9,6 +9,7 @@ import { DEFAULT_HOTKEYS, ACTION_LABELS, bindingToString, eventToBinding, resolv import { Tooltip } from './Tooltip' import { AGENT_REGISTRY, agentDisplayName, CLAUDE_MODELS, CODEX_MODELS } from '../../shared/agent-registry' import { AgentIcon } from './AgentIcon' +import { InterfaceToggle } from './InterfaceToggle' import { BUILT_IN_THEMES_BY_MODE, type ThemeOption } from '../themes' import { SEMANTIC_KEYS } from '../theme-apply' import type { CustomTheme } from '../../shared/state/settings' @@ -197,7 +198,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: wsTransportEnabled, wsTransportPort, wsTransportHost, - jsonModeClaudeTabs, defaultClaudeTabType, jsonModeChatDensity, jsonModeDefaultPermissionMode, @@ -1200,6 +1200,21 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:

New agent tabs will use the selected default. Existing tabs are unaffected.

+ + {defaultAgent === 'claude' && ( +
+ +

+ Which interface new Claude tabs spawn in. Switch any + existing tab from its right-click menu or the chip in + the tab header. +

+ { void backend.setDefaultClaudeTabType(value) }} + /> +
+ )}

@@ -1421,6 +1436,118 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: )} + {/* Chat interface settings — only relevant when running + Chat tabs, but always visible so the controls are findable. */} +
+ +

+ Behavior for Claude tabs running the Chat interface + (inline tool cards, approval flows, cost meter). No + effect on Terminal tabs. +

+ +
+ +
+ New Chat tabs start in this mode. Change per-chat + anytime via the statusline picker. +
+ +
+ +
+ +
+ A Chat tab waiting for your reply for this long + (yellow dot) gets its subprocess torn down to free + RAM. Click the tab to wake — history is intact. Set + to 0 to disable. +
+
+ setAutoSleepDraft(e.target.value)} + onBlur={commitAutoSleepMinutes} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + className="bg-panel border border-border-strong rounded px-2 py-1 text-xs text-fg-bright outline-none focus:border-fg w-24" + /> + minutes +
+
+ +
+ +
+ Larger text and padding for new users or + screen-sharing. +
+
+ + +
+
+
+ {/* ── Codex subsection ── */} @@ -2396,166 +2523,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: - {/* JSON-mode Claude tabs sub-card */} -
-
-

JSON-mode Claude tabs

- - Experimental - -
-

- Adds a second Claude tab type that runs{' '} - claude -p --output-format stream-json{' '} - and renders the conversation in React — native textarea, real - text selection, markdown, syntax highlighting, per-tool cards. - When enabled, -click the - Sparkles button on a worktree's tab bar to spawn one. - Many TUI features are still missing — see{' '} - plans/json-mode-native-chat.md. -

- - - -
- -
- Which mode new Claude tabs spawn in. Per-tab swap is - available from the tab's right-click menu. -
- -
- -
- -
- New json-mode chats start in this mode. Change per-chat - anytime via the statusline picker. -
- -
- -
- -
- A json-mode tab waiting for your reply for this long (yellow - dot) gets its subprocess torn down to free RAM. Click the tab - to wake — history is intact. Set to 0 to disable. -
-
- setAutoSleepDraft(e.target.value)} - onBlur={commitAutoSleepMinutes} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur() - } - }} - disabled={!jsonModeClaudeTabs} - className="bg-panel border border-border-strong rounded px-2 py-1 text-xs text-fg-bright outline-none focus:border-fg w-24 disabled:opacity-40 disabled:cursor-not-allowed" - /> - minutes -
-
- -
- -
- Larger text and padding for new users or screen-sharing. Has - no effect on xterm Claude tabs. -
-
- - -
-
-
- {/* Auto-approve safe tool calls sub-card */}
@@ -2565,7 +2532,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:

- In JSON-mode tabs, spawns a Haiku oneshot to approve obviously-safe tool calls (Read, Grep, Edit, …) instead of prompting you. A hardcoded deny-list catches risky calls (rm -rf, git push, WebFetch, …) before Haiku is consulted. Productivity feature only — an LLM judging another LLM is not a security boundary. Has no effect on xterm Claude tabs. + In Chat tabs, spawns a Haiku oneshot to approve obviously-safe tool calls (Read, Grep, Edit, …) instead of prompting you. A hardcoded deny-list catches risky calls (rm -rf, git push, WebFetch, …) before Haiku is consulted. Productivity feature only — an LLM judging another LLM is not a security boundary. Has no effect on Terminal tabs.

)} + {!loading && !exited && onSwitchToChat && type === 'agent' && agentKind === 'claude' && ( +
+ +
+ )} {exited && type === 'agent' && onRestartAgent && (
From 4d30d08ec3e49c982d91de0760c78ffdaee98bb4 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 11:44:07 -0600 Subject: [PATCH 04/13] refactor: drop "cost meter" from Chat description copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There's no real cost meter UI in Chat — the description was forward- looking and read oddly to users. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/InterfaceToggle.tsx | 2 +- src/renderer/components/Settings.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/InterfaceToggle.tsx b/src/renderer/components/InterfaceToggle.tsx index eb8d1514..102dbef9 100644 --- a/src/renderer/components/InterfaceToggle.tsx +++ b/src/renderer/components/InterfaceToggle.tsx @@ -30,7 +30,7 @@ const OPTIONS: Array<{ value: 'json', label: 'Chat', description: - 'Native interface with inline tool cards, approval flows, and a cost meter.', + 'Native interface with inline tool cards and approval flows.', Icon: MessageSquare } ] diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 13b2308d..646bb3f3 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -1442,8 +1442,8 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }:

Behavior for Claude tabs running the Chat interface - (inline tool cards, approval flows, cost meter). No - effect on Terminal tabs. + (inline tool cards, approval flows). No effect on + Terminal tabs.

From 61384654195e9baf0ed63b2dcd43de65096bc867 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 11:52:37 -0600 Subject: [PATCH 05/13] feat: dismissible Chat promo + Switch back / Set Chat as default on Chat tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Terminal-tab Chat promo chip now ships with an X button that persistently hides the nudge across the workspace. Once dismissed, the user keeps Settings → Agent → Interface and right-click → Switch to Chat as the discovery surfaces. New `settings.chatPromotionDismissed: boolean` slice field (5-file pattern: shared slice + test, persistence, initial-state, IPC, backend method, type). Defaults to false. On Chat tabs, when the user's default is still Terminal (i.e. they transitioned from Terminal → Chat on this tab), a top-left overlay shows two buttons: - "Switch back" — calls `panesConvertTabType('agent')` to revert this tab to a Terminal Claude tab. - "Set Chat as default" — calls `setDefaultClaudeTabType('json')`. Both overlay buttons auto-hide afterwards since the user is now at home in Chat. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/build-initial-state.ts | 1 + src/main/index.ts | 17 +++++++++++++++++ src/main/persistence.ts | 3 +++ src/renderer/build-backend.ts | 2 ++ src/renderer/components/JsonModeChat.tsx | 21 ++++++++++++++++++++- src/renderer/components/XTerminal.tsx | 14 +++++++++++--- src/renderer/types.ts | 1 + src/shared/state/settings.test.ts | 14 ++++++++++++++ src/shared/state/settings.ts | 8 ++++++++ 9 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/main/build-initial-state.ts b/src/main/build-initial-state.ts index 57ef48dd..bd6030a5 100644 --- a/src/main/build-initial-state.ts +++ b/src/main/build-initial-state.ts @@ -89,6 +89,7 @@ export function buildInitialAppState( browserToolsEnabled: config.browserToolsEnabled !== false, browserToolsMode: config.browserToolsMode === 'view' ? 'view' : 'full', defaultClaudeTabType: config.defaultClaudeTabType === 'json' ? 'json' : 'xterm', + chatPromotionDismissed: config.chatPromotionDismissed === true, autoApprovePermissions: config.autoApprovePermissions === true, autoApproveSteerInstructions: config.autoApproveSteerInstructions || '', useSystemClaudeForJsonMode: config.useSystemClaudeForJsonMode === true, diff --git a/src/main/index.ts b/src/main/index.ts index f73ff63f..a5c40ab3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2663,6 +2663,23 @@ function registerIpcHandlers(): void { } ) + transport.onRequest( + 'config:setChatPromotionDismissed', + (_ctx, value: boolean) => { + if (value) { + config.chatPromotionDismissed = true + } else { + delete config.chatPromotionDismissed + } + saveConfig(config) + store.dispatch({ + type: 'settings/chatPromotionDismissedChanged', + payload: value + }) + return true + } + ) + transport.onRequest( 'config:setJsonModeChatDensity', (_ctx, value: 'compact' | 'comfy') => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 364934b1..63cf2d5a 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -176,6 +176,9 @@ export interface Config { // Controls whether new Claude tabs spawn as the terminal-hosted TUI // ('xterm') or the React chat interface ('json'). Default 'xterm'. defaultClaudeTabType?: 'xterm' | 'json' + // True once the user dismisses the "Switch to the new Chat mode" + // overlay shown on Terminal Claude tabs. + chatPromotionDismissed?: boolean // When true, JSON-mode tabs delegate per-tool approval decisions to a // Haiku oneshot for obviously-safe tool calls. Productivity feature // only — not a security boundary. Default off (undefined treated as diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 91bfd949..c26d3560 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -241,6 +241,8 @@ export function buildBackend( setBrowserToolsMode: (mode: 'view' | 'full') => req('config:setBrowserToolsMode', mode), setDefaultClaudeTabType: (value: 'xterm' | 'json') => req('config:setDefaultClaudeTabType', value), + setChatPromotionDismissed: (value: boolean) => + req('config:setChatPromotionDismissed', value), setJsonModeChatDensity: (value: 'compact' | 'comfy') => req('config:setJsonModeChatDensity', value), setJsonModeDefaultPermissionMode: (value: 'default' | 'acceptEdits' | 'plan') => diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index adfdcac5..3173af51 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -856,7 +856,8 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo const backend = useBackend() const session = useJsonClaudeSession(sessionId) const { pending, resolve } = useJsonClaudeApprovals(sessionId) - const density = useSettings().jsonModeChatDensity + const { jsonModeChatDensity: density, defaultClaudeTabType } = useSettings() + const cameFromTerminalDefault = defaultClaudeTabType === 'xterm' const [draft, setDraft] = useState('') // Mention/popover state. `dismissed` carries the draft text at which // the user pressed Escape — comparing against the live draft is how we @@ -1588,6 +1589,24 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
)} + {cameFromTerminalDefault && ( +
+ + +
+ )}
(null) const terminalRef = useRef(null) @@ -849,8 +850,8 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa
)} - {!loading && !exited && onSwitchToChat && type === 'agent' && agentKind === 'claude' && ( -
+ {!loading && !exited && onSwitchToChat && type === 'agent' && agentKind === 'claude' && !chatPromotionDismissed && ( +
+
)} {exited && type === 'agent' && onRestartAgent && ( diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 4c6a7caf..dee8ad0d 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -306,6 +306,7 @@ export interface ElectronAPI { setBrowserToolsEnabled(enabled: boolean): Promise setBrowserToolsMode(mode: 'view' | 'full'): Promise setDefaultClaudeTabType(value: 'xterm' | 'json'): Promise + setChatPromotionDismissed(value: boolean): Promise setJsonModeChatDensity(value: 'compact' | 'comfy'): Promise setJsonModeDefaultPermissionMode( value: 'default' | 'acceptEdits' | 'plan' diff --git a/src/shared/state/settings.test.ts b/src/shared/state/settings.test.ts index b5953d09..900188a8 100644 --- a/src/shared/state/settings.test.ts +++ b/src/shared/state/settings.test.ts @@ -355,6 +355,20 @@ describe('settingsReducer', () => { expect(full.browserToolsMode).toBe('full') }) + it('chatPromotionDismissedChanged toggles the flag', () => { + expect(initialSettings.chatPromotionDismissed).toBe(false) + const on = apply(initialSettings, { + type: 'settings/chatPromotionDismissedChanged', + payload: true + }) + expect(on.chatPromotionDismissed).toBe(true) + const off = apply(on, { + type: 'settings/chatPromotionDismissedChanged', + payload: false + }) + expect(off.chatPromotionDismissed).toBe(false) + }) + it('defaultClaudeTabTypeChanged switches between xterm and json', () => { expect(initialSettings.defaultClaudeTabType).toBe('xterm') const json = apply(initialSettings, { diff --git a/src/shared/state/settings.ts b/src/shared/state/settings.ts index 35ec07ed..9a1720d4 100644 --- a/src/shared/state/settings.ts +++ b/src/shared/state/settings.ts @@ -102,6 +102,10 @@ export interface SettingsState { * ('xterm') or the React chat interface ('json'). Internal values are * unchanged; the user-facing label is "Terminal" / "Chat". */ defaultClaudeTabType: 'xterm' | 'json' + /** True once the user clicks the X on the "Switch to the new Chat + * mode" overlay shown on Terminal Claude tabs. Persistent so the + * promotion stays dismissed across reloads. */ + chatPromotionDismissed: boolean /** When true, JSON-mode tabs run a Haiku oneshot to auto-approve * obviously-safe tool calls instead of prompting the user. Productivity * feature only — an LLM judging another LLM is not a security boundary. @@ -196,6 +200,7 @@ export type SettingsEvent = | { type: 'settings/browserToolsEnabledChanged'; payload: boolean } | { type: 'settings/browserToolsModeChanged'; payload: BrowserToolsMode } | { type: 'settings/defaultClaudeTabTypeChanged'; payload: 'xterm' | 'json' } + | { type: 'settings/chatPromotionDismissedChanged'; payload: boolean } | { type: 'settings/autoApprovePermissionsChanged'; payload: boolean } | { type: 'settings/autoApproveSteerInstructionsChanged'; payload: string } | { type: 'settings/useSystemClaudeForJsonModeChanged'; payload: boolean } @@ -250,6 +255,7 @@ export const initialSettings: SettingsState = { browserToolsEnabled: true, browserToolsMode: 'full', defaultClaudeTabType: 'xterm', + chatPromotionDismissed: false, autoApprovePermissions: false, autoApproveSteerInstructions: '', useSystemClaudeForJsonMode: false, @@ -337,6 +343,8 @@ export function settingsReducer(state: SettingsState, event: SettingsEvent): Set return { ...state, browserToolsMode: event.payload } case 'settings/defaultClaudeTabTypeChanged': return { ...state, defaultClaudeTabType: event.payload } + case 'settings/chatPromotionDismissedChanged': + return { ...state, chatPromotionDismissed: event.payload } case 'settings/autoApprovePermissionsChanged': return { ...state, autoApprovePermissions: event.payload } case 'settings/autoApproveSteerInstructionsChanged': From ff9de2bc01fd2119d313ddf0f51c6ed3ffa0d858 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:01:00 -0600 Subject: [PATCH 06/13] refactor: clearer copy + tooltips on Chat-mode switch overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Switch back" → "Switch back to Terminal mode" - "Set Chat as default" → "Make Chat mode default" - Tooltips on the Switch-to-Chat, X dismiss, and Switch-back buttons point users at the right-click tab menu as a permanent alternative. - Tooltip on the Make-Chat-default button points at Settings → Agent → Interface for reversing the choice later. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/JsonModeChat.tsx | 33 ++++++++++++++---------- src/renderer/components/XTerminal.tsx | 33 ++++++++++++++---------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index 3173af51..6f8b07a9 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -28,6 +28,7 @@ import { useJsonClaudeSession, useSettings } from '../store' import { useBackend } from '../backend' import { useJsonClaudeApprovals } from '../hooks/useJsonClaudeApprovals' import { JsonClaudeApprovalCard } from './JsonClaudeApprovalCard' +import { Tooltip } from './Tooltip' import { dispatchToolCard, ToolCardChrome } from './json-mode-cards' import { ToolGroup } from './json-mode-cards/ToolGroup' import { TaskCard } from './json-mode-cards/TaskCard' @@ -1591,20 +1592,24 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo )} {cameFromTerminalDefault && (
- - + + + + + +
)}
diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 8a9c63ec..1079b6f5 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -9,6 +9,7 @@ import type { StateEvent } from '../../shared/state' import { getClientId, subscribeActiveTransportReconnect, useSettings, useTerminalSession } from '../store' import { getBackend, useBackend } from '../backend' import { Eye, X, Sparkles } from 'lucide-react' +import { Tooltip } from './Tooltip' function ClaudeLoader() { return ( @@ -852,20 +853,24 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa )} {!loading && !exited && onSwitchToChat && type === 'agent' && agentKind === 'claude' && !chatPromotionDismissed && (
- - + + + + + +
)} {exited && type === 'agent' && onRestartAgent && ( From ea7d9d1556b0ae130128f656939d7baaa92eba91 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:02:41 -0600 Subject: [PATCH 07/13] =?UTF-8?q?feat(dev):=20Help=20=E2=86=92=20Debug:=20?= =?UTF-8?q?Reset=20Onboarding=20entry=20in=20dev=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends `app:debugResetOnboarding` from the Help menu (gated on `!app.isPackaged`) and the renderer flips the quest back to 'hidden'. The choose-interface card resurfaces on the next worktree tick. Lets us re-test the onboarding flow without nuking `userData/config.json`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/desktop-shell.ts | 10 +++++++++- src/renderer/App.tsx | 9 +++++++++ src/renderer/build-backend.ts | 2 ++ src/renderer/types.ts | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/desktop-shell.ts b/src/main/desktop-shell.ts index 0f853ed9..57491213 100644 --- a/src/main/desktop-shell.ts +++ b/src/main/desktop-shell.ts @@ -421,7 +421,15 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar { label: 'Debug: Crash Focused Tab', click: () => transport.sendSignal('app:debugCrashFocusedTab') - } + }, + ...(!app.isPackaged + ? [ + { + label: 'Debug: Reset Onboarding', + click: () => transport.sendSignal('app:debugResetOnboarding') + } as const + ] + : []) ] } ] diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 46c82775..671e3f54 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -385,6 +385,15 @@ const setQuestStep = useCallback((next: QuestStep) => { }) }, [activeWorktreeId, panes, activePaneId]) + // Debug: Reset Onboarding (dev-only Help menu entry). Walks the quest + // back to 'hidden' so the choose-interface card reappears after the + // next worktree-add tick. + useEffect(() => { + return backend.onDebugResetOnboarding(() => { + setQuestStep('hidden') + }) + }, [setQuestStep]) + // Trigger a full PR refresh in main. Used by the sidebar refresh button // and after worktree creation/removal. const fetchAllPRStatuses = useCallback(() => { diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c26d3560..9dd8a56f 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -413,6 +413,8 @@ export function buildBackend( onLocalSignal('app:openReportIssue', () => callback()), onDebugCrashFocusedTab: (callback: () => void) => onLocalSignal('app:debugCrashFocusedTab', () => callback()), + onDebugResetOnboarding: (callback: () => void) => + onLocalSignal('app:debugResetOnboarding', () => callback()), onOpenAddBackend: (callback: () => void) => onLocalSignal('app:openAddBackend', () => callback()), diff --git a/src/renderer/types.ts b/src/renderer/types.ts index dee8ad0d..0207c849 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -441,6 +441,7 @@ export interface ElectronAPI { onOpenNewProject(callback: () => void): () => void onOpenReportIssue(callback: () => void): () => void onDebugCrashFocusedTab(callback: () => void): () => void + onDebugResetOnboarding(callback: () => void): () => void onOpenAddBackend(callback: () => void): () => void acceptHooks(): Promise From 3b94866d1bbd1af69fcdf1ad2d4edf5330155b4e Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:05:56 -0600 Subject: [PATCH 08/13] feat(chat): inspirational empty-state hero, vertically centered Big brand-gradient "What are we going to build today?" with a Sparkles-in-a-gradient-circle above it, vertically centered in the scroll area when the chat has no entries / pending approvals / busy indicator. Subtitle "Send a message to get started." in muted text. Hidden the moment the first entry lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/JsonModeChat.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index 6f8b07a9..dc1dea81 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -22,7 +22,8 @@ import { X, Layers, RotateCcw, - ShieldAlert + ShieldAlert, + Sparkles } from 'lucide-react' import { useJsonClaudeSession, useSettings } from '../store' import { useBackend } from '../backend' @@ -1620,6 +1621,25 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo className="flex-1 overflow-y-auto overflow-x-hidden outline-none" style={{ overflowAnchor: 'none' }} > + {entries.length === 0 && orphanApprovals.length === 0 && !busy && ( +
+
+
+
+ +
+
+

+ What are we going to build today? +

+

+ Send a message to get started. +

+
+ )}
{groupedItems.map((g) => g.kind === 'single' ? ( From dcec4121822d378bfa8327aad0feb25d72018dee Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:09:08 -0600 Subject: [PATCH 09/13] refactor: append "mode" to Terminal/Chat references for clarity Bare "Switch to Terminal" / "Switch to Chat" reads ambiguously (Terminal what? Chat what?). Adding "mode" makes the right-click menu, the sparkles button tooltip, and the Settings Interface radio match the overlay copy already in place. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/InterfaceToggle.tsx | 4 ++-- src/renderer/components/MobileApp.tsx | 4 ++-- src/renderer/components/TerminalPanel.tsx | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/InterfaceToggle.tsx b/src/renderer/components/InterfaceToggle.tsx index 102dbef9..be42dd83 100644 --- a/src/renderer/components/InterfaceToggle.tsx +++ b/src/renderer/components/InterfaceToggle.tsx @@ -22,13 +22,13 @@ const OPTIONS: Array<{ }> = [ { value: 'xterm', - label: 'Terminal', + label: 'Terminal mode', description: "Claude Code's TUI in a shell tab.", Icon: TerminalIcon }, { value: 'json', - label: 'Chat', + label: 'Chat mode', description: 'Native interface with inline tool cards and approval flows.', Icon: MessageSquare diff --git a/src/renderer/components/MobileApp.tsx b/src/renderer/components/MobileApp.tsx index 2c9f34dd..e6c30871 100644 --- a/src/renderer/components/MobileApp.tsx +++ b/src/renderer/components/MobileApp.tsx @@ -389,7 +389,7 @@ function TabChip({ tab, active, status, shellActivity, onSelect, onConvertTabTyp onConvertTabType('json-claude') }} > - Switch to Chat + Switch to Chat mode ) : ( )}
diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index c80be7b9..82a78f97 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -261,7 +261,7 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect onConvertTabType('json-claude') }} > - Switch to Chat + Switch to Chat mode )} {onConvertTabType && tab.type === 'json-claude' && ( @@ -273,7 +273,7 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect onConvertTabType('agent') }} > - Switch to Terminal + Switch to Terminal mode )}
@@ -382,8 +382,8 @@ export function TerminalPanel({ : '' const shiftPart = onAddJsonClaudeTab ? chatIsDefault - ? ` · ⇧-click for Terminal` - : ' · ⇧-click for Chat' + ? ` · ⇧-click for Terminal mode` + : ' · ⇧-click for Chat mode' : '' return plain + altPart + shiftPart })()} From 616117224782b4ef18ea54a8e85966df5d97bf1d Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:10:27 -0600 Subject: [PATCH 10/13] fix(chat): empty-state scroll caused by redundant content wrapper The empty-state hero (`min-h-full`) and the regular `px-4 py-3 space-y-3` content wrapper were both rendering when entries were empty. The wrapper added ~24px of padding below the full-height empty state, producing a tiny scrollable overflow. Switch to a ternary so only one of the two trees mounts at a time. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/JsonModeChat.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index dc1dea81..13be123e 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -1621,7 +1621,7 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo className="flex-1 overflow-y-auto overflow-x-hidden outline-none" style={{ overflowAnchor: 'none' }} > - {entries.length === 0 && orphanApprovals.length === 0 && !busy && ( + {entries.length === 0 && orphanApprovals.length === 0 && !busy ? (
- )} + ) : (
{groupedItems.map((g) => g.kind === 'single' ? ( @@ -1685,6 +1685,7 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo ) })()}
+ )}
{showJumpToBottom && ( +
+ )}
diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index 9dd8a56f..b421fbf9 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -413,8 +413,8 @@ export function buildBackend( onLocalSignal('app:openReportIssue', () => callback()), onDebugCrashFocusedTab: (callback: () => void) => onLocalSignal('app:debugCrashFocusedTab', () => callback()), - onDebugResetOnboarding: (callback: () => void) => - onLocalSignal('app:debugResetOnboarding', () => callback()), + onDebugPreviewOnboarding: (callback: () => void) => + onLocalSignal('app:debugPreviewOnboarding', () => callback()), onOpenAddBackend: (callback: () => void) => onLocalSignal('app:openAddBackend', () => callback()), diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 0207c849..f5a0f6ce 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -441,7 +441,7 @@ export interface ElectronAPI { onOpenNewProject(callback: () => void): () => void onOpenReportIssue(callback: () => void): () => void onDebugCrashFocusedTab(callback: () => void): () => void - onDebugResetOnboarding(callback: () => void): () => void + onDebugPreviewOnboarding(callback: () => void): () => void onOpenAddBackend(callback: () => void): () => void acceptHooks(): Promise From 71579207ebd45caedd0ff451afb84f59a3500f31 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:17:53 -0600 Subject: [PATCH 12/13] feat(onboarding): nest Interface picker under Claude in welcome step 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-step welcome form's "Choose your default agent" step now reveals the Terminal/Chat InterfaceToggle right below the Claude button when Claude is the selected default — same nested visual pattern as Settings → Agent. Skipping it leaves the existing 'xterm' default in place. Drops the post-creation QuestCard 'choose-interface' step now that the welcome form covers it. QuestStep union goes back to the original 5 values (hidden → spawn-second → switch-between → finale → done). QuestCard reverts to its pre-PR shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/App.tsx | 20 ++++++--- src/renderer/components/QuestCard.tsx | 61 +-------------------------- src/renderer/types.ts | 8 +--- src/shared/state/onboarding.test.ts | 10 +---- src/shared/state/onboarding.ts | 8 +--- 5 files changed, 19 insertions(+), 88 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2fbb9552..b573cc7a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -26,6 +26,7 @@ import { WeeklyWrappedScreen } from './components/WeeklyWrappedScreen' import { Guide } from './components/Guide' import { AGENT_REGISTRY } from '../shared/agent-registry' import { AgentIcon } from './components/AgentIcon' +import { InterfaceToggle } from './components/InterfaceToggle' import { Activity } from './components/Activity' import { Cleanup } from './components/Cleanup' import { CommandCenter } from './components/CommandCenter' @@ -294,7 +295,7 @@ const setQuestStep = useCallback((next: QuestStep) => { useEffect(() => { if (questStep === 'done' || questStep === 'finale') return if (questStep === 'hidden' && agentWorktreeCount >= 1) { - setQuestStep(agentWorktreeCount >= 2 ? 'switch-between' : 'choose-interface') + setQuestStep(agentWorktreeCount >= 2 ? 'switch-between' : 'spawn-second') return } if (questStep === 'spawn-second' && agentWorktreeCount >= 2) { @@ -947,6 +948,18 @@ const setQuestStep = useCallback((next: QuestStep) => { ))}
+ {agentChosen && defaultAgent === 'claude' && ( +
+
+ Which interface should new Claude tabs use? +
+ { void backend.setDefaultClaudeTabType(value) }} + size="compact" + /> +
+ )}
{ step={questStep} onDismiss={() => setQuestStep('done')} onFinish={() => setQuestStep('done')} - claudeTabType={settings.defaultClaudeTabType} - onSelectClaudeTabType={(value) => { - void backend.setDefaultClaudeTabType(value) - }} - onAdvanceFromInterface={() => setQuestStep('spawn-second')} /> {/* Right panel — hidden on the new-worktree screen so the form gets the full width */} {!showNewWorktree && !showActivity && !showCleanup && !showCommandCenter && !showReview && reportIssueState === null && !rightColumnHidden && ( diff --git a/src/renderer/components/QuestCard.tsx b/src/renderer/components/QuestCard.tsx index 7c643723..ddcf4c33 100644 --- a/src/renderer/components/QuestCard.tsx +++ b/src/renderer/components/QuestCard.tsx @@ -1,73 +1,16 @@ -import { X, Sparkles, Zap, PartyPopper, SlidersHorizontal } from 'lucide-react' +import { X, Sparkles, Zap, PartyPopper } from 'lucide-react' import type { QuestStep } from '../types' import { HotkeyBadge } from './HotkeyBadge' -import { InterfaceToggle, type ClaudeTabType } from './InterfaceToggle' interface QuestCardProps { step: QuestStep onDismiss: () => void onFinish: () => void - /** Required when `step === 'choose-interface'`. */ - claudeTabType: ClaudeTabType - onSelectClaudeTabType: (next: ClaudeTabType) => void - onAdvanceFromInterface: () => void } -export function QuestCard({ - step, - onDismiss, - onFinish, - claudeTabType, - onSelectClaudeTabType, - onAdvanceFromInterface -}: QuestCardProps): JSX.Element | null { +export function QuestCard({ step, onDismiss, onFinish }: QuestCardProps): JSX.Element | null { if (step === 'hidden' || step === 'done') return null - if (step === 'choose-interface') { - return ( -
-
-
-
-
-
- - - Setup - -
- -
-
- Pick your Claude interface. -
-
- You can change this per-tab any time from the tab’s - right-click menu or the switch chip in the header. -
- - -
-
-
- ) - } - const content = (() => { if (step === 'spawn-second') { return { diff --git a/src/renderer/types.ts b/src/renderer/types.ts index f5a0f6ce..1bd22805 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -77,13 +77,7 @@ export interface PersistedPane { activeTabId: string } -export type QuestStep = - | 'hidden' - | 'choose-interface' - | 'spawn-second' - | 'switch-between' - | 'finale' - | 'done' +export type QuestStep = 'hidden' | 'spawn-second' | 'switch-between' | 'finale' | 'done' import type { UpdaterStatus } from '../shared/state/updater' export type { UpdaterStatus } diff --git a/src/shared/state/onboarding.test.ts b/src/shared/state/onboarding.test.ts index db955494..25d67ff1 100644 --- a/src/shared/state/onboarding.test.ts +++ b/src/shared/state/onboarding.test.ts @@ -11,16 +11,8 @@ describe('onboardingReducer', () => { }) it('walks through every quest step', () => { - const steps: Array< - | 'hidden' - | 'choose-interface' - | 'spawn-second' - | 'switch-between' - | 'finale' - | 'done' - > = [ + const steps: Array<'hidden' | 'spawn-second' | 'switch-between' | 'finale' | 'done'> = [ 'hidden', - 'choose-interface', 'spawn-second', 'switch-between', 'finale', diff --git a/src/shared/state/onboarding.ts b/src/shared/state/onboarding.ts index 2e6650a8..61aaa5b1 100644 --- a/src/shared/state/onboarding.ts +++ b/src/shared/state/onboarding.ts @@ -1,10 +1,4 @@ -export type QuestStep = - | 'hidden' - | 'choose-interface' - | 'spawn-second' - | 'switch-between' - | 'finale' - | 'done' +export type QuestStep = 'hidden' | 'spawn-second' | 'switch-between' | 'finale' | 'done' export interface OnboardingState { quest: QuestStep From 90e95ce518028eacaf07c2f794e829de538479b6 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 12:22:07 -0600 Subject: [PATCH 13/13] feat(settings): brand-gradient [New] pill next to Chat mode label Subtle "Setting"-section flourish to draw the eye to the recently- promoted Chat interface inside the Terminal/Chat radio. Uses the same brand-gradient-bg as the Sparkles overlay button and onboarding finale CTA for visual consistency. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/components/InterfaceToggle.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/InterfaceToggle.tsx b/src/renderer/components/InterfaceToggle.tsx index be42dd83..00862f14 100644 --- a/src/renderer/components/InterfaceToggle.tsx +++ b/src/renderer/components/InterfaceToggle.tsx @@ -19,6 +19,7 @@ const OPTIONS: Array<{ label: string description: string Icon: typeof TerminalIcon + badge?: 'new' }> = [ { value: 'xterm', @@ -31,7 +32,8 @@ const OPTIONS: Array<{ label: 'Chat mode', description: 'Native interface with inline tool cards and approval flows.', - Icon: MessageSquare + Icon: MessageSquare, + badge: 'new' } ] @@ -69,8 +71,13 @@ export function InterfaceToggle({ /> - - {opt.label} + + {opt.label} + {opt.badge === 'new' && ( + + New + + )} {opt.description}