Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:<id>`; 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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/main/build-initial-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ 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',
chatPromotionDismissed: config.chatPromotionDismissed === true,
autoApprovePermissions: config.autoApprovePermissions === true,
autoApproveSteerInstructions: config.autoApproveSteerInstructions || '',
useSystemClaudeForJsonMode: config.useSystemClaudeForJsonMode === true,
Expand Down
10 changes: 9 additions & 1 deletion src/main/desktop-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,15 @@ export function startDesktopShell(deps: DesktopShellStartDeps): DesktopShellStar
{
label: 'Debug: Crash Focused Tab',
click: () => transport.sendSignal('app:debugCrashFocusedTab')
}
},
...(!app.isPackaged
? [
{
label: 'Debug: Preview Onboarding',
click: () => transport.sendSignal('app:debugPreviewOnboarding')
} as const
]
: [])
]
}
]
Expand Down
34 changes: 17 additions & 17 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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') => {
Expand All @@ -2680,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') => {
Expand Down
11 changes: 5 additions & 6 deletions src/main/panes-fsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@ interface PanesFSMOptions {
getRepoRootForWorktree: (worktreePath: string) => string | undefined
getLatestClaudeSessionId: (worktreePath: string) => Promise<string | null>
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,
Expand Down Expand Up @@ -229,7 +228,7 @@ export class PanesFSM {
agentTab = {
id: sessionId,
type: 'json-claude',
label: 'Claude (JSON)',
label: 'Chat',
sessionId,
mode: 'awake',
model
Expand Down Expand Up @@ -418,7 +417,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 }
Expand Down
12 changes: 5 additions & 7 deletions src/main/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,12 @@ 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'
// 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
Expand Down
55 changes: 45 additions & 10 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -234,6 +235,11 @@ function DesktopApp(): JSX.Element {
const [showNewProject, setShowNewProject] = useState(false)
const [reportIssueState, setReportIssueState] = useState<OpenReportIssueDetail | null>(null)
const [showAddBackend, setShowAddBackend] = useState(false)
// Dev-only: forces the welcome / onboarding form to render even when
// the user already has repos added, so the layout can be inspected
// without wiping `userData/config.json`. Toggled from Help → Debug:
// Preview Onboarding (gated `!app.isPackaged`).
const [previewOnboarding, setPreviewOnboarding] = useState(false)
const [crashedTabIds, setCrashedTabIds] = useState<ReadonlySet<string>>(() => new Set())
// `theme` and `defaultAgent` are both seeded at init, so we track
// explicit confirmation separately for the onboarding step checkmarks.
Expand Down Expand Up @@ -385,6 +391,16 @@ const setQuestStep = useCallback((next: QuestStep) => {
})
}, [activeWorktreeId, panes, activePaneId])

// Debug: Preview Onboarding (dev-only Help menu entry). Toggles a
// renderer-local override that forces the welcome / onboarding form
// to render regardless of repo count — handy for inspecting the
// layout without wiping userData.
useEffect(() => {
return backend.onDebugPreviewOnboarding(() => {
setPreviewOnboarding((v) => !v)
})
}, [])

// Trigger a full PR refresh in main. Used by the sidebar refresh button
// and after worktree creation/removal.
const fetchAllPRStatuses = useCallback(() => {
Expand Down Expand Up @@ -732,7 +748,7 @@ const setQuestStep = useCallback((next: QuestStep) => {
) : null


if (repoRoots.length === 0) {
if (repoRoots.length === 0 || previewOnboarding) {
const step1Complete = themeChosen
const step2Complete = agentChosen
const step3Complete = hooksConsent !== 'pending'
Expand All @@ -747,6 +763,19 @@ const setQuestStep = useCallback((next: QuestStep) => {
<HotkeysProvider bindings={resolvedHotkeys}>
<div className="flex h-full flex-col">
<div className="drag-region h-10 shrink-0" />
{previewOnboarding && repoRoots.length > 0 && (
<div className="shrink-0 px-4 py-1.5 bg-warning/10 border-b border-warning/30 text-xs text-fg-bright flex items-center justify-between gap-3">
<span>
<strong className="font-semibold">Preview mode</strong> — viewing onboarding with {repoRoots.length} repo{repoRoots.length === 1 ? '' : 's'} already added.
</span>
<button
onClick={() => setPreviewOnboarding(false)}
className="px-2 py-0.5 rounded bg-panel border border-border hover:border-border-strong cursor-pointer"
>
Exit preview
</button>
</div>
)}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-xl w-full px-6 py-6">
<div className="text-center mb-6">
Expand Down Expand Up @@ -919,6 +948,18 @@ const setQuestStep = useCallback((next: QuestStep) => {
</button>
))}
</div>
{agentChosen && defaultAgent === 'claude' && (
<div className="ml-8 mt-4 pl-4 border-l-2 border-border">
<div className="text-xs text-dim mb-2">
Which interface should new Claude tabs use?
</div>
<InterfaceToggle
value={settings.defaultClaudeTabType}
onChange={(value) => { void backend.setDefaultClaudeTabType(value) }}
size="compact"
/>
</div>
)}
</div>

<div
Expand Down Expand Up @@ -1268,15 +1309,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}
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/build-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,10 @@ 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),
setChatPromotionDismissed: (value: boolean) =>
req('config:setChatPromotionDismissed', value),
setJsonModeChatDensity: (value: 'compact' | 'comfy') =>
req('config:setJsonModeChatDensity', value),
setJsonModeDefaultPermissionMode: (value: 'default' | 'acceptEdits' | 'plan') =>
Expand Down Expand Up @@ -412,6 +413,8 @@ export function buildBackend(
onLocalSignal('app:openReportIssue', () => callback()),
onDebugCrashFocusedTab: (callback: () => void) =>
onLocalSignal('app:debugCrashFocusedTab', () => callback()),
onDebugPreviewOnboarding: (callback: () => void) =>
onLocalSignal('app:debugPreviewOnboarding', () => callback()),
onOpenAddBackend: (callback: () => void) =>
onLocalSignal('app:openAddBackend', () => callback()),

Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/ActivityCosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export function ActivityCosts(): JSX.Element {

{!loading && repos.length === 0 && (
<div className="text-sm text-dim py-12 text-center">
No json-mode sessions in the selected period.
No Chat sessions in the selected period.
</div>
)}

Expand Down
91 changes: 91 additions & 0 deletions src/renderer/components/InterfaceToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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
badge?: 'new'
}> = [
{
value: 'xterm',
label: 'Terminal mode',
description: "Claude Code's TUI in a shell tab.",
Icon: TerminalIcon
},
{
value: 'json',
label: 'Chat mode',
description:
'Native interface with inline tool cards and approval flows.',
Icon: MessageSquare,
badge: 'new'
}
]

export function InterfaceToggle({
value,
onChange,
size = 'normal'
}: InterfaceToggleProps): JSX.Element {
return (
<div className="space-y-2" role="radiogroup" aria-label="Claude interface">
{OPTIONS.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(opt.value)}
className={`w-full flex items-start gap-3 text-left rounded-lg border transition-colors cursor-pointer ${
size === 'compact' ? 'px-3 py-2' : 'px-3 py-2.5'
} ${
selected
? 'border-fg bg-surface text-fg-bright'
: 'border-border bg-panel hover:border-border-strong text-fg'
}`}
>
<span
className={`mt-0.5 w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
selected
? 'border-fg-bright bg-fg-bright/30'
: 'border-border-strong'
}`}
aria-hidden
/>
<opt.Icon size={size === 'compact' ? 13 : 14} className="mt-0.5 shrink-0" />
<span className="flex-1 min-w-0">
<span className={`flex items-center gap-1.5 text-sm font-medium ${selected ? 'text-fg-bright' : 'text-fg'}`}>
<span>{opt.label}</span>
{opt.badge === 'new' && (
<span className="brand-gradient-bg text-white text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full leading-none">
New
</span>
)}
</span>
<span className={`block ${size === 'compact' ? 'text-[11px]' : 'text-xs'} text-dim mt-0.5`}>
{opt.description}
</span>
</span>
</button>
)
})}
</div>
)
}
Loading
Loading