From a8acbd8861d55e68ebf1fba994a8f7eefe60eb63 Mon Sep 17 00:00:00 2001 From: mrsibe Date: Fri, 25 Sep 2026 20:51:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(workspace):=20organise=20the=20shell?= =?UTF-8?q?=20around=20Library=20=E2=86=92=20Reading/Chat=20=E2=86=92=20No?= =?UTF-8?q?tes=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the existing provenance capabilities reachable as one workflow instead of a set of parallel entries. No new features, no geometry change, no rebrand. - Library → Reader: a source row opens the in-app reader for every format (PDF via PdfSourceReader, everything else via the text fallback) instead of handing PDFs to the OS. "Open in system application" becomes an explicit reader-header action. Source rows are real buttons (Tab + Enter). - Keyboard return: the element that opened the reader is remembered and focus returns to it on Back/Escape; Library-opened readers return to the list; a reader with no origin falls back to the chat composer (FOCUS_CHAT_EVENT). - Zone shortcuts: Cmd/Ctrl+[ and ] now "go to" Library / Notes — revealing a collapsed zone and moving focus into it. Persisted action IDs are unchanged; collapse stays on the panel-header buttons. - Current citation: CitationChip derives its selected state from uiStore.focusedSource via sourceAnchorsEqual; no new selection store. - IA: DESIGN.md gains the normative Information architecture section (three zones, the loop, per-zone states, keyboard path), the stale `selectedDocument ?? focusedDocument` snippet is fixed, and mind map / quiz / Anki are recorded as secondary utilities outside the loop. - Naming: zone labels move to Library / Notes in en-US and zh-CN. - docs/ia-audit.md records the pre-change audit as evidence; DESIGN.md is the contract. Verified: typecheck, 266 tests (4 new for the focus handoff), check:design, eslint clean on changed files, electron-vite build. --- DESIGN.md | 97 ++++++- docs/ia-audit.md | 240 ++++++++++++++++++ .../components/layouts/ResizableLayout.tsx | 60 ++++- .../src/components/notebook/NotePanel.tsx | 2 +- .../src/components/notebook/ProcessPanel.tsx | 39 +-- .../src/components/notebook/SourcePanel.tsx | 114 ++++++--- .../notebook/chat/AnswerSources.tsx | 4 +- .../components/notebook/chat/CitationChip.tsx | 17 +- .../components/notebook/chat/MessageItem.tsx | 19 +- .../src/components/notebook/chat/markdown.css | 9 + .../notebook/source/DocumentList.tsx | 48 ++-- src/renderer/src/hooks/sourceFocusReturn.ts | 54 ++++ .../src/hooks/useSourceAnchorNavigation.ts | 17 +- src/renderer/src/lib/workspaceEvents.ts | 10 + src/renderer/src/locales/en-US/notebook.json | 3 +- src/renderer/src/locales/en-US/shortcuts.json | 4 +- src/renderer/src/locales/en-US/ui.json | 16 +- src/renderer/src/locales/zh-CN/notebook.json | 3 +- src/renderer/src/locales/zh-CN/shortcuts.json | 4 +- src/renderer/src/locales/zh-CN/ui.json | 16 +- test/sourceFocusReturn.test.ts | 66 +++++ 21 files changed, 726 insertions(+), 116 deletions(-) create mode 100644 docs/ia-audit.md create mode 100644 src/renderer/src/hooks/sourceFocusReturn.ts create mode 100644 src/renderer/src/lib/workspaceEvents.ts create mode 100644 test/sourceFocusReturn.test.ts diff --git a/DESIGN.md b/DESIGN.md index b392b86..f7360eb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,8 +10,8 @@ Tailwind class. If you are an agent adding a page or a component, follow the recipes in [Component recipes](#component-recipes) instead of inventing values. If a rule here is wrong or missing, change this file in the same PR as the code. -Non-goals: this document does not define product behaviour, information -architecture, branding (logo, hue of the primary colour) or i18n. +Non-goals: this document does not define product behaviour, branding (logo, hue +of the primary colour) or i18n. ## Design principles @@ -31,6 +31,97 @@ architecture, branding (logo, hue of the primary colour) or i18n. Direction reference: Notion's content feel, Linear's information density, Raycast's desktop chrome (near-flat dark surfaces, hairlines, almost no shadow). +## Information architecture + +Implementation: `components/notebook/NotebookLayout.tsx` and the three panels it +composes. [Workspace shell](#workspace-shell) owns the geometry; this section is +the layer above it — what each zone is for, how it is entered, and how the loop +between them stays continuous. + +### Three zones, one loop + +KnowNote is not a menu of features. It is one loop: + +```text +import → read → annotate / cite → ask → inspect the source → return → excerpt into a note +``` + +| Zone | Surface | Owns | +| ------------------ | --------------------------------------- | ------------------------------------------------------------------------------ | +| **Library** | left panel, list state | what is in the notebook: sources, and the way in | +| **Reading / Chat** | left panel reading state + centre panel | the working surface: the document is the anchor, the conversation is beside it | +| **Notes** | right panel | output that stays linked to its source | + +The **citation is the connective tissue** between the three. A `[n]` in an answer +opens the source at its page/block ([Cross-panel requests](#cross-panel-requests)); +a selection in the reader becomes an excerpt in a note that links back to the same +anchor (`shared/utils/excerpt.ts`). No other surface needs to know how the panels +talk to each other. + +Mind map, quiz and Anki are **secondary utilities**, not part of this loop. They +stay reachable — from the Notes panel and from their own windows — but they are +never promoted into the three zones or into the loop above. This is #65's explicit +non-goal, recorded here so the next IA change does not relitigate it. + +### Library and Reading are one zone, two states + +Reading does **not** get its own column. The left card switches between the source +list and the reader, and the switch is explicit in both directions: + +- A source row opens the reader **in-app, for every format**. PDF renders its + pages (`PdfSourceReader`); every other format uses the text fallback. There is + one entry (`handleSelectDocument`) and one reading contract, so "read from the + Library" and "jump from a citation" cannot drift into two navigation models. +- "Open in system application" is an explicit action in the reader header, never + the meaning of a list click. +- **Back** (Back button or `Escape`) returns to where the reader was opened from. + From a citation or a note excerpt, focus returns to that element; from the + Library list, focus returns to the list; a reader with no origin (deep link, + restored session) hands focus to the chat composer. Returning to the exact + origin is the accessible behaviour — not a hard-coded "Escape focuses the + composer". + +The source row is the primary control of this switch, so it is a real ` - ) + <> + {/* Opening the original in the OS application used to be what a list + click did. Now that the list click reads in-app (#65), this is the + explicit, secondary way to reach the stored file / URL. Only file + and URL sources have something outside the app to open. */} + {(document.type === 'file' || document.type === 'url') && ( + + )} + {selection && ( + + )} + } /> @@ -278,6 +311,9 @@ export default function SourcePanel(): ReactElement { const [modalType, setModalType] = useState(null) const [hasEmbeddingModel, setHasEmbeddingModel] = useState(false) const [selectedDocument, setSelectedDocument] = useState(null) + // The source list is a full-panel view that unmounts while a source is open. + // Focus returns here after “Back” so a keyboard user does not land on . + const listRef = useRef(null) const { documents, @@ -524,27 +560,38 @@ export default function SourcePanel(): ReactElement { } }, []) - // 处理文档点击 + // 处理文档点击(#65)。 + // + // 列表点击一律打开应用内阅读器:PDF 走 `PdfSourceReader`,其余格式走文本回退,两者 + // 都是 #71 的同一个 reading 契约。以前只有 text/note 预览、file/url 交给操作系统, + // 于是「从 Library 读 PDF」和「从 citation 跳 PDF」变成了两套模型;现在两者同一个入口, + // 外部打开退化为阅读器标题栏里的显式动作。 const handleSelectDocument = useCallback( (document: KnowledgeDocument) => { - // 文本和笔记类型可以预览,直接显示预览页面 - if (document.type === 'text' || document.type === 'note') { - setSelectedDocument(document) - // 列表里的选择优先,清掉对话/URL 那边可能还挂着的请求 - closeSourceAnchor() - } else { - // 其他类型(文件、URL)直接打开 - handleOpenSource(document.id) - } + setSelectedDocument(document) + // 列表里的选择优先,清掉对话/URL 那边可能还挂着的请求 + closeSourceAnchor() }, - [handleOpenSource, closeSourceAnchor] + [closeSourceAnchor] ) - // 返回列表 + // 返回列表(#65)。焦点必须落回「打开阅读器的那里」:citation → 那张芯片, + // 笔记摘录 → 编辑器,Library 进来 → 来源列表。只有没有 origin 的 reader + // (deep link / 恢复的 session)才把焦点交给 composer。 const handleBack = useCallback(() => { + const cameFromAnchor = focusedSource !== null setSelectedDocument(null) closeSourceAnchor() - }, [closeSourceAnchor]) + + if (cameFromAnchor) { + if (restoreSourceOriginFocus()) return + window.dispatchEvent(new CustomEvent(FOCUS_CHAT_EVENT)) + return + } + + // 列表在下一帧才重新挂载,所以越过这一帧再 focus。 + requestAnimationFrame(() => listRef.current?.focus()) + }, [focusedSource, closeSourceAnchor]) // 处理弹窗提交 const handleModalSubmit = useCallback( @@ -570,15 +617,20 @@ export default function SourcePanel(): ReactElement { anchor={readerAnchor} onBack={handleBack} onSaveExcerpt={handleSaveExcerpt} + onOpenSource={handleOpenSource} /> ) : ( // 文档列表页面 - <> +
- {t('knowledgeBase')} + {t('library')} } right={ @@ -666,7 +718,7 @@ export default function SourcePanel(): ReactElement { /> )} - +
)} {/* 添加来源弹窗 - 使用 key 强制在 type 变化时重新挂载组件 */} diff --git a/src/renderer/src/components/notebook/chat/AnswerSources.tsx b/src/renderer/src/components/notebook/chat/AnswerSources.tsx index ffda863..0f84728 100644 --- a/src/renderer/src/components/notebook/chat/AnswerSources.tsx +++ b/src/renderer/src/components/notebook/chat/AnswerSources.tsx @@ -8,7 +8,7 @@ interface AnswerSourcesProps { sources: AnswerSource[] /** `null` for a message written before this was recorded — claim nothing. */ retrieval: RetrievalStatus | null - onShowDocument: (documentId: string) => void + onShowDocument: (documentId: string, origin: HTMLElement) => void } /** @@ -71,7 +71,7 @@ export default function AnswerSources({ {/* 删除按钮列 - 固定宽度 */}