From e5185bf6151c670a18bd842e6854d56d4e0db431 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Sun, 9 Aug 2026 10:36:22 +0800 Subject: [PATCH] fix(desktop): refine Author document tabs Co-Authored-By: agentfleet --- desktop/src/i18n/index.ts | 4 + .../src/styles/partials/09-canvas-tail.css | 166 +++++++++++++ desktop/src/surfaces/AuthorSurface.tsx | 234 +++++++++++++----- 3 files changed, 344 insertions(+), 60 deletions(-) diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index d8b21ee9..695ed19e 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1589,6 +1589,8 @@ const en: Dict = { 'read.showDetails': 'Show details', 'author.docTabs': 'Open documents', + 'author.tabsScrollBack': 'Scroll to earlier documents', + 'author.tabsScrollForward': 'Scroll to later documents', 'author.words_one': '{n} word', 'author.words_other': '{n} words', 'author.placeholder': 'Write in Markdown — $math$ and ```code``` supported…', @@ -3711,6 +3713,8 @@ const zh: Dict = { 'read.showDetails': '显示详情', 'author.docTabs': '打开的文档', + 'author.tabsScrollBack': '滚动到较早的文档', + 'author.tabsScrollForward': '滚动到较后的文档', 'author.words': '{n} 词', 'author.placeholder': '用 Markdown 撰写 — 支持 $数学$ 与 ```代码```…', 'author.empty': '暂无可预览内容。', diff --git a/desktop/src/styles/partials/09-canvas-tail.css b/desktop/src/styles/partials/09-canvas-tail.css index 27228508..f17288bf 100644 --- a/desktop/src/styles/partials/09-canvas-tail.css +++ b/desktop/src/styles/partials/09-canvas-tail.css @@ -1006,6 +1006,172 @@ display: flex; flex-direction: column; } +/* Author's document strip is denser than the Reader's mixed-content tabs, but + filenames must remain recognizable. Fixed-width tabs scroll instead of + collapsing into icon/close-button noise; edge controls make that overflow + discoverable without leaving a permanent scrollbar in the workbench chrome. */ +.author-tabs-shell { + flex: 0 0 38px; + min-width: 0; + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.author-tabs-shell .author-doc-tabs { + flex: 1; + min-width: 0; + gap: 3px; + padding: 5px 7px 0; + border-bottom: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-behavior: smooth; + overscroll-behavior-inline: contain; +} +.author-doc-tabs::-webkit-scrollbar { + display: none; +} +.author-doc-tabs .read-tabitem { + position: relative; + flex: 0 0 clamp(148px, 16vw, 208px); + min-width: 0; + height: 32px; + max-width: none; + border-color: transparent; + border-radius: 7px 7px 0 0; + transition: color 120ms var(--ease), background 120ms var(--ease), border-color 120ms var(--ease); +} +.author-doc-tabs .read-tabitem::before { + content: ''; + position: absolute; + z-index: 1; + top: 0; + left: 10px; + right: 10px; + height: 2px; + border-radius: 0 0 2px 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.55); + transition: opacity 120ms var(--ease), transform 120ms var(--ease); +} +.author-doc-tabs .read-tabitem:hover:not(.active) { + color: var(--text); + background: var(--hover); +} +.author-doc-tabs .read-tabitem.active { + background: var(--bg); + border-color: var(--border); +} +.author-doc-tabs .read-tabitem.active::before { + opacity: 1; + transform: scaleX(1); +} +.author-doc-tabs .read-tabitem-label { + flex: 1; + gap: 7px; + min-width: 0; + padding: 0 4px 0 10px; + letter-spacing: -0.005em; +} +.author-doc-tabs .read-tabitem-kind { + color: var(--text-muted); + transition: color 120ms var(--ease); +} +.author-doc-tabs .active .read-tabitem-kind { + color: var(--accent-text); +} +.author-tab-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.author-tab-dirty { + flex: 0 0 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--warn) 13%, transparent); +} +.author-doc-tabs .read-tabitem-badge { + flex: none; + margin-left: 0; + padding: 0 4px; + line-height: 16px; + color: var(--text-secondary); + background: var(--raised); +} +.author-doc-tabs .read-tabitem-agent { + flex: 0 0 20px; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 1px 0 0; + padding: 0; + border-radius: 5px; +} +.author-doc-tabs .read-tabitem-agent:hover { + background: var(--accent-strong); +} +.author-doc-tabs .read-tabitem-x { + flex: 0 0 22px; + width: 22px; + height: 22px; + margin-right: 4px; + padding: 0; + border-radius: 5px; + opacity: 0; + transition: opacity 120ms var(--ease), color 120ms var(--ease), background 120ms var(--ease); +} +.author-doc-tabs .read-tabitem:hover .read-tabitem-x, +.author-doc-tabs .read-tabitem.active .read-tabitem-x, +.author-doc-tabs .read-tabitem-x:focus-visible, +.author-doc-tabs .read-tabitem-x.danger { + opacity: 0.72; +} +.author-doc-tabs .read-tabitem-x:hover { + color: var(--text); + background: var(--hover); + opacity: 1; +} +.author-doc-tabs .read-tabitem-x.danger { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} +.author-tab-scroll { + flex: 0 0 28px; + width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--text-secondary); + background: var(--surface); + border: 0; + cursor: pointer; +} +.author-tab-scroll.before { + border-right: 1px solid var(--border); +} +.author-tab-scroll.after { + border-left: 1px solid var(--border); +} +.author-tab-scroll:hover:not(:disabled) { + color: var(--text); + background: var(--hover); +} +.author-tab-scroll:disabled { + color: var(--text-muted); + opacity: 0.3; + cursor: default; +} .author-nav-col { flex: none; min-height: 0; diff --git a/desktop/src/surfaces/AuthorSurface.tsx b/desktop/src/surfaces/AuthorSurface.tsx index 2b8beea2..204d512b 100644 --- a/desktop/src/surfaces/AuthorSurface.tsx +++ b/desktop/src/surfaces/AuthorSurface.tsx @@ -316,10 +316,83 @@ export function AuthorSurface(): JSX.Element { // The categorized "New ▾" dropdown — one menu for every document kind (Write / // Data / Draw / Figure), the figure rows driven by the `FIGURES` registry. const [newMenu, setNewMenu] = useState(false); + const tabStripRef = useRef(null); + const [tabOverflow, setTabOverflow] = useState({ overflowing: false, before: false, after: false }); const active = docs.find((d) => d.id === activeId); const tauri = isShell(); + // Document tabs keep a useful, stable width instead of shrinking into a row + // of indistinguishable file icons. Track the scroll edges so compact previous / + // next controls appear only when the strip genuinely overflows. + const measureTabOverflow = useCallback((): void => { + const el = tabStripRef.current; + if (el === null) return; + const max = Math.max(0, el.scrollWidth - el.clientWidth); + // `scrollIntoView({ inline: 'nearest' })` may stop at the strip's 7px + // inline padding rather than literal zero; treat that optical gutter as an + // edge so a control never looks actionable when every tab is already shown. + const edgeSlack = 8; + const next = { + overflowing: max > 1, + before: el.scrollLeft > edgeSlack, + after: el.scrollLeft < max - edgeSlack, + }; + setTabOverflow((prev) => + prev.overflowing === next.overflowing && prev.before === next.before && prev.after === next.after ? prev : next, + ); + }, []); + + useEffect(() => { + const el = tabStripRef.current; + if (el === null) return; + const frame = window.requestAnimationFrame(measureTabOverflow); + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measureTabOverflow) : null; + observer?.observe(el); + return () => { + window.cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, [docs.length, measureTabOverflow]); + + // Opening or selecting a document should never leave its tab hidden beyond + // an overflow edge. `nearest` moves the strip only as far as necessary. + useEffect(() => { + const el = tabStripRef.current; + if (el === null || activeId === null) return; + const frame = window.requestAnimationFrame(() => { + const tab = Array.from(el.children).find((child) => (child as HTMLElement).dataset.docId === activeId); + tab?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + measureTabOverflow(); + }); + return () => window.cancelAnimationFrame(frame); + }, [activeId, docs.length, measureTabOverflow]); + + function scrollTabs(direction: -1 | 1): void { + const el = tabStripRef.current; + if (el === null) return; + el.scrollBy({ left: direction * Math.max(180, el.clientWidth * 0.72), behavior: 'smooth' }); + } + + function moveTabFocus(e: React.KeyboardEvent): void { + const target = e.target as HTMLElement; + if (target.getAttribute('role') !== 'tab' || docs.length === 0) return; + const current = docs.findIndex((d) => d.id === activeId); + let next = current < 0 ? 0 : current; + if (e.key === 'ArrowRight') next = (next + 1) % docs.length; + else if (e.key === 'ArrowLeft') next = (next - 1 + docs.length) % docs.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = docs.length - 1; + else return; + e.preventDefault(); + setActive(docs[next].id); + window.requestAnimationFrame(() => { + const strip = tabStripRef.current; + const tab = strip === null ? undefined : Array.from(strip.children).find((child) => (child as HTMLElement).dataset.docId === docs[next].id); + tab?.querySelector('[role="tab"]')?.focus(); + }); + } + // The dock companion's context provider (the unified assistant dock, D2.2 — // state/companionContext.ts): the active document. Register on mount + // active-document change, unregister on unmount / no active document (the @@ -641,70 +714,111 @@ export function AuthorSurface(): JSX.Element { )}
{docs.length > 0 && ( -
- {docs.map((d) => { - const draft = d.filePath === undefined; - const openTab = (e: { clientX: number; clientY: number; preventDefault: () => void }): void => { - const items: MenuItem[] = [{ label: t('author.navRename'), onClick: () => void renameDoc(d.id) }]; - if (draft && folder !== null) { - items.push({ label: t('author.navSaveToWorkspace'), onClick: () => void saveDraftToWorkspace(d.id) }); - } - if (!draft && tauri) { - const p = d.filePath as string; - items.push({ label: t('author.fReveal'), onClick: () => revealPath(p) }); - } - items.push({ label: t('author.navClose'), danger: true, onClick: () => closeTab(d.id) }); - openTabMenu(e, items); - }; - return ( - - + )} +
+ {docs.map((d) => { + const draft = d.filePath === undefined; + const title = d.title !== '' ? d.title : t('author.untitled'); + const edit = latestAgentEdit(agentEdits, d.id); + const editTitle = edit === undefined ? '' : agentEditTitle(edit, agentEditCount(agentEdits, d.id), t); + const openTab = (e: { clientX: number; clientY: number; preventDefault: () => void }): void => { + const items: MenuItem[] = [{ label: t('author.navRename'), onClick: () => void renameDoc(d.id) }]; + if (draft && folder !== null) { + items.push({ label: t('author.navSaveToWorkspace'), onClick: () => void saveDraftToWorkspace(d.id) }); + } + if (!draft && tauri) { + const p = d.filePath as string; + items.push({ label: t('author.fReveal'), onClick: () => revealPath(p) }); + } + items.push({ label: t('author.navClose'), danger: true, onClick: () => closeTab(d.id) }); + openTabMenu(e, items); + }; + return ( + - - {d.dirty === true ? '● ' : ''} - {d.title !== '' ? d.title : t('author.untitled')} - {draft && {t('author.navDraft')}} - - {/* B6: attribution + one-click revert. Outside the tab button - (a button cannot nest one) so it is reachable without - switching to the tab — reverting a write on a document you - are not looking at is the common case when an agent works - across several. */} - {latestAgentEdit(agentEdits, d.id) !== undefined && ( - )} - - - ); - })} + {/* B6: attribution + one-click revert. Outside the tab button + (a button cannot nest one) so it is reachable without + switching to the tab — reverting a write on a document you + are not looking at is the common case when an agent works + across several. */} + {edit !== undefined && ( + + )} + + + ); + })} +
+ {tabOverflow.overflowing && ( + + )}
)} {active !== undefined ? (