Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,13 @@ the citation chip, not the row.
- **Open a citation:** `Tab` to the chip, then `Enter` / `Space`. Chips are real
buttons, so this is the platform behaviour, not a custom keybinding.
- **Return:** `Escape` (or Back) returns focus to the chip that opened the reader.
- **Move between the zones:** `Cmd/Ctrl+[` reveals Library (if collapsed) and
moves focus into it; `Cmd/Ctrl+]` does the same for Notes. The persisted action
IDs (`toggle_knowledge_base` / `toggle_creative_space`) are unchanged, but the
behaviour is “go to the zone”, not “show the panel” — this is the “move between
zones” path. Collapsing a side stays on its panel-header button. The centre
composer is reached through `FOCUS_CHAT_EVENT` (the reader's back fallback).
- **Toggle side panels:** `Cmd/Ctrl+[` expands/collapses Library and
`Cmd/Ctrl+]` expands/collapses Notes, exactly like the panel-header buttons.
Reopening restores the remembered width. Citation navigation is separate:
it only reveals Library, never collapses it. The centre composer is reached
through `FOCUS_CHAT_EVENT` (the reader's back fallback).
- **Close notebook:** `Cmd/Ctrl+D`, with the unsaved-note guard. Existing
Escape and Cmd/Ctrl+W defaults migrate to this binding; other custom bindings stay.

The chip for the citation the reader is currently showing carries `aria-current`
and a selected fill. That state is **derived** from `uiStore.focusedSource`
Expand Down Expand Up @@ -418,7 +419,7 @@ Two rules for the composer and for leaving the workspace:
`pinToBottom` handle and `handleSend` calls it, so the follow state cannot hide
the message the user just wrote.
- **Leaving the workspace asks first when the note editor is dirty.** Closing a
tab, switching tabs, the Home tab and `Cmd+W` all unmount it. `NotePanel`
tab, switching tabs, the Home tab and `Cmd/Ctrl+D` all unmount it. `NotePanel`
publishes its dirty state to `uiStore` (only it knows), and `NotebookLayout`
owns the single `UnsavedChangesDialog` that every one of those paths goes
through. The in-panel Back button is not special-cased — it uses the same
Expand Down
8 changes: 2 additions & 6 deletions src/main/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,8 @@ export const defaultShortcuts: ShortcutConfig[] = [
},
{
action: ShortcutAction.CLOSE_NOTEBOOK,
// Not bare Escape. Escape is the key that dismisses a field, a dialog and a
// dropdown, and this action closes the whole workspace — including unsaved
// note text, because the main process claims the key before the renderer's
// unsaved-changes guard can see it. Ctrl/Cmd+W is the platform idiom for
// "close this tab" and cannot be pressed by accident.
accelerator: 'CommandOrControl+W',
// Keep Escape available to dialogs and avoid the window-close Cmd/Ctrl+W.
accelerator: 'CommandOrControl+D',
enabled: true,
description: 'shortcuts:closeNotebook'
},
Expand Down
66 changes: 14 additions & 52 deletions src/main/services/ShortcutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,7 @@ import type Store from 'electron-store'
import { ShortcutConfig, ShortcutAction } from '../../shared/types'
import { defaultShortcuts } from '../config/defaults'
import type { StoreSchema } from '../config/types'

/**
* Accelerators that shipped as a default and must not survive an upgrade.
*
* The merge in `registerShortcuts` only adds actions a stored config has never
* seen, so *changing* a default never reaches anyone who has already run the app.
* Bare `Escape` was bound to closing the notebook, which the main process claims
* before the renderer sees it: it destroyed the workspace — unsaved note text
* included — and made Escape unusable for dismissing anything. Replacing it by
* value is the migration; there is no need for a version counter because a
* modifier-less accelerator is no longer accepted by the recorder either.
*/
const SUPERSEDED_ACCELERATORS: Partial<Record<ShortcutAction, string[]>> = {
[ShortcutAction.CLOSE_NOTEBOOK]: ['Escape']
}
import { migrateShortcuts } from '../../shared/utils/shortcutMigration'

/**
* 快捷键管理器
Expand All @@ -27,7 +13,7 @@ export class ShortcutManager {
private store: Store<StoreSchema>
private mainWindow: BrowserWindow | null = null
private shortcuts: ShortcutConfig[] = []
private keyboardHandler: ((event: Event, input: Electron.Input) => void) | null = null
private keyboardHandler: ((event: Electron.Event, input: Electron.Input) => void) | null = null

constructor(store: Store<StoreSchema>) {
this.store = store
Expand All @@ -53,27 +39,8 @@ export class ShortcutManager {
return
}

// 合并新增的默认快捷键(向后兼容旧版本配置),
// 并替换掉已废弃的默认加速键(见 SUPERSEDED_ACCELERATORS)。
const mergedShortcuts = [...shortcuts]
let hasNewShortcut = false
for (const defaultShortcut of defaultShortcuts) {
const index = mergedShortcuts.findIndex((s) => s.action === defaultShortcut.action)
if (index === -1) {
mergedShortcuts.push({ ...defaultShortcut })
hasNewShortcut = true
continue
}
const superseded = SUPERSEDED_ACCELERATORS[defaultShortcut.action]
if (superseded?.includes(mergedShortcuts[index].accelerator)) {
mergedShortcuts[index] = {
...mergedShortcuts[index],
accelerator: defaultShortcut.accelerator
}
hasNewShortcut = true
}
}
if (hasNewShortcut) {
const mergedShortcuts = migrateShortcuts(shortcuts, defaultShortcuts)
if (JSON.stringify(mergedShortcuts) !== JSON.stringify(shortcuts)) {
this.store.set('shortcuts', mergedShortcuts)
}

Expand All @@ -86,31 +53,23 @@ export class ShortcutManager {

// 移除旧的监听器
if (this.keyboardHandler) {
;(this.mainWindow.webContents as any).removeListener(
'before-input-event',
this.keyboardHandler
)
this.mainWindow.webContents.removeListener('before-input-event', this.keyboardHandler)
}

// 创建新的处理器
this.keyboardHandler = (event: Event, input: Electron.Input) => {
this.keyboardHandler = (event: Electron.Event, input: Electron.Input) => {
this.handleKeyboardEvent(event, input)
}

// 等待 webContents ready 后再注册
if (this.mainWindow.webContents.isLoading()) {
this.mainWindow.webContents.once('did-finish-load', () => {
;(this.mainWindow!.webContents as any).on('before-input-event', this.keyboardHandler!)
})
} else {
;(this.mainWindow.webContents as any).on('before-input-event', this.keyboardHandler)
}
// Input belongs to webContents, not to a loaded document. Attach immediately
// so failed/restarted loads cannot leave the window without shortcuts.
this.mainWindow.webContents.on('before-input-event', this.keyboardHandler)
}

/**
* 处理键盘事件
*/
private handleKeyboardEvent(event: Event, input: Electron.Input): void {
private handleKeyboardEvent(event: Electron.Event, input: Electron.Input): void {
// 只处理 keyDown 事件
if (input.type !== 'keyDown') return

Expand Down Expand Up @@ -148,7 +107,10 @@ export class ShortcutManager {
* 注销所有快捷键
*/
unregisterShortcuts(): void {
// 清空菜单(移除所有快捷键)
if (this.keyboardHandler && this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.removeListener('before-input-event', this.keyboardHandler)
}
this.keyboardHandler = null
Menu.setApplicationMenu(null)
}

Expand Down
9 changes: 4 additions & 5 deletions src/renderer/src/components/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ function greetingKey(hour: number): string {
*
* Four sections at most, in the order they are used: a greeting, the search entry,
* the notebooks, then what the user was doing and what recently arrived. The
* greeting is small on purpose; the entry below it is the thing the eye should land
* on, because a long-lived tool's Home is opened a hundred times and a hero banner
* is paid for on every one of them.
* greeting is the page's focus heading; search stays immediately below it without
* turning the workspace into a promotional hero.
*
* Every section is allowed to render nothing. A workspace with no conversations and
* no sources shows a greeting, a search box and the shelf — not four empty boxes
Expand Down Expand Up @@ -85,10 +84,10 @@ export default function Home({

const header = (
<header>
<h1 className="text-xl font-medium tracking-tight text-foreground">
<h1 className="text-3xl font-medium tracking-tight text-foreground">
{t(greetingKey(new Date().getHours()))}
</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('homePrompt')}</p>
<p className="mt-2 text-base text-muted-foreground">{t('homePrompt')}</p>
</header>
)

Expand Down
37 changes: 11 additions & 26 deletions src/renderer/src/components/layouts/ResizableLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, ReactNode, ReactElement } from 'react'
import { useTranslation } from 'react-i18next'
import DragHandle from './DragHandle'
import { REVEAL_LIBRARY_EVENT } from '../../lib/workspaceEvents'
import { lockDrag } from '../../lib/dragLock'
import {
DEFAULT_LEFT_WIDTH,
Expand Down Expand Up @@ -303,42 +304,26 @@ export default function ResizableLayout({
const leftZoneRef = useRef<HTMLDivElement>(null)
const rightZoneRef = useRef<HTMLDivElement>(null)

/**
* Zone shortcuts enter the workspace (#65).
*
* The persisted action IDs are unchanged, but the behaviour is “go to the
* zone”, not “toggle the panel”: reveal a collapsed zone, then move focus into
* it. Collapsing stays on the panel-header buttons. Listening to the same
* `shortcut:toggle-*` events keeps the shortcut IDs stable.
*/
// Shortcuts toggle just like the header buttons. Citation navigation only
// reveals the library, so repeated citation clicks never hide the reader.
useEffect(() => {
const enterLibrary = (): void => {
const revealLibrary = (): void => {
setIsLeftCollapsed((collapsed) => {
const entry = planZoneEntry(collapsed, lastSizeRef.current.left, DEFAULT_LEFT_WIDTH)
if (entry.width !== null) setPanelWidth('left', entry.width)
return entry.collapsed
})
// The wrapper mounts on this tick when it was collapsed, so focus after the
// commit rather than on an unmounted node.
requestAnimationFrame(() => leftZoneRef.current?.focus())
}

const enterNotes = (): void => {
setIsRightCollapsed((collapsed) => {
const entry = planZoneEntry(collapsed, lastSizeRef.current.right, DEFAULT_RIGHT_WIDTH)
if (entry.width !== null) setPanelWidth('right', entry.width)
return entry.collapsed
})
requestAnimationFrame(() => rightZoneRef.current?.focus())
}

window.addEventListener('shortcut:toggle-knowledge-base', enterLibrary)
window.addEventListener('shortcut:toggle-creative-space', enterNotes)
window.addEventListener(REVEAL_LIBRARY_EVENT, revealLibrary)
window.addEventListener('shortcut:toggle-knowledge-base', toggleLeftPanel)
window.addEventListener('shortcut:toggle-creative-space', toggleRightPanel)
return () => {
window.removeEventListener('shortcut:toggle-knowledge-base', enterLibrary)
window.removeEventListener('shortcut:toggle-creative-space', enterNotes)
window.removeEventListener(REVEAL_LIBRARY_EVENT, revealLibrary)
window.removeEventListener('shortcut:toggle-knowledge-base', toggleLeftPanel)
window.removeEventListener('shortcut:toggle-creative-space', toggleRightPanel)
}
}, [setPanelWidth])
}, [setPanelWidth, toggleLeftPanel, toggleRightPanel])

// Stable, so a drag frame does not hand the centre panel a new element.
const centerPanelProps = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ const PdfSourceReader = forwardRef<ReaderHandle, PdfSourceReaderProps>(function
) : (
<div
ref={scrollRef}
className="kn-reader"
className="kn-reader themed-scrollbar"
onScroll={handleScroll}
onMouseUp={handleSelection}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ const TextSourceReader = forwardRef<ReaderHandle, TextSourceReaderProps>(functio
const contentRef = useRef<HTMLDivElement>(null)
const lastSelection = useRef<ReaderSelection | null>(null)
const [highlightRects, setHighlightRects] = useState<HighlightRect[]>([])
const activeAnchor = useRef<ReaderAnchor | null>(null)

const applyAnchor = useCallback(
(target: ReaderAnchor): void => {
(target: ReaderAnchor, scroll = true): void => {
activeAnchor.current = target
const { block } = resolveAnchor(blocks, target)
const start = target.startOffset ?? block?.startOffset ?? null
const end = target.endOffset ?? block?.endOffset ?? null
Expand All @@ -58,7 +60,7 @@ const TextSourceReader = forwardRef<ReaderHandle, TextSourceReaderProps>(functio
// 定位不到就清掉上一次的高亮,并回到文本顶部:否则从一条 citation 切到 document-only
// anchor 时会清掉高亮、却停在旧滚动位置。
setHighlightRects([])
container?.scrollTo({ top: 0, behavior: 'auto' })
if (scroll) container?.scrollTo({ top: 0, behavior: 'auto' })
return
}

Expand All @@ -77,7 +79,8 @@ const TextSourceReader = forwardRef<ReaderHandle, TextSourceReaderProps>(functio
setHighlightRects(rects)

const first = rects[0]
if (first) container.scrollTo({ top: Math.max(0, first.top - 80), behavior: 'auto' })
if (scroll && first)
container.scrollTo({ top: Math.max(0, first.top - 80), behavior: 'auto' })
},
[blocks]
)
Expand All @@ -101,6 +104,18 @@ const TextSourceReader = forwardRef<ReaderHandle, TextSourceReaderProps>(functio
return () => cancelAnimationFrame(frame)
}, [anchor, blocks, applyAnchor])

useEffect(() => {
const container = scrollRef.current
const body = contentRef.current
if (!container || !body) return
const observer = new ResizeObserver(() => {
if (activeAnchor.current) applyAnchor(activeAnchor.current, false)
})
observer.observe(container)
observer.observe(body)
return () => observer.disconnect()
}, [applyAnchor, content])

const handleSelection = useCallback((): void => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
Expand All @@ -127,7 +142,7 @@ const TextSourceReader = forwardRef<ReaderHandle, TextSourceReaderProps>(functio
return (
<div
ref={scrollRef}
className="kn-reader"
className="kn-reader themed-scrollbar"
onMouseUp={handleSelection}
onKeyUp={handleSelection}
>
Expand Down
20 changes: 18 additions & 2 deletions src/renderer/src/components/pages/NotebookListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function NotebookListPage(): ReactElement {
})
}, [])

const handleCreateNotebook = async (): Promise<void> => {
const handleCreateNotebook = useCallback(async (): Promise<void> => {
const newId = await addNotebook({
title: t('newNotebook', { index: notebooks.length + 1 }),
description: t('notebookDescription')
Expand All @@ -74,7 +74,23 @@ export default function NotebookListPage(): ReactElement {
setCurrentNotebook(newId)
rememberOpen(newId)
navigate(`/notebook/${newId}`)
}
}, [
addNotebook,
t,
notebooks.length,
addOpenedNotebook,
setCurrentNotebook,
rememberOpen,
navigate
])

useEffect(() => {
const create = (): void => {
void handleCreateNotebook()
}
window.addEventListener('shortcut:create-notebook', create)
return () => window.removeEventListener('shortcut:create-notebook', create)
}, [handleCreateNotebook])

const handleNotebookClick = (id: string): void => {
addOpenedNotebook(id)
Expand Down
33 changes: 19 additions & 14 deletions src/renderer/src/components/settings/ModelsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import ModelConnectionForm, { type ProtocolInfo } from './ModelConnectionForm'
import EmbeddingSettings from './EmbeddingSettings'

interface ModelsSettingsProps {
capability: ModelCapability
connections: ConnectionMap
onConnectionsChange: (connections: ConnectionMap) => void
}

export default function ModelsSettings({
capability,
connections,
onConnectionsChange
}: ModelsSettingsProps): ReactElement {
Expand Down Expand Up @@ -37,20 +39,23 @@ export default function ModelsSettings({

return (
<div className="flex flex-col gap-10">
<ModelConnectionForm
capability="chat"
connection={connections.chat}
protocols={protocols}
onChange={(connection) => setConnection('chat', connection)}
onClear={() => clearConnection('chat')}
/>
<div className="border-t border-border" />
<EmbeddingSettings
connection={connections.embedding}
protocols={protocols}
onChange={(connection) => setConnection('embedding', connection)}
onClear={() => clearConnection('embedding')}
/>
{capability === 'chat' && (
<ModelConnectionForm
capability="chat"
connection={connections.chat}
protocols={protocols}
onChange={(connection) => setConnection('chat', connection)}
onClear={() => clearConnection('chat')}
/>
)}
{capability === 'embedding' && (
<EmbeddingSettings
connection={connections.embedding}
protocols={protocols}
onChange={(connection) => setConnection('embedding', connection)}
onClear={() => clearConnection('embedding')}
/>
)}
</div>
)
}
Loading
Loading