diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte new file mode 100644 index 0000000..f2d52f0 --- /dev/null +++ b/src/lib/ChatPane.svelte @@ -0,0 +1,1139 @@ + + + + + + +
+ {#if Object.keys(chat.subagents).length} +
+ {#each Object.entries(chat.subagents) as [path, info] (path)} + {path} · {agentStatus(info.status)} + {/each} +
+ {/if} + + {#if showFind} + + {/if} + +
+
+ +
+ {#if chat.booting && chat.engineState !== 'exited'} +
+ +

{t('shell.spawning')}

+
+ {:else if chat.messages.length === 0 && !chat.busy} +
+

{t('shell.welcomeTip')}

+
+ / {t('shell.hintCommand')} + @ {t('shell.hintRef')} + ⌘K {t('shell.hintPalette')} + {t('shell.hintImage')} +
+
+ {/if} +
+ {#if !atBottom} + + {/if} + +
+ {#if chat.engineState === 'exited'} +
+
+ {t('shell.engineDown')} + +
+
+ {/if} + {#if chat.pendingApproval} +
+ {#key chat.pendingApproval.callId} + + {/key} +
+ {/if} + + {#if chat.rateLimit} + (chat.rateLimit = null)} /> + {/if} + + + + send({ op: 'steer' })} + onPick={pickFiles} + onScreenshot={screenshot} + onRecord={toggleRecord} + onModel={openModelPicker} + onModelSelect={selectRow} + onModelEffort={setEffort} + onModelClose={() => chat.closePicker()} + modelRows={filteredRows} + modelActive={activeModel} + modelTitle={pickerTitle} + modelSearch={showPickerSearch} + {backendLocked} + onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)} + bind:pickerQuery + bind:pickerSelIdx={selIdx} + onEffort={chooseEffort} + onApproval={setApprovalMode} + /> +
+
+ +{#if isActive && chat.trustPrompt} + +{/if} + +{#if isActive && chat.picker && chat.picker.kind !== 'model'} + chat.closePicker()} + onSelect={selectRow} + onEffort={setEffort} + /> +{/if} + +{#if isActive && chat.pendingRewind} + +{/if} + + diff --git a/src/lib/CommandPalette.svelte b/src/lib/CommandPalette.svelte index 628fc9f..9178dd2 100644 --- a/src/lib/CommandPalette.svelte +++ b/src/lib/CommandPalette.svelte @@ -3,7 +3,7 @@ import { Search, Plus, FolderPlus, Cpu, RotateCcw, History, Layers, Gauge, Activity, Stethoscope, GitBranch, GitBranchPlus, Store, Settings as SettingsIcon, - PanelRight, SunMoon, ChevronRight, Wrench, SquareTerminal + PanelLeft, LayoutGrid, SunMoon, ChevronRight, Wrench, SquareTerminal } from 'lucide-svelte'; import type { ChatState } from '$lib/chat.svelte'; import { caps, BACKEND_IDS, BACKEND_LABELS, type BackendCaps, type BackendId } from '$lib/backends'; @@ -14,6 +14,7 @@ chat, hasProject, canNewTask = false, + panelOptions = [], onClose, onRun, onNewSession, @@ -21,7 +22,8 @@ onNewTask, onSettings, onMarket, - onTogglePanel, + onOpenPanel, + onToggleSidebar, onToggleTheme, onSetup, onOpenTui @@ -30,6 +32,8 @@ hasProject: boolean; /** 当前激活项目可以开并行任务(是普通项目而非 worktree)。 */ canNewTask?: boolean; + /** Tool tiles the canvas can open (Git / Terminal / Files / Browser / …). */ + panelOptions?: { key: string; label: string }[]; onClose: () => void; onRun: (cmd: string) => void; onNewSession: () => void; @@ -37,10 +41,12 @@ onNewTask: () => void; onSettings: () => void; onMarket: () => void; - onTogglePanel: () => void; + /** Open (or focus) a tool tile on the canvas mosaic. */ + onOpenPanel: (kind: string) => void; + onToggleSidebar: () => void; onToggleTheme: () => void; onSetup: () => void; - /** Open a native TUI tab (real interactive CLI in a pty) for a backend. */ + /** Open a native TUI tile (real interactive CLI in a pty) for a backend. */ onOpenTui: (backend: BackendId) => void; } = $props(); @@ -85,6 +91,15 @@ { id: 'stats', label: t('shell.cmd.stats'), icon: Activity, keywords: t('shell.cmd.statsKw'), cap: 'slashCommands', run: wrap(() => onRun('/stats')) }, { id: 'doctor', label: t('shell.cmd.doctor'), icon: Stethoscope, keywords: t('shell.cmd.doctorKw'), cap: 'slashCommands', run: wrap(() => onRun('/doctor')) }, { id: 'market', label: t('shell.cmd.market'), icon: Store, keywords: t('shell.cmd.marketKw'), cap: 'skills', run: wrap(onMarket) }, + // Tool tiles on the canvas: Git, Terminal, Files, Browser, Plan, … + ...panelOptions.map((p): Action => ({ + id: `panel-${p.key}`, + label: t('shell.cmd.openPanel', { name: p.label }), + hint: t('shell.cmd.openPanelHint'), + icon: LayoutGrid, + keywords: `${t('shell.cmd.openPanelKw')} ${p.key} ${p.label}`, + run: wrap(() => onOpenPanel(p.key)) + })), ...BACKEND_IDS.map((b): Action => ({ id: `tui-${b}`, label: t('shell.cmd.openTui', { name: BACKEND_LABELS[b] }), @@ -95,7 +110,7 @@ })), { id: 'settings', label: t('shell.cmd.settings'), keys: '⌘,', icon: SettingsIcon, keywords: t('shell.cmd.settingsKw'), run: wrap(onSettings) }, { id: 'setup', label: t('shell.cmd.setup'), hint: t('shell.cmd.setupHint'), icon: Wrench, keywords: t('shell.cmd.setupKw'), run: wrap(onSetup) }, - { id: 'panel', label: t('shell.cmd.panel'), keys: '⌘B', icon: PanelRight, keywords: t('shell.cmd.panelKw'), run: wrap(onTogglePanel) }, + { id: 'sidebar', label: t('shell.cmd.sidebar'), keys: '⌘B', icon: PanelLeft, keywords: t('shell.cmd.sidebarKw'), run: wrap(onToggleSidebar) }, { id: 'theme', label: t('shell.cmd.theme'), icon: SunMoon, keywords: t('shell.cmd.themeKw'), run: wrap(onToggleTheme) } ]; const known = new Set(['/model', '/rewind', '/undo', '/resume', '/tree', '/compact', '/context', '/stats', '/doctor', '/new']); diff --git a/src/lib/RightDock.svelte b/src/lib/RightDock.svelte deleted file mode 100644 index 896301d..0000000 --- a/src/lib/RightDock.svelte +++ /dev/null @@ -1,561 +0,0 @@ - - -{#snippet panelBody(kind: string)} - {@const tui = tuiBackendOf(kind)} - {#if kind === 'plan'} - {:else if kind === 'goal'} - {:else if kind === 'changes'} - {:else if kind === 'turns'} - {:else if kind === 'files'} - {:else if kind === 'git'} - {:else if kind === 'term'} - {:else if kind === 'browser'} - {:else if kind === 'diag'} - {:else if tui}{/if} -{/snippet} - -{#if prefs.mosaic} -
- ({ key: p.key, label: labelOf(p.key) }))} - onAdd={mosaicAdd} - emptyText={t('dock.dock.empty')} - > - {#snippet panel(tab)} - {@render panelBody(tab.panel)} - {/snippet} - -
-{:else} -
-
-
- {#each visibleTabs as tab (tab.id)} - - {/each} -
-
- (addOpen = !addOpen)} label="add panel"> - {#if addOpen} - -
- {#each [...PANELS, ...TUI_OPTIONS] as p (p.key)} - - {/each} -
- {/if} -
-
- -
- {#each visibleTabs as tab (tab.id)} -
- {@render panelBody(tab.panel)} -
- {/each} - {#if visibleTabs.length === 0} -
-

{t('dock.dock.empty')}

- {t('dock.dock.hint')} + -
- {#each PANELS as p (p.key)} - - {/each} -
-
- {/if} -
-
-{/if} - - diff --git a/src/lib/Settings.svelte b/src/lib/Settings.svelte index 52b98cf..e313548 100644 --- a/src/lib/Settings.svelte +++ b/src/lib/Settings.svelte @@ -551,19 +551,6 @@ {/if} -
-
{t('settings.behavior.mosaic')}
- prefs.setMosaic(v === 'on')} - /> -

{t('settings.behavior.mosaicHint')}

-
- diff --git a/src/lib/backends/acp.ts b/src/lib/backends/acp.ts index 428e36a..d0079fc 100644 --- a/src/lib/backends/acp.ts +++ b/src/lib/backends/acp.ts @@ -63,7 +63,7 @@ export const ACP_CAPS: BackendCaps = { steer: false, // no mid-turn injection; queued messages run as the next turn interrupt: true, // session/cancel (core protocol, all agents) branchTree: false, - goals: false, // plan updates still render (RightDock shows plan when present) + goals: false, // plan updates still render (the plan tile shows them when present) skills: false, mcpManage: false, checkpoints: false, diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index 0b64972..aa18fec 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -1,5 +1,6 @@ -// Right-dock area: RightDock, GitPanel, ChangesPanel, FilesPanel, PlanPanel, -// GoalPanel, TerminalPanel. Populated during the dock-area i18n migration. +// Tool-panel area: GitPanel, ChangesPanel, FilesPanel, PlanPanel, GoalPanel, +// TerminalPanel and the Mosaic chrome. Populated during the dock-area i18n +// migration; the keys keep their historical names. const dock = { zh: { tabs: { diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 61d679b..5c61890 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -150,10 +150,6 @@ const settings = { vibrancyOn: '开启', vibrancyOff: '关闭', vibrancyHint: '让侧栏呈现原生毛玻璃质感,透出桌面背景。', - mosaic: '平铺面板(试验中)', - mosaicOn: '开启', - mosaicOff: '关闭', - mosaicHint: '右侧面板可拆分平铺:拖动标签到面板边缘分屏,双击标签栏最大化。布局随工作区保存。', reasoningEffort: '思考强度', compaction: '上下文压缩', compactionThreshold: '压缩阈值', @@ -451,10 +447,6 @@ const settings = { vibrancyOn: 'On', vibrancyOff: 'Off', vibrancyHint: 'Give the sidebar a native frosted-glass look that shows the desktop behind it.', - mosaic: 'Tiled panels (experimental)', - mosaicOn: 'On', - mosaicOff: 'Off', - mosaicHint: 'Split the right dock into tiles: drag a tab to a panel edge to split, double-click a tab bar to maximize. The arrangement is saved with the workspace.', notConfigured: 'Not configured', reasoningEffort: 'Reasoning effort', compaction: 'Context compaction', diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index 8133de3..7e96d56 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -35,9 +35,9 @@ const shell = { noOptions: '暂无可选项', pickerFoot: '↑↓ 选择 · Enter 确认 · Esc 关闭', - // header / panel - togglePanel: '侧边面板 · ⌘B', - toggleSidebar: '会话列表', + // header / navigator + toggleSidebar: '会话列表 · ⌘B', + chatGone: '该对话已关闭', // workspaces workspace: { @@ -192,12 +192,15 @@ const shell = { setup: '安装向导 / 环境检查', setupHint: 'git、登录', setupKw: 'setup wizard env git login 安装 向导 环境 登录', - panel: '切换右侧面板', - panelKw: 'panel dock 面板', + openPanel: '打开面板:{name}', + openPanelHint: '在画布上平铺', + openPanelKw: 'panel tile open 面板 平铺 打开', + sidebar: '切换会话列表', + sidebarKw: 'sidebar sessions navigator 侧边栏 会话 列表', theme: '切换主题', themeKw: 'theme dark light 主题', openTui: '打开 TUI:{name}', - openTuiHint: '在面板中运行交互式命令行(独立会话)', + openTuiHint: '在画布上运行交互式命令行(独立会话)', openTuiKw: 'tui terminal cli 终端 命令行' }, @@ -263,9 +266,9 @@ const shell = { noOptions: 'No options available', pickerFoot: '↑↓ Navigate · Enter Select · Esc Close', - // header / panel - togglePanel: 'Side panel · ⌘B', - toggleSidebar: 'Session list', + // header / navigator + toggleSidebar: 'Session list · ⌘B', + chatGone: 'This conversation is closed', // workspaces workspace: { @@ -420,12 +423,15 @@ const shell = { setup: 'Setup wizard / env check', setupHint: 'git, sign-in', setupKw: 'setup wizard env git login', - panel: 'Toggle right panel', - panelKw: 'panel dock', + openPanel: 'Open panel: {name}', + openPanelHint: 'as a tile on the canvas', + openPanelKw: 'panel tile open', + sidebar: 'Toggle session list', + sidebarKw: 'sidebar sessions navigator', theme: 'Toggle theme', themeKw: 'theme dark light', openTui: 'Open TUI: {name}', - openTuiHint: 'Run the interactive CLI in a panel (own session)', + openTuiHint: 'Run the interactive CLI on the canvas (own session)', openTuiKw: 'tui terminal cli' }, diff --git a/src/lib/prefs.svelte.ts b/src/lib/prefs.svelte.ts index 5fd8239..7f7b7d0 100644 --- a/src/lib/prefs.svelte.ts +++ b/src/lib/prefs.svelte.ts @@ -11,12 +11,9 @@ type PrefsShape = { * effect layer is always present but stays invisible unless this opts the CSS * in (the root `data-vibrancy` flag), so toggling needs no window round-trip. */ sidebarVibrancy: boolean; - /** Feature flag: tiled (mosaic) right dock — splittable/drag-arranged panel - * tiles instead of the single tab stack. Off by default while it bakes. */ - mosaic: boolean; }; -const DEFAULTS: PrefsShape = { htmlOpenInBrowser: true, sidebarVibrancy: true, mosaic: false }; +const DEFAULTS: PrefsShape = { htmlOpenInBrowser: true, sidebarVibrancy: true }; function load(): PrefsShape { try { @@ -50,13 +47,11 @@ export const vibrancySupported = () => class PrefsStore { htmlOpenInBrowser = $state(DEFAULTS.htmlOpenInBrowser); sidebarVibrancy = $state(DEFAULTS.sidebarVibrancy); - mosaic = $state(DEFAULTS.mosaic); init() { const p = load(); this.htmlOpenInBrowser = p.htmlOpenInBrowser; this.sidebarVibrancy = p.sidebarVibrancy; - this.mosaic = p.mosaic; this.#applyVibrancy(); } @@ -66,8 +61,7 @@ class PrefsStore { KEY, JSON.stringify({ htmlOpenInBrowser: this.htmlOpenInBrowser, - sidebarVibrancy: this.sidebarVibrancy, - mosaic: this.mosaic + sidebarVibrancy: this.sidebarVibrancy }) ); } catch { @@ -95,11 +89,6 @@ class PrefsStore { this.#applyVibrancy(); this.#save(); } - - setMosaic(v: boolean) { - this.mosaic = v; - this.#save(); - } } export const prefs = new PrefsStore(); diff --git a/src/lib/tuiOpen.svelte.ts b/src/lib/tuiOpen.svelte.ts deleted file mode 100644 index 29a826c..0000000 --- a/src/lib/tuiOpen.svelte.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Cross-component signal: "open a native TUI tab for this backend". The -// command palette raises it, the page reveals the right dock, and RightDock -// adds or re-activates the tab — same pattern as browser.openSignal. - -import type { BackendId } from '$lib/backends/types'; - -class TuiOpenSignal { - /** Bumped on every request so effects can react to repeated opens. */ - n = $state(0); - backend: BackendId = 'jucode'; - - open(backend: BackendId) { - this.backend = backend; - this.n++; - } -} - -export const tuiOpen = new TuiOpenSignal(); diff --git a/src/lib/workbench/Mosaic.svelte b/src/lib/workbench/Mosaic.svelte index 4113a03..4c54c4f 100644 --- a/src/lib/workbench/Mosaic.svelte +++ b/src/lib/workbench/Mosaic.svelte @@ -20,7 +20,7 @@ // tab stacks, pointer-drag of tabs between leaves (edge drop = split, with // a translucent landing preview), dblclick on a tab bar to maximize. All // state transforms come from tiles.ts; this component only reports the next - // layout through `onchange`. + // layout through `onchange` (and pointer focus through `onFocus`). let { layout, onchange, @@ -28,7 +28,9 @@ label, addOptions = [], onAdd, - emptyText = '' + emptyText = '', + focused = null, + onFocus }: { layout: TileLayout; onchange: (next: TileLayout) => void; @@ -40,6 +42,10 @@ addOptions?: { key: string; label: string }[]; onAdd?: (leafId: string | null, key: string) => void; emptyText?: string; + /** The focused leaf (owned by the caller; drives engine-action targeting). */ + focused?: string | null; + /** Pointer went down inside a leaf — report it as the focused one. */ + onFocus?: (leafId: string) => void; } = $props(); let rootEl = $state(null); @@ -53,9 +59,10 @@ const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), Math.max(min, max)); // Esc: cancel an in-flight drag, close the add menu, or restore a - // maximized leaf — in that priority order. + // maximized leaf — in that priority order. A pane that already claimed + // Escape (e.g. a chat pane closing its picker) wins. function windowKeydown(e: KeyboardEvent) { - if (e.key !== 'Escape') return; + if (e.key !== 'Escape' || e.defaultPrevented) return; if (drag?.live) { drag = null; hover = null; @@ -190,11 +197,14 @@ {/snippet} {#snippet leafView(leaf: LeafNode)} +
onFocus?.(leaf.id)} >
barDblClick(e, leaf)} role="tablist" tabindex="-1">
@@ -359,6 +369,11 @@ .leaf.droptarget { box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--accent) 65%, transparent); } + /* Focused leaf: the tab bar's hairline warms up — engine actions (model, + approvals, palette commands) target this pane's chat session. */ + .leaf.focus .lbar { + border-bottom-color: color-mix(in oklab, var(--accent) 40%, var(--hairline)); + } .lbar { position: relative; display: flex; diff --git a/src/lib/workbench/canvas.test.ts b/src/lib/workbench/canvas.test.ts new file mode 100644 index 0000000..98fe48d --- /dev/null +++ b/src/lib/workbench/canvas.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { + chatPanel, + chatSessionOf, + chatSessionsIn, + chatTab, + openChatTab, + reconcileLayout, + CHAT_SEED_RATIO +} from './canvas'; +import { + activateTab, + leavesOf, + serializeLayout, + singleLeafLayout, + splitLeaf, + toggleMaximize, + type LeafNode, + type SplitNode, + type TileTab +} from './tiles'; + +const panelTab = (id: string, panel = id): TileTab => ({ id, panel }); + +/** A persisted dock-only layout: [plan, git] | [term] — no chat tiles. */ +function dockOnlyLayout() { + const base = singleLeafLayout([panelTab('t1', 'plan'), panelTab('t2', 'git')], 't2'); + const first = leavesOf(base.root)[0]; + return splitLeaf(base, first.id, 'right', panelTab('t3', 'term')).layout; +} + +describe('chat tab naming', () => { + it('round-trips a session id through the panel kind', () => { + expect(chatSessionOf(chatPanel('s1-abc'))).toBe('s1-abc'); + }); + + it('returns null for tool panels, tui tabs and a bare prefix', () => { + expect(chatSessionOf('git')).toBeNull(); + expect(chatSessionOf('tui:codex')).toBeNull(); + expect(chatSessionOf('chat:')).toBeNull(); + }); + + it('uses the panel kind as the tab id (one tile per session)', () => { + expect(chatTab('s9')).toEqual({ id: 'chat:s9', panel: 'chat:s9' }); + }); +}); + +describe('openChatTab', () => { + it('opens the chat tile in the requested leaf and activates it', () => { + const layout = dockOnlyLayout(); + const target = leavesOf(layout.root)[1]; + const next = openChatTab(layout, target.id, 's1'); + const leaf = leavesOf(next.root)[1]; + expect(leaf.tabs.map((t) => t.panel)).toEqual(['term', 'chat:s1']); + expect(leaf.active).toBe('chat:s1'); + }); + + it('re-activates an existing tile instead of duplicating it', () => { + const layout = openChatTab(dockOnlyLayout(), null, 's1'); + const other = leavesOf(layout.root)[1]; + const next = openChatTab(activateTab(layout, 't1'), other.id, 's1'); + expect(chatSessionsIn(next)).toEqual(['s1']); + }); +}); + +describe('reconcileLayout', () => { + it('migrates a dock-only layout into the canvas by grafting one chat leaf on the left', () => { + const saved = serializeLayout(dockOnlyLayout()); + const next = reconcileLayout(saved, ['s1'], 's1'); + const root = next.root as SplitNode; + expect(root.kind).toBe('split'); + expect(root.dir).toBe('row'); + expect(root.ratio).toBe(CHAT_SEED_RATIO); + expect((root.a as LeafNode).tabs.map((t) => t.panel)).toEqual(['chat:s1']); + // The old panel arrangement survives untouched on the other side. + const panels = leavesOf(root.b).flatMap((l) => l.tabs.map((t) => t.panel)); + expect(panels).toEqual(['plan', 'git', 'term']); + }); + + it('drops chat tiles for dead sessions and keeps live ones', () => { + const layout = openChatTab(openChatTab(dockOnlyLayout(), null, 'dead'), null, 'live'); + const next = reconcileLayout(serializeLayout(layout), ['live'], 'live'); + expect(chatSessionsIn(next)).toEqual(['live']); + }); + + it('re-seeds one chat leaf when every persisted chat session is dead', () => { + const layout = openChatTab(dockOnlyLayout(), null, 'old-run'); + const next = reconcileLayout(serializeLayout(layout), ['fresh'], 'fresh'); + expect(chatSessionsIn(next)).toEqual(['fresh']); + }); + + it('falls back to a single chat leaf for garbage or absent layouts', () => { + for (const raw of [null, 'junk', { version: 99 }]) { + const next = reconcileLayout(raw, ['s1'], 's1'); + expect(next.root?.kind).toBe('leaf'); + expect((next.root as LeafNode).tabs.map((t) => t.panel)).toEqual(['chat:s1']); + } + }); + + it('yields an empty canvas when there is no session to seed', () => { + expect(reconcileLayout(null, [], null).root).toBeNull(); + }); + + it('keeps a valid layout (chat tile alive) byte-identical, maximize included', () => { + const base = openChatTab(dockOnlyLayout(), null, 's1'); + const maxed = toggleMaximize(base, leavesOf(base.root)[0].id); + const next = reconcileLayout(JSON.parse(JSON.stringify(serializeLayout(maxed))), ['s1'], 's1'); + expect(next).toEqual(maxed); + }); +}); diff --git a/src/lib/workbench/canvas.ts b/src/lib/workbench/canvas.ts new file mode 100644 index 0000000..f497802 --- /dev/null +++ b/src/lib/workbench/canvas.ts @@ -0,0 +1,84 @@ +// Unified canvas tabs: everything right of the navigator is ONE mosaic whose +// tabs are chat sessions (`chat:`), tool panels (plan / git / term +// / …), native TUI tabs (`tui:`) and the audit pane. Pure data — +// layout transforms come from tiles.ts, this module only adds the chat-tab +// naming plus the reconcile/migration step run when a workspace loads. + +import { + closeTab, + deserializeLayout, + emptyLayout, + leavesOf, + openTab, + singleLeafLayout, + wrapRoot, + type TileLayout, + type TileTab +} from './tiles'; + +export const CHAT_PREFIX = 'chat:'; + +/** A chat tile's share of the canvas when grafted beside an old dock layout. */ +export const CHAT_SEED_RATIO = 0.58; + +/** Panel kind (and tab id) of a session's chat tile — one tile per session, + * so the id can be derived from the session id. */ +export function chatPanel(sessionId: string): string { + return `${CHAT_PREFIX}${sessionId}`; +} + +/** The session id a `chat:*` panel kind renders, or null for any other kind. */ +export function chatSessionOf(panel: string): string | null { + return panel.startsWith(CHAT_PREFIX) && panel.length > CHAT_PREFIX.length + ? panel.slice(CHAT_PREFIX.length) + : null; +} + +export function chatTab(sessionId: string): TileTab { + return { id: chatPanel(sessionId), panel: chatPanel(sessionId) }; +} + +/** Session ids of every chat tile in the layout (DFS / visual order). */ +export function chatSessionsIn(layout: TileLayout): string[] { + return leavesOf(layout.root) + .flatMap((l) => l.tabs) + .map((t) => chatSessionOf(t.panel)) + .filter((s): s is string => s !== null); +} + +/** Open (or re-activate) the chat tile for `sessionId`, landing in `leafId`. */ +export function openChatTab( + layout: TileLayout, + leafId: string | null, + sessionId: string +): TileLayout { + return openTab(layout, leafId, chatTab(sessionId)); +} + +/** + * Build the canvas from a persisted layout blob when a workspace loads: + * - chat tiles whose session no longer exists this run are dropped (session + * ids are minted per run, so most restarts land here); + * - a layout left without any chat tile gets one seeded for `seedSessionId` — + * an old dock-only layout keeps its panel arrangement and gains a chat leaf + * on the left (the pre-canvas shape, chat | panels); + * - garbage / absent input falls back to a single chat leaf (or an empty + * canvas when there is no session to seed). + */ +export function reconcileLayout( + raw: unknown, + liveSessionIds: string[], + seedSessionId: string | null +): TileLayout { + let layout = deserializeLayout(raw) ?? emptyLayout(); + const live = new Set(liveSessionIds); + for (const leaf of leavesOf(layout.root)) { + for (const tab of leaf.tabs) { + const sid = chatSessionOf(tab.panel); + if (sid && !live.has(sid)) layout = closeTab(layout, tab.id); + } + } + if (!seedSessionId || chatSessionsIn(layout).length) return layout; + if (!layout.root) return singleLeafLayout([chatTab(seedSessionId)]); + return wrapRoot(layout, 'left', chatTab(seedSessionId), CHAT_SEED_RATIO); +} diff --git a/src/lib/workbench/tiles.test.ts b/src/lib/workbench/tiles.test.ts index 6e87b38..7097dbf 100644 --- a/src/lib/workbench/tiles.test.ts +++ b/src/lib/workbench/tiles.test.ts @@ -14,6 +14,7 @@ import { singleLeafLayout, splitLeaf, toggleMaximize, + wrapRoot, LAYOUT_VERSION, RATIO_MAX, RATIO_MIN, @@ -181,6 +182,34 @@ describe('move', () => { }); }); +describe('wrapRoot', () => { + it('grafts a fresh leaf before the whole tree for left/top', () => { + const { layout } = twoLeaves('right'); + const next = wrapRoot(layout, 'left', tab('z'), 0.6); + const root = next.root as SplitNode; + expect(root.kind).toBe('split'); + expect(root.dir).toBe('row'); + expect(root.ratio).toBe(0.6); + expect((root.a as LeafNode).tabs.map((t) => t.id)).toEqual(['z']); + expect(root.b).toBe(layout.root); // untouched subtree keeps identity + }); + + it('puts the fresh leaf after the tree for right/bottom, ratio still its share', () => { + const layout = singleLeafLayout([tab('a')]); + const next = wrapRoot(layout, 'bottom', tab('z'), 0.3); + const root = next.root as SplitNode; + expect(root.dir).toBe('col'); + expect(root.ratio).toBeCloseTo(0.7); + expect((root.b as LeafNode).tabs.map((t) => t.id)).toEqual(['z']); + }); + + it('turns an empty tree into a single leaf', () => { + const next = wrapRoot(emptyLayout(), 'left', tab('z')); + expect(next.root?.kind).toBe('leaf'); + expect((next.root as LeafNode).tabs.map((t) => t.id)).toEqual(['z']); + }); +}); + describe('resize', () => { it('sets the split ratio', () => { const { layout } = twoLeaves('right'); diff --git a/src/lib/workbench/tiles.ts b/src/lib/workbench/tiles.ts index 75a7d17..ea2252b 100644 --- a/src/lib/workbench/tiles.ts +++ b/src/lib/workbench/tiles.ts @@ -198,6 +198,31 @@ export function splitLeaf( }; } +/** Split the whole tree against a fresh leaf holding `tab` (left/top puts the + * new leaf first); `ratio` is the fresh leaf's share. Used to graft a primary + * pane onto a restored layout (e.g. a chat leaf beside an old dock-only + * arrangement). An empty tree just becomes that single leaf. */ +export function wrapRoot( + layout: TileLayout, + zone: Exclude, + tab: TileTab, + ratio = 0.5 +): TileLayout { + if (!layout.root) return { root: makeLeaf([tab]), maximized: null }; + const fresh = makeLeaf([tab]); + const dir: SplitDir = zone === 'left' || zone === 'right' ? 'row' : 'col'; + const first = zone === 'left' || zone === 'top'; + const split: SplitNode = { + kind: 'split', + id: newId('s'), + dir, + ratio: clampRatio(first ? ratio : 1 - ratio), + a: first ? fresh : layout.root, + b: first ? layout.root : fresh + }; + return { root: split, maximized: layout.maximized }; +} + /** * Move `tabId` onto `targetLeafId`: 'center' joins that stack (or moves the tab * to the stack's end when it is already there), an edge zone splits the target diff --git a/src/lib/workbench/workspaces.ts b/src/lib/workbench/workspaces.ts index 23e7de7..bd0eb62 100644 --- a/src/lib/workbench/workspaces.ts +++ b/src/lib/workbench/workspaces.ts @@ -1,7 +1,8 @@ // Workspace model + on-disk format. A workspace owns a set of projects (the -// existing SavedProject shape, unchanged) plus its dock tile layout; the file -// holds every workspace and which one is active. Pure data + pure transforms — -// the reactive store and the Tauri IO live in workspaceStore.svelte.ts. +// existing SavedProject shape, unchanged) plus its canvas tile layout; the +// file holds every workspace and which one is active. Pure data + pure +// transforms — the reactive store and the Tauri IO live in +// workspaceStore.svelte.ts. import type { SavedProject } from '$lib/session.svelte'; import { serializeLayout, singleLeafLayout, type SerializedLayout, type TileTab } from './tiles'; @@ -15,7 +16,7 @@ export const LEGACY_PROJECTS_KEY = 'jucode-projects'; export const LEGACY_DOCK_TABS_KEY = 'jucode-dock-tabs'; export const LEGACY_DOCK_ACTIVE_KEY = 'jucode-dock-active'; -/** Dock panel kinds a tile tab may reference (RightDock keeps the icons). */ +/** Tool panel kinds a tile tab may reference (besides chat:/tui: tabs). */ export const DOCK_PANELS = ['plan', 'goal', 'changes', 'turns', 'files', 'git', 'term', 'browser', 'diag'] as const; export interface WorkspaceEntry { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b256194..b2cfb8f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,8 @@ - +
- + (store.activeId = id)} onNewProject={addProject} onNewTask={newTask} - onNewSession={(p) => newSessionFlow(p)} + onNewSession={(p) => store.addSession(p)} onCloseSession={(id) => store.removeSession(id)} onCloseProject={removeProject} onArchiveSession={(id) => store.archiveSession(id)} @@ -1286,160 +795,69 @@ /> - -
- {#if chat} -
-
- {chat.title} - {project} -
-
- -
- - {#if Object.keys(chat.subagents).length} -
- {#each Object.entries(chat.subagents) as [path, info] (path)} - {path} · {agentStatus(info.status)} - {/each} -
- {/if} - - {#if showFind} - - {/if} - -
-
- -
- {#if chat.booting && chat.engineState !== 'exited'} -
- -

{t('shell.spawning')}

-
- {:else if chat.messages.length === 0 && !chat.busy} -
-

{t('shell.welcomeTip')}

-
- / {t('shell.hintCommand')} - @ {t('shell.hintRef')} - ⌘K {t('shell.hintPalette')} - {t('shell.hintImage')} -
-
- {/if} -
- {#if !atBottom} - - {/if} - -
- {#if chat.engineState === 'exited'} -
-
- {t('shell.engineDown')} - -
-
- {/if} - {#if chat.pendingApproval} -
- {#key chat.pendingApproval.callId} - - {/key} + +
+
+
+ {chat?.title ?? 'JuCode'} + {#if chat}{project}{/if} +
+
+
+ +
+ {#if store.loaded && projects.length === 0} +
+ JuCode +

{t('shell.noChat')}

+
+ {:else} + + {#snippet panel(tab)} + {@const sid = chatSessionOf(tab.panel)} + {@const tui = tuiBackendOf(tab.panel)} + {#if sid} + {@const sess = sessionMap.get(sid)} + {#if sess} + + {:else} +
{t('shell.chatGone')}
+ {/if} + {:else if tab.panel === 'plan'} + {:else if tab.panel === 'goal'} + {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> + {:else if tab.panel === 'turns'} + {:else if tab.panel === 'files'} + {:else if tab.panel === 'git'} openTaskProject(path, meta)} onTaskRemoved={closeTaskProject} /> + {:else if tab.panel === 'term'} + {:else if tab.panel === 'browser'} + {:else if tab.panel === 'diag'} + {:else if tab.panel === 'audit'} + {:else if tui} { settingsInitial = 'behavior'; showSettings = true; }} /> + {/if} + {/snippet} +
{/if} - - {#if chat.rateLimit} - (chat.rateLimit = null)} /> - {/if} - - - - send({ op: 'steer' })} - onPick={pickFiles} - onScreenshot={screenshot} - onRecord={toggleRecord} - onModel={openModelPicker} - onModelSelect={selectRow} - onModelEffort={setEffort} - onModelClose={() => chat?.closePicker()} - modelRows={filteredRows} - modelActive={activeModel} - modelTitle={pickerTitle} - modelSearch={showPickerSearch} - {backendLocked} - onBackend={(b, acpAgent) => store.switchBackend(activeId, b, acpAgent)} - bind:pickerQuery - bind:pickerSelIdx={selIdx} - onEffort={chooseEffort} - onApproval={setApprovalMode} - /> -
- {:else} -
- JuCode -

{t('shell.noChat')}

- -
- {/if} -
- - - {#if editorStore.visible} - -
- -
- {/if} - - - - +
{#if showSettings} { showSettings = false; showMarket = true; }} onClose={() => { showSettings = false; settingsInitial = 'overview'; loadProviders(); }} /> @@ -1464,54 +882,6 @@ /> {/if} - {#if chat?.trustPrompt} - - {/if} - - {#if chat?.picker && chat.picker.kind !== 'model'} - chat?.closePicker()} - onSelect={selectRow} - onEffort={setEffort} - /> - {/if} - - {#if chat?.pendingRewind} - - {/if} - {#if showQuickOpen && activeProject} {@const qoRoot = activeProject.path} ({ key: k, label: t(`dock.tabs.${k}`) }))} onClose={() => (showPalette = false)} - onRun={(cmd) => nav(cmd)} - onNewSession={() => activeProject && newSessionFlow(activeProject)} + onRun={runCommand} + onNewSession={() => activeProject && store.addSession(activeProject)} onNewProject={addProject} onNewTask={() => newTask(activeProject)} onSettings={() => (showSettings = true)} onMarket={() => (showMarket = true)} - onTogglePanel={toggleRight} + onOpenPanel={openPanelTile} + onToggleSidebar={toggleSidebar} onToggleTheme={cycleTheme} onSetup={() => (showSetup = true)} onOpenTui={openTui} @@ -1587,15 +959,15 @@ } /* Windows/Linux have a native title bar and no traffic lights, so drop the * macOS traffic-light offsets: sit the toggle at the sidebar's content edge, - * vertically level with the chat header's right-hand toggle. */ + * vertically level with the canvas header. */ :global(:root[data-os='windows']) .sb-toggle, :global(:root[data-os='linux']) .sb-toggle { - top: 18px; + top: 8px; left: 14px; } - /* ---------- center ---------- */ - .center { + /* ---------- the canvas ---------- */ + .canvas { flex: 1; display: flex; flex-direction: column; @@ -1603,76 +975,12 @@ background: var(--bg); position: relative; } - .jump { - position: absolute; - left: 50%; - bottom: 132px; /* overridden inline to sit just above the composer */ - transform: translateX(-50%); - width: 34px; - height: 34px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--panel); - border: 1px solid var(--border); - color: var(--text); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); - cursor: pointer; - z-index: 10; - animation: jump-in var(--t-med) var(--ease-spring); - transition: - background var(--t-fast) var(--ease-out), - transform var(--t-fast) var(--ease-spring), - box-shadow var(--t-med) var(--ease-out); - } - /* pop-in variant that keeps the horizontal centering transform */ - @keyframes jump-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(6px) scale(0.9); - } - to { - opacity: 1; - transform: translateX(-50%); - } - } - .jump:hover { - background: var(--surface2); - transform: translateX(-50%) translateY(-1px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.32); - } - .jump:active { - transform: translateX(-50%) scale(0.92); - } - /* Match the composer's outer frame (max-width 880, 18px side padding) so the - approval box lines up flush with the input box. */ - .approval-wrap { - max-width: 880px; - width: 100%; - margin: 0 auto; - padding: 0 18px 10px; - } - .enginedown { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 14px; - background: color-mix(in oklab, var(--err) 10%, var(--panel)); - border: 1px solid color-mix(in oklab, var(--err) 38%, transparent); - border-radius: var(--r-md); - } - .ed-text { - font-size: 13px; - color: var(--err); - font-weight: 500; - } + /* Slim window-drag strip; also names the active session for orientation. */ header { display: flex; align-items: center; - gap: 12px; - padding: 14px 18px; + gap: 10px; + padding: 8px 18px; border-bottom: 1px solid var(--hairline); transition: padding-left var(--t-med) var(--ease-out); } @@ -1688,17 +996,18 @@ } .htitle { display: flex; - flex-direction: column; + align-items: baseline; + gap: 8px; min-width: 0; } .hname { font-family: var(--font-display); font-weight: 700; - font-size: 14px; + font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 240px; + max-width: 280px; } .hcrumb { font-size: 11px; @@ -1708,83 +1017,30 @@ .hspace { flex: 1; } - .hicon { - display: inline-flex; - padding: 7px; - border: none; - border-radius: var(--r-sm); - background: none; - color: var(--dim); - cursor: pointer; - transition: - background var(--t-fast) var(--ease-out), - color var(--t-fast) var(--ease-out), - transform var(--t-fast) var(--ease-spring); - } - .hicon:hover { - background: var(--surface2); - color: var(--text); - } - .hicon:active { - transform: scale(0.9); + .stage { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; } - .hicon.on { - color: var(--accent-bright); + .stage > :global(.mosaic) { + flex: 1; } - - .agents { + .gone { + height: 100%; display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 8px 18px; - border-bottom: 1px solid var(--hairline); - } - .agent { - display: inline-flex; align-items: center; - gap: 6px; - font-family: var(--font-mono); - font-size: 11px; - color: var(--dim); - } - .agent-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent-bright); - animation: pulse 1.2s ease-in-out infinite; + justify-content: center; + font-size: 13px; + color: var(--dim2); } - - main { + .nochat { flex: 1; - overflow-y: auto; - padding: 22px 18px 26px; - display: flex; - flex-direction: column; - gap: 16px; - max-width: 880px; - width: 100%; - margin: 0 auto; - } - .welcome { - margin: auto; display: flex; flex-direction: column; align-items: center; - gap: 10px; - padding: 24px; - text-align: center; - animation: rise var(--t-slow) var(--ease-out) both; - } - .spawn-spin { - display: inline-flex; - color: var(--accent); - animation: spawn-spin 0.8s linear infinite; - } - @keyframes spawn-spin { - to { - transform: rotate(360deg); - } + justify-content: center; + gap: 12px; } .welcome-mark { font-family: var(--font-display); @@ -1799,55 +1055,13 @@ font-size: 14px; color: var(--dim); } - .welcome-hints { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px 16px; - margin-top: 6px; - font-size: 12px; - color: var(--dim2); - } - .welcome-hints span { - display: inline-flex; - align-items: center; - gap: 6px; - } - .welcome-hints kbd { - font-family: var(--font-mono); - font-size: 11px; - color: var(--dim); - background: var(--surface2); - border: 1px solid var(--hairline); - border-radius: 5px; - padding: 1px 6px; - } - .nochat { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - } - /* ---------- editor split ---------- */ - .editor-pane { - flex-shrink: 0; - min-width: 0; - border-left: 1px solid var(--hairline); - overflow: hidden; - } - .editor-pane.resizing { - user-select: none; - } - /* ---------- right ---------- */ + /* ---------- sidebar resizer ---------- */ .resizer { width: 5px; flex-shrink: 0; cursor: col-resize; background: transparent; - margin-left: -3px; z-index: 5; transition: background var(--t-med) var(--ease-out); } @@ -1861,116 +1075,4 @@ .resizer.hidden { display: none; } - .right { - flex-shrink: 0; - min-width: 0; - /* Opaque so the macOS vibrancy behind the transparent webview only shows in - the sidebar, never through the dock (incl. during the width transition). */ - background: var(--bg); - border-left: 1px solid var(--hairline); - overflow: hidden; - transition: width var(--t-med) var(--ease-out); - } - .right.resizing { - transition: none; - } - .right.closed { - border-left: none; - } - .right-inner { - height: 100%; - } - - /* ---------- modals (picker / trust) ---------- */ - .overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 50; - animation: fade var(--t-fast) var(--ease-out); - } - .modal { - width: min(560px, 92vw); - max-height: 76vh; - display: flex; - flex-direction: column; - background: var(--panel); - border: 1px solid var(--border); - border-radius: var(--r-lg); - box-shadow: var(--shadow-modal); - overflow: hidden; - animation: pop-in var(--t-med) var(--ease-spring); - } - .modal-head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 13px 16px; - font-weight: 600; - font-size: 14px; - border-bottom: 1px solid var(--hairline); - } - :global(.prow-check) { - color: var(--accent-bright); - flex-shrink: 0; - } - .modal.trust { - width: min(460px, 92vw); - } - .trust-body { - padding: 16px; - font-size: 14px; - line-height: 1.55; - } - .trust-body p { - margin: 0 0 12px; - } - .trust-path { - display: block; - font-family: var(--font-mono); - font-size: 12px; - color: var(--dim); - background: var(--surface2); - border: 1px solid var(--border); - border-radius: var(--r-sm); - padding: 8px 10px; - word-break: break-all; - } - .trust-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - padding: 12px 16px 16px; - } - .btn { - font-size: 13px; - padding: 8px 14px; - border-radius: var(--r-sm); - border: 1px solid var(--border); - background: var(--surface2); - color: var(--text); - cursor: pointer; - transition: - background var(--t-fast) var(--ease-out), - border-color var(--t-fast) var(--ease-out), - transform var(--t-fast) var(--ease-spring); - } - .btn:hover { - border-color: color-mix(in oklab, var(--accent) 45%, var(--border)); - } - .btn:active { - transform: scale(0.97); - } - .btn.ghost { - color: var(--dim); - } - .btn.primary { - background: var(--accent); - border-color: var(--accent); - color: var(--on-accent); - font-weight: 600; - }