diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index f2d52f0..13c980b 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -30,9 +30,6 @@ import { buildSetApprovalModeOp, needsClaudeYoloRespawn, type ApprovalMode, type ApproveOp } from '$lib/approval'; import { focusTrap } from '$lib/focusTrap'; import { - captureScreenshot, - startScreenRecording, - stopScreenRecording, processVideo, claudeSessions, gitCheckpointCapture, @@ -96,7 +93,6 @@ type PickedRef = WebRef & { id: number }; let webRefs = $state([]); let refSeq = 0; - let recording = $state(false); let scroller = $state(null); let composerEl = $state(null); let composerRef = $state<{ insertToken: (t: string) => void } | undefined>(); @@ -397,35 +393,6 @@ } } - async function screenshot() { - try { - const path = await captureScreenshot(); - if (path) { - attachments.push({ path, image: true }); - composerEl?.focus(); - } - } catch (e) { - await message(String(e), { title: 'JuCode', kind: 'error' }); - } - } - - async function toggleRecord() { - try { - if (!recording) { - await startScreenRecording(); - recording = true; - } else { - recording = false; - const path = await stopScreenRecording(); - await attachVideo(path); - composerEl?.focus(); - } - } catch (e) { - recording = false; - await message(String(e), { title: 'JuCode', kind: 'error' }); - } - } - // Serialize a picked element into model-readable context. Inserted in place of // its inline token on submit. function formatWebRef(r: PickedRef): string { @@ -816,13 +783,10 @@ bind:attachments bind:videos bind:el={composerEl} - {recording} onSubmit={submit} onStop={stop} onSteer={() => send({ op: 'steer' })} onPick={pickFiles} - onScreenshot={screenshot} - onRecord={toggleRecord} onModel={openModelPicker} onModelSelect={selectRow} onModelEffort={setEffort} diff --git a/src/lib/Composer.svelte b/src/lib/Composer.svelte index be50c7f..e025b23 100644 --- a/src/lib/Composer.svelte +++ b/src/lib/Composer.svelte @@ -1,5 +1,5 @@ @@ -209,6 +230,7 @@
barDblClick(e, leaf)} role="tablist" tabindex="-1">
{#each leaf.tabs as tab (tab.id)} + {@const chrome = decorate?.(tab) ?? null}
tabPointerDown(e, tab)} + ondblclick={(e) => onTabRename?.(tab, e)} + oncontextmenu={(e) => onTabContext?.(tab, e)} onkeydown={(e) => e.key === 'Enter' && onchange(activateTab(layout, tab.id))} > - - {label(tab)} + {#if chrome && (chrome.icon || chrome.color)} + + {:else} + + {/if} + {label(tab)} + + + diff --git a/src/lib/workbench/TabGlyph.svelte b/src/lib/workbench/TabGlyph.svelte new file mode 100644 index 0000000..975057a --- /dev/null +++ b/src/lib/workbench/TabGlyph.svelte @@ -0,0 +1,97 @@ + + + + {#if Lucide} + + {:else if icon?.kind === 'slug' && isEmojiSlug(icon.value)} + {icon.value.trim()} + {:else if badge} + {badge} + {:else if icon?.kind === 'svg'} + + {@html icon.markup} + {:else} + + {/if} + + + diff --git a/src/lib/workbench/WorkspaceTabs.svelte b/src/lib/workbench/WorkspaceTabs.svelte new file mode 100644 index 0000000..7e0d651 --- /dev/null +++ b/src/lib/workbench/WorkspaceTabs.svelte @@ -0,0 +1,283 @@ + + +
+
+ {#each workspaces as w (w.id)} + + {/each} + +
+
+
+ +{#if menuFor && menuWs} + onRename(menuWs.id, n)} + onColor={(c) => onChrome(menuWs.id, { color: c })} + onIcon={(i) => onChrome(menuWs.id, { icon: i })} + onDelete={!menuWs.isDefault && workspaces.length > 1 + ? () => { + const id = menuWs.id; + menuFor = null; + onDelete(id); + } + : undefined} + deleteLabel={t('shell.workspace.delete')} + onClose={() => (menuFor = null)} + /> +{/if} + + diff --git a/src/lib/workbench/canvas.test.ts b/src/lib/workbench/canvas.test.ts index 98fe48d..84436a9 100644 --- a/src/lib/workbench/canvas.test.ts +++ b/src/lib/workbench/canvas.test.ts @@ -83,6 +83,16 @@ describe('reconcileLayout', () => { expect(chatSessionsIn(next)).toEqual(['live']); }); + it('keeps a persisted 2-chat split intact when both session ids are live', () => { + // Restore with persisted tab ids re-spawns sessions under the same ids, + // so a workspace switch (or restart) must not collapse the split. + const base = singleLeafLayout([chatTab('live-a')]); + const split = splitLeaf(base, leavesOf(base.root)[0].id, 'right', chatTab('live-b')).layout; + const next = reconcileLayout(serializeLayout(split), ['live-a', 'live-b'], 'live-a'); + expect(next).toEqual(split); + expect(chatSessionsIn(next)).toEqual(['live-a', 'live-b']); + }); + 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'); diff --git a/src/lib/workbench/canvas.ts b/src/lib/workbench/canvas.ts index f497802..b540ba1 100644 --- a/src/lib/workbench/canvas.ts +++ b/src/lib/workbench/canvas.ts @@ -57,8 +57,10 @@ export function openChatTab( /** * 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); + * - chat tiles whose session is not live are dropped — desktop session ids + * are stable across restore when the saved tabs carry `id` (see + * SavedProject.tabs), so only truly missing sessions (and legacy files + * saved without ids) lose their tile; * - 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); diff --git a/src/lib/workbench/tabChrome.test.ts b/src/lib/workbench/tabChrome.test.ts new file mode 100644 index 0000000..0a28252 --- /dev/null +++ b/src/lib/workbench/tabChrome.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { BUILTIN_ICONS, isEmojiSlug, normalizeColor, parseTabIcon, sanitizeSvg } from './tabChrome'; + +const PATH_SVG = ''; + +describe('sanitizeSvg', () => { + it('accepts a simple path svg', () => { + expect(sanitizeSvg(PATH_SVG)).toBe(PATH_SVG); + expect(sanitizeSvg(` ${PATH_SVG} `)).toBe(PATH_SVG); + }); + + it('accepts shapes, groups and a title', () => { + const svg = + 'ok'; + expect(sanitizeSvg(svg)).toBe(svg); + }); + + it('rejects script and other executable elements', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects event handlers and dangerous attribute values', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects url(...) paint servers (external resource references)', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects entity-encoded attribute values (url( bypass)', () => { + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); + + it('rejects non-svg roots, comments and oversized markup', () => { + expect(sanitizeSvg('
hi
')).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + expect(sanitizeSvg(``)).toBeNull(); + expect(sanitizeSvg('')).toBeNull(); + }); +}); + +describe('parseTabIcon', () => { + it('accepts a known builtin id and rejects unknown ones', () => { + expect(parseTabIcon({ kind: 'builtin', id: 'rocket' })).toEqual({ kind: 'builtin', id: 'rocket' }); + expect(parseTabIcon({ kind: 'builtin', id: 'not-an-icon' })).toBeUndefined(); + expect(BUILTIN_ICONS).toContain('rocket'); + }); + + it('trims slugs and caps their length', () => { + expect(parseTabIcon({ kind: 'slug', value: ' 🚀 ' })).toEqual({ kind: 'slug', value: '🚀' }); + expect(parseTabIcon({ kind: 'slug', value: ' ' })).toBeUndefined(); + expect(parseTabIcon({ kind: 'slug', value: 'x'.repeat(33) })).toBeUndefined(); + expect(parseTabIcon({ kind: 'slug', value: 'x'.repeat(32) })).toEqual({ kind: 'slug', value: 'x'.repeat(32) }); + }); + + it('sanitizes svg icons and rejects dirty markup', () => { + expect(parseTabIcon({ kind: 'svg', markup: PATH_SVG })).toEqual({ kind: 'svg', markup: PATH_SVG }); + expect(parseTabIcon({ kind: 'svg', markup: '' })).toBeUndefined(); + }); + + it('rejects garbage shapes', () => { + expect(parseTabIcon(null)).toBeUndefined(); + expect(parseTabIcon('rocket')).toBeUndefined(); + expect(parseTabIcon({ kind: 'nope' })).toBeUndefined(); + }); +}); + +describe('normalizeColor', () => { + it('accepts #rgb and #rrggbb, lowercased', () => { + expect(normalizeColor('#ABC')).toBe('#abc'); + expect(normalizeColor(' #2563eb ')).toBe('#2563eb'); + }); + it('rejects everything else', () => { + expect(normalizeColor('red')).toBeUndefined(); + expect(normalizeColor('#12345')).toBeUndefined(); + expect(normalizeColor('rgb(1,2,3)')).toBeUndefined(); + expect(normalizeColor(42)).toBeUndefined(); + }); +}); + +describe('isEmojiSlug', () => { + it('treats short non-ascii slugs as text', () => { + expect(isEmojiSlug('🚀')).toBe(true); + expect(isEmojiSlug('👩‍💻')).toBe(true); + expect(isEmojiSlug('火')).toBe(true); + }); + it('ascii names and long strings are not emoji', () => { + expect(isEmojiSlug('rocket')).toBe(false); + expect(isEmojiSlug('')).toBe(false); + expect(isEmojiSlug('这是一个很长的中文说明文字啊')).toBe(false); + }); +}); diff --git a/src/lib/workbench/tabChrome.ts b/src/lib/workbench/tabChrome.ts new file mode 100644 index 0000000..c3d4963 --- /dev/null +++ b/src/lib/workbench/tabChrome.ts @@ -0,0 +1,109 @@ +// Tab chrome shared by workspaces and sessions: an optional tag color plus an +// optional icon (builtin lucide id, free-form slug/emoji, or pasted SVG). +// Pure data + node-safe validation — no DOM, so the SVG sanitizer is a strict +// string allowlist that rejects (returns null) instead of stripping. + +export type TabIcon = + | { kind: 'builtin'; id: string } + | { kind: 'slug'; value: string } + | { kind: 'svg'; markup: string }; + +export const BUILTIN_ICONS = [ + 'layers', 'folder', 'code', 'bug', 'rocket', 'terminal', 'globe', + 'star', 'home', 'file', 'git-branch', 'bot', 'sparkles', 'zap', + 'heart', 'bookmark', 'box', 'cpu', 'database', 'message-square', + 'search', 'shield', 'target', 'wrench' +] as const; + +export const TAB_COLORS = [ + '#6d3bd7', '#2563eb', '#0891b2', '#059669', '#ca8a04', + '#ea580c', '#dc2626', '#db2777', '#9333ea', '#64748b' +]; + +const MAX_SLUG = 32; +const MAX_SVG = 8192; + +/** Elements a stored icon SVG may contain (shapes + grouping only). */ +const SVG_TAGS = new Set([ + 'svg', 'g', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'title', 'defs' +]); +/** Presentation attributes kept on those elements. Anything else is dirty. */ +const SVG_ATTRS = new Set([ + 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', + 'stroke-linejoin', 'stroke-dasharray', 'fill-rule', 'clip-rule', 'opacity', + 'fill-opacity', 'stroke-opacity', 'd', 'width', 'height', 'cx', 'cy', 'r', + 'rx', 'ry', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'points', 'transform' +]); + +/** + * Validate user-pasted SVG markup with a string allowlist (no DOM, so it runs + * under vitest's node environment). Returns the trimmed markup when every tag + * and attribute is on the allowlist, null when anything looks dirty — the + * result is stored and later rendered via {@html}, so reject, never repair. + */ +export function sanitizeSvg(markup: string): string | null { + const s = markup.trim(); + if (!s || s.length > MAX_SVG) return null; + if (!/^]/i.test(s) || !/<\/svg>$/i.test(s)) return null; + // Comments, CDATA and processing instructions can smuggle markup past a + // tag-level scan — reject them outright. + if (/ +
-
-
- {chat?.title ?? 'JuCode'} - {#if chat}{project}{/if} -
-
-
+ workspaces.rename(id, name)} + onChrome={(id, chrome) => workspaces.setChrome(id, chrome)} + onDelete={deleteWorkspace} + />
{#if store.loaded && projects.length === 0} @@ -822,6 +889,9 @@ emptyText={t('dock.dock.empty')} focused={focusedLeaf} onFocus={onLeafFocus} + decorate={tileChrome} + onTabContext={openTileChrome} + onTabRename={openTileChrome} > {#snippet panel(tab)} {@const sid = chatSessionOf(tab.panel)} @@ -898,6 +968,20 @@ (taskDialogFor = null)} onCreated={openTaskProject} /> {/if} + {#if sessionChromeFor && chromeSession} + store.renameSession(chromeSession.id, n)} + onColor={(c) => store.setSessionChrome(chromeSession.id, { color: c })} + onIcon={(i) => store.setSessionChrome(chromeSession.id, { icon: i })} + onClose={() => (sessionChromeFor = null)} + /> + {/if} + {#if showPalette}