@@ -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}
-
-
- {#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 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}
-
-
-
{t('shell.trustQuestion')}
-
-
{t('shell.trustBody')}
-
{chat.trustPrompt.repoRoot ?? chat.trustPrompt.cwd}
-
-
-
- {#if chat.trustPrompt.repoRoot}{/if}
-
-
-
-
- {/if}
-
- {#if chat?.picker && chat.picker.kind !== 'model'}
- chat?.closePicker()}
- onSelect={selectRow}
- onEffort={setEffort}
- />
- {/if}
-
- {#if chat?.pendingRewind}
- e.target === e.currentTarget && chat && (chat.pendingRewind = null)}>
-
-
{t('shell.rewindQuestion')}
-
-
{@html t('shell.rewindBody')}
-
{chat.pendingRewind.text.slice(0, 120)}
-
-
-
-
-
-
-
- {/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;
- }