From fb13457f6fa8b8598b4b9be0315f4bbb91de304b Mon Sep 17 00:00:00 2001 From: m4 Date: Sun, 27 Sep 2026 17:37:19 +0800 Subject: [PATCH] feat: consolidate desktop and provider updates --- apps/desktop/DESIGN.md | 16 + .../electron/freemodel2api-ipc.test.ts | 36 +- apps/desktop/electron/freemodel2api-ipc.ts | 32 +- apps/desktop/electron/freemodel2api.ts | 24 +- apps/desktop/electron/preload.ts | 3 +- .../desktop/electron/user-account-ipc.test.ts | 191 +++- apps/desktop/electron/user-account-ipc.ts | 76 +- apps/desktop/package.json | 11 +- apps/desktop/scripts/launch-dev-electron.mjs | 21 + .../scripts/patch-dev-electron-branding.mjs | 88 +- .../patch-dev-electron-branding.test.mjs | 76 ++ apps/desktop/src/api/freemodel2api.ts | 7 + .../app/chat/composer/attachments.test.tsx | 2 +- .../src/app/chat/composer/attachments.tsx | 1 - .../src/app/chat/composer/composer-utils.ts | 23 +- .../hooks/use-composer-metrics.test.tsx | 9 +- .../composer/hooks/use-composer-metrics.ts | 84 +- apps/desktop/src/app/chat/composer/index.tsx | 17 +- .../app/chat/composer/status-stack/index.tsx | 38 + apps/desktop/src/app/chat/index.tsx | 6 +- apps/desktop/src/app/chat/pane-mirror.ts | 6 + .../desktop/src/app/chat/preview-tile.test.ts | 11 + apps/desktop/src/app/chat/preview-tile.tsx | 88 +- .../chat/right-rail/office-preview.test.tsx | 101 ++ .../app/chat/right-rail/office-preview.tsx | 74 ++ .../app/chat/right-rail/preview-file.test.tsx | 91 +- .../src/app/chat/right-rail/preview-file.tsx | 82 +- .../src/app/chat/right-rail/preview-image.tsx | 22 + .../src/app/chat/session-history-nav.tsx | 45 + apps/desktop/src/app/chat/session-tile.tsx | 1 + .../sidebar/chat-sidebar.integration.test.tsx | 40 + .../src/app/chat/sidebar/filter-menu.tsx | 11 - apps/desktop/src/app/chat/sidebar/index.tsx | 39 +- .../profile-dropdown-switcher.test.tsx | 278 +++-- .../sidebar/profile-dropdown-switcher.tsx | 259 ++--- .../sidebar/profile-rail-connect.test.tsx | 22 +- .../src/app/chat/sidebar/profile-switcher.tsx | 14 +- .../chat/sidebar/projects/entered-content.tsx | 9 + .../src/app/chat/sidebar/projects/index.ts | 3 + .../app/chat/sidebar/projects/model.test.ts | 25 +- .../src/app/chat/sidebar/projects/model.ts | 18 + .../sidebar/projects/overview-row.test.tsx | 12 + .../chat/sidebar/projects/overview-row.tsx | 40 +- .../sidebar/projects/project-menu.test.tsx | 31 + .../chat/sidebar/projects/project-menu.tsx | 23 +- .../chat/sidebar/projects/workspace-group.tsx | 11 +- .../chat/sidebar/projects/workspace-groups.ts | 19 + .../sidebar/projects/workspace-header.tsx | 22 +- .../src/app/chat/sidebar/session-row.tsx | 9 +- .../src/app/chat/sidebar/sessions-section.tsx | 48 +- .../app/chat/sidebar/user-account-card.tsx | 179 +++- .../chat/sidebar/user-account-footer.test.tsx | 173 ++++ .../chat/sidebar/user-account-menu.test.tsx | 173 ++++ .../app/chat/sidebar/version-menu.test.tsx | 114 +++ .../src/app/chat/sidebar/version-menu.tsx | 156 +++ .../src/app/chat/sidebar/zone-tabs.test.tsx | 169 +++ .../src/app/chat/sidebar/zone-tabs.tsx | 152 +++ .../src/app/chat/workspace-bar.test.tsx | 62 +- apps/desktop/src/app/chat/workspace-bar.tsx | 60 +- .../src/app/context-menu/app-context-menu.tsx | 7 - apps/desktop/src/app/contrib/controller.tsx | 53 +- .../desktop/src/app/contrib/layout-presets.ts | 7 +- apps/desktop/src/app/contrib/wiring.tsx | 3 +- apps/desktop/src/app/hooks/use-keybinds.ts | 2 - .../app/hooks/use-on-profile-switch.test.tsx | 39 + .../src/app/hooks/use-on-profile-switch.ts | 11 +- .../src/app/me/change-password-dialog.tsx | 178 ++++ apps/desktop/src/app/me/me-page.test.tsx | 19 +- .../components/reference-chip.tsx | 1 - apps/desktop/src/app/profiles/index.test.tsx | 2 + apps/desktop/src/app/profiles/index.tsx | 4 + .../src/app/profiles/profile-create-host.tsx | 50 + .../files/tree-dblclick.test.tsx | 25 +- .../src/app/right-sidebar/files/tree.tsx | 8 +- apps/desktop/src/app/right-sidebar/index.tsx | 2 +- .../hooks/use-session-actions/index.ts | 10 +- .../src/app/settings/env-credentials.tsx | 16 + .../settings/freemodel2api-settings.test.tsx | 605 ++++++++++- .../app/settings/freemodel2api-settings.tsx | 959 +++++++++++++++--- apps/desktop/src/app/settings/index.tsx | 12 +- .../src/app/settings/model-settings.test.tsx | 70 +- .../src/app/settings/model-settings.tsx | 77 +- .../src/app/settings/providers-settings.tsx | 17 +- .../shell/hooks/use-statusbar-items.test.tsx | 69 ++ .../app/shell/hooks/use-statusbar-items.tsx | 122 +-- .../src/app/shell/titlebar-controls.test.tsx | 16 +- .../src/app/shell/titlebar-controls.tsx | 31 +- apps/desktop/src/components/Backdrop.tsx | 2 +- .../assistant-ui/embeds/listing-embed.tsx | 1 - .../chat/generated-image-result.tsx | 52 +- .../src/components/chat/image-viewer.test.tsx | 170 ++++ .../src/components/chat/image-viewer.tsx | 210 ++++ .../src/components/chat/intro-copy.jsonl | 2 +- apps/desktop/src/components/chat/intro.tsx | 2 +- apps/desktop/src/components/chat/wordmark.tsx | 2 +- .../src/components/chat/zoomable-image.tsx | 104 +- .../onboarding-chat/setup-profile.ts | 2 +- .../components/onboarding-chat/signpost.ts | 10 +- .../src/components/onboarding/index.test.tsx | 53 +- .../src/components/onboarding/index.tsx | 67 +- .../tree/lone-chat-sibling-zone.test.ts | 10 + .../tree/renderer/left-sidebar-rail.test.tsx | 113 +++ .../tree/renderer/narrow-overlays.tsx | 21 +- .../tree/renderer/track-model.test.ts | 53 + .../pane-shell/tree/renderer/track-model.ts | 36 +- .../tree/renderer/tree-group.test.tsx | 61 ++ .../pane-shell/tree/renderer/tree-group.tsx | 92 +- .../pane-shell/tree/renderer/tree-split.tsx | 69 +- .../src/components/pane-shell/tree/store.ts | 67 +- apps/desktop/src/components/ui/pane-tab.tsx | 13 +- .../desktop/src/components/ui/use-zoom-pan.ts | 63 +- .../src/components/user-account-gate.tsx | 34 +- apps/desktop/src/global.d.ts | 10 +- apps/desktop/src/i18n/ar.ts | 28 +- apps/desktop/src/i18n/en.ts | 99 +- apps/desktop/src/i18n/ja.ts | 43 +- apps/desktop/src/i18n/ru.ts | 32 +- apps/desktop/src/i18n/types.ts | 24 +- apps/desktop/src/i18n/zh-hant.ts | 26 +- apps/desktop/src/i18n/zh.ts | 30 +- apps/desktop/src/lib/keybinds/actions.ts | 2 - apps/desktop/src/lib/office-files.test.ts | 22 +- apps/desktop/src/lib/office-files.ts | 19 +- apps/desktop/src/lib/tips/catalog.ts | 2 +- apps/desktop/src/lib/user-account-errors.ts | 17 + apps/desktop/src/lib/version-status.ts | 4 +- apps/desktop/src/store/onboarding.test.ts | 17 + apps/desktop/src/store/onboarding.ts | 19 +- apps/desktop/src/store/profile-rail-prefs.ts | 11 - apps/desktop/src/store/profile.ts | 3 +- apps/desktop/src/store/project-pins.test.ts | 51 + apps/desktop/src/store/project-pins.ts | 75 ++ apps/desktop/src/store/projects.test.ts | 52 +- apps/desktop/src/store/projects.ts | 24 +- .../desktop/src/store/session-history.test.ts | 73 ++ apps/desktop/src/store/session-history.ts | 74 ++ apps/desktop/src/store/session.test.ts | 37 +- apps/desktop/src/store/session.ts | 30 +- .../src/store/titlebar-app-actions.test.ts | 10 +- .../desktop/src/store/titlebar-app-actions.ts | 16 +- apps/desktop/src/store/user-account.test.ts | 21 +- apps/desktop/src/store/user-account.ts | 22 +- apps/desktop/src/styles.css | 7 +- apps/desktop/src/types/hermes.ts | 10 +- hermes_cli/auth.py | 43 +- hermes_cli/config_defaults.py | 5 +- hermes_cli/config_providers.py | 5 + hermes_cli/inventory.py | 64 +- hermes_cli/main.py | 28 +- hermes_cli/model_switch_providers.py | 21 +- hermes_cli/models.py | 3 + hermes_cli/models_pricing.py | 180 ++++ hermes_cli/providers.py | 44 +- hermes_cli/runtime_provider.py | 4 + hermes_cli/web_models.py | 3 + hermes_cli/web_routers/config_env.py | 22 +- hermes_cli/web_server_config.py | 16 +- package-lock.json | 9 +- plugins/image_gen/_common.py | 37 + plugins/image_gen/openai/__init__.py | 31 +- plugins/image_gen/openai/plugin.yaml | 6 +- .../model-providers/freemodel2api/__init__.py | 21 + .../model-providers/freemodel2api/plugin.yaml | 5 + .../test_custom_endpoint_pricing.py | 158 +++ .../hermes_cli/test_freemodel2api_provider.py | 135 +++ tests/hermes_cli/test_inventory.py | 54 +- .../hermes_cli/test_list_picker_providers.py | 44 + .../test_model_switch_custom_providers.py | 28 + .../test_provider_config_validation.py | 13 + .../test_resolve_provider_corrupt_config.py | 7 + tests/hermes_cli/test_web_server.py | 6 +- .../plugins/image_gen/test_openai_provider.py | 50 +- website/docs/reference/tools-reference.md | 3 +- .../user-guide/features/image-generation.md | 14 +- 174 files changed, 7669 insertions(+), 1477 deletions(-) create mode 100644 apps/desktop/scripts/launch-dev-electron.mjs create mode 100644 apps/desktop/scripts/patch-dev-electron-branding.test.mjs create mode 100644 apps/desktop/src/app/chat/right-rail/office-preview.test.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/office-preview.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/preview-image.tsx create mode 100644 apps/desktop/src/app/chat/session-history-nav.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/user-account-footer.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/user-account-menu.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/version-menu.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/version-menu.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/zone-tabs.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/zone-tabs.tsx create mode 100644 apps/desktop/src/app/hooks/use-on-profile-switch.test.tsx create mode 100644 apps/desktop/src/app/me/change-password-dialog.tsx create mode 100644 apps/desktop/src/app/profiles/profile-create-host.tsx create mode 100644 apps/desktop/src/app/shell/hooks/use-statusbar-items.test.tsx create mode 100644 apps/desktop/src/components/chat/image-viewer.test.tsx create mode 100644 apps/desktop/src/components/chat/image-viewer.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/left-sidebar-rail.test.tsx create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/track-model.test.ts create mode 100644 apps/desktop/src/lib/user-account-errors.ts delete mode 100644 apps/desktop/src/store/profile-rail-prefs.ts create mode 100644 apps/desktop/src/store/project-pins.test.ts create mode 100644 apps/desktop/src/store/project-pins.ts create mode 100644 apps/desktop/src/store/session-history.test.ts create mode 100644 apps/desktop/src/store/session-history.ts create mode 100644 plugins/model-providers/freemodel2api/__init__.py create mode 100644 plugins/model-providers/freemodel2api/plugin.yaml create mode 100644 tests/hermes_cli/test_custom_endpoint_pricing.py create mode 100644 tests/hermes_cli/test_freemodel2api_provider.py diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 7335f3ce58..70c81c4d25 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -230,6 +230,17 @@ existing traffic-light and Window Controls Overlay measurements. The left cluster shows sidebar, settings, layout editor, and HUD controls. Flip and the right-sidebar toggle sit on the right; haptics remain in settings. +The sidebar account entry stays below the pane body, outside its scrolling +content. It survives tab switches and uses an icon on the +collapsed side rail. +The expanded account row has a trailing menu; the collapsed account icon opens +the same menu. Password changes use email verification and return to sign-in. +Profiles live in a submenu of this account menu, with the current profile +visible on its trigger. Square ordering, coloring, and advanced profile actions +live in Manage Profiles. A single shell host owns the create-profile hotkey. +The sidebar's version opens the existing updates flow through a target menu; +its collapsed rail uses an update icon. Desktop and remote-backend versions +are not repeated in the statusbar. Passive checks and update notices stay active. Holding Cmd (Ctrl off macOS) reveals small slot numbers over the target strip's status dots after 400ms, without changing tab widths. Hints follow the same binding and hovered/focused-zone resolver as the number shortcuts. @@ -285,6 +296,11 @@ so glass and message-bubble transparency do not reveal scrolling text. from the chip to the floating pill; leaving both dismisses it. - A tool result may expose an inline action that opens a preview. It must not open the rail automatically. +- Images in file previews and transcript lightboxes share `ImageViewer` and + `useZoomPan`: fit-to-window, actual size, cursor-centered wheel zoom, drag, + and download. Expanded views use nearly the whole viewport; image clicks + never dismiss a view while inspecting or panning it. Fit mode follows pane + resizes, while manual zoom remains under the user's control. - Tool rows reserve destructive red for explicit failures. Missing read paths and ambiguous exit-1 results use neutral notices, with details still available. Errors described inside returned data are not tool failures. Expanded failures diff --git a/apps/desktop/electron/freemodel2api-ipc.test.ts b/apps/desktop/electron/freemodel2api-ipc.test.ts index 36a562ab58..f4c1b069fb 100644 --- a/apps/desktop/electron/freemodel2api-ipc.test.ts +++ b/apps/desktop/electron/freemodel2api-ipc.test.ts @@ -45,23 +45,23 @@ function makeFetcher(behavior: (url: string, init?: any) => { status: number; bo const LOGIN_OK = { code: 0, - data: { access_token: 'AT-1', expires_in: 3600, token_type: 'Bearer', user: { email: 'u@example.com', balance: 9.5 } } + data: { access_token: 'AT-1', expires_in: 3600, token_type: 'Bearer', user: { email: 'u@example.com', username: 'yb', balance: 9.5, frozen_balance: 12.5 } } } test('login persists credentials and returns balance; status reuses in-memory session', async () => { const { handlers, ipcMain } = fakeIpcMain() const { io } = fakeIo() - const { fetcher, calls } = makeFetcher(url => (url.endsWith('/auth/login') ? { status: 200, body: LOGIN_OK } : { status: 200, body: { code: 0, data: { email: 'u@example.com', balance: 9.5 } } })) + const { fetcher, calls } = makeFetcher(url => (url.endsWith('/auth/login') ? { status: 200, body: LOGIN_OK } : { status: 200, body: { code: 0, data: { email: 'u@example.com', username: 'yb', balance: 9.5, frozen_balance: 12.5 } } })) registerFreeModel2ApiIpc({ ipcMain, io, fetcher }) const login = await handlers.get('hermes:freemodel2api:login')!(null, { baseUrl: 'https://sub.example.com/', email: 'u@example.com', password: 'pw' }) - assert.deepEqual(login, { ok: true, email: 'u@example.com', balance: 9.5 }) + assert.deepEqual(login, { ok: true, email: 'u@example.com', balance: 9.5, username: 'yb', frozenBalance: 12.5 }) assert.deepEqual(JSON.parse(io.decrypt(JSON.parse(io.readStoreText()).freemodel2api)), { baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw' }) const status = await handlers.get('hermes:freemodel2api:status')!(null) - assert.deepEqual(status, { configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5 }) + assert.deepEqual(status, { configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5, username: 'yb', frozenBalance: 12.5 }) // status 用内存里的 session,没有第二次 login 请求 assert.equal(calls.filter(c => c.url.endsWith('/auth/login')).length, 1) }) @@ -74,6 +74,34 @@ test('status with no stored credentials returns configured:false', async () => { assert.deepEqual(await handlers.get('hermes:freemodel2api:status')!(null), { configured: false }) }) +test('key-balance queries by api key without a session; non-sub2api answers null', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + const { fetcher, calls } = makeFetcher(url => + url.endsWith('/v1/sub2api/balance') + ? { status: 200, body: { object: 'sub2api.key_balance', billing_mode: 'balance', balance: 9181.34, unit: 'USD' } } + : { status: 200, body: LOGIN_OK }) + registerFreeModel2ApiIpc({ ipcMain, io, fetcher }) + + // baseUrl 带旧 /v1 也要归到同一个端点;凭 key,不 login + assert.deepEqual( + await handlers.get('hermes:freemodel2api:key-balance')!(null, { baseUrl: 'http://h:3020/v1', apiKey: 'sk-k' }), + { balance: 9181.34 } + ) + assert.equal(calls[0].url, 'http://h:3020/v1/sub2api/balance') + assert.equal(calls[0].init?.headers?.Authorization, 'Bearer sk-k') + assert.equal(calls.filter(c => c.url.endsWith('/auth/login')).length, 0) + + // 非 sub2api 端点 404:不是错误,只是查不到 + const { fetcher: fetcher404 } = makeFetcher(() => ({ status: 404, body: { message: 'nope' } })) + const second = fakeIpcMain() + registerFreeModel2ApiIpc({ ipcMain: second.ipcMain, io, fetcher: fetcher404 }) + assert.deepEqual( + await second.handlers.get('hermes:freemodel2api:key-balance')!(null, { baseUrl: 'http://h:3020', apiKey: 'sk-k' }), + { balance: null } + ) +}) + test('keys re-login once on 401 then succeed', async () => { const { handlers, ipcMain } = fakeIpcMain() const { io } = fakeIo() diff --git a/apps/desktop/electron/freemodel2api-ipc.ts b/apps/desktop/electron/freemodel2api-ipc.ts index 0e4627e8e2..04ef00d922 100644 --- a/apps/desktop/electron/freemodel2api-ipc.ts +++ b/apps/desktop/electron/freemodel2api-ipc.ts @@ -16,6 +16,7 @@ import { persistRememberedLogin, sub2ApiFetchKeys, sub2ApiFetchProfile, + sub2ApiKeyBalance, sub2ApiLogin, type FetchLike, type FreeModel2ApiStoreIo @@ -78,7 +79,13 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void { } const result = await freshLogin(baseUrl, email, password) persistFreeModel2ApiCredentials({ baseUrl, email, password }, io) - return { ok: true, email: result.email, balance: result.balance } + return { + ok: true, + email: result.email, + balance: result.balance, + username: result.username, + frozenBalance: result.frozenBalance + } }) ipcMain.handle('hermes:freemodel2api:status', async () => { @@ -87,7 +94,14 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void { return { configured: false } } const profile = await withSession(s => sub2ApiFetchProfile(s.baseUrl, s.accessToken, fetcher)) - return { configured: true, baseUrl: creds.baseUrl, email: profile.email || creds.email, balance: profile.balance } + return { + configured: true, + baseUrl: creds.baseUrl, + email: profile.email || creds.email, + balance: profile.balance, + username: profile.username, + frozenBalance: profile.frozenBalance + } }) ipcMain.handle('hermes:freemodel2api:keys', async () => { @@ -105,6 +119,20 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void { return loadRememberedLogin(io) }) + // 未登录的余额读数:凭 renderer 传来的 key 直查,不走会话。失败只报 null,不打扰人。 + ipcMain.handle('hermes:freemodel2api:key-balance', async (_event, payload) => { + const baseUrl = String(payload?.baseUrl ?? '') + const apiKey = String(payload?.apiKey ?? '') + if (!baseUrl || !apiKey) { + return { balance: null } + } + try { + return { balance: await sub2ApiKeyBalance(baseUrl, apiKey, fetcher) } + } catch { + return { balance: null } + } + }) + ipcMain.handle('hermes:freemodel2api:remembered:save', async (_event, payload) => { if (!payload) { persistRememberedLogin(null, io) diff --git a/apps/desktop/electron/freemodel2api.ts b/apps/desktop/electron/freemodel2api.ts index 8cc2d9b73f..36d1d225e5 100644 --- a/apps/desktop/electron/freemodel2api.ts +++ b/apps/desktop/electron/freemodel2api.ts @@ -94,6 +94,8 @@ export interface Sub2ApiProfile { email: string username: string balance: number + /** 上游没这个字段时按 0 处理 —— 面板宁可显示 $0.00,也不为它单独加一次请求 */ + frozenBalance: number } export interface Sub2ApiLoginResult { @@ -101,6 +103,8 @@ export interface Sub2ApiLoginResult { expiresAt: number email: string balance: number + username: string + frozenBalance: number } export type FetchLike = ( @@ -152,7 +156,9 @@ export async function sub2ApiLogin(baseUrl: string, email: string, password: str // 提前 60s 视为过期,边界请求不踩死线 expiresAt: Date.now() + expiresIn * 1000 - 60_000, email: String(data?.user?.email ?? email), - balance: Number(data?.user?.balance ?? 0) + balance: Number(data?.user?.balance ?? 0), + username: String(data?.user?.username ?? ''), + frozenBalance: Number(data?.user?.frozen_balance ?? 0) } } @@ -161,10 +167,24 @@ export async function sub2ApiFetchProfile(baseUrl: string, token: string, fetche return { email: String(data?.email ?? ''), username: String(data?.username ?? ''), - balance: Number(data?.balance ?? 0) + balance: Number(data?.balance ?? 0), + frozenBalance: Number(data?.frozen_balance ?? 0) } } +/** sub2api 凭 API key 直查余额(`{root}/v1/sub2api/balance`)——不需要会话。 + * 非 sub2api 端点 404 / 响应不含 balance → null,不算错误:这只是块读数。 */ +export async function sub2ApiKeyBalance(baseUrl: string, apiKey: string, fetcher: FetchLike): Promise { + const root = normalizeSub2ApiBaseUrl(baseUrl).replace(/\/v1$/, '') + const res = await fetcher(`${root}/v1/sub2api/balance`, { headers: { Authorization: `Bearer ${apiKey}` } }) + if (!res.ok) { + return null + } + const payload = await res.json().catch(() => null) + const value = Number(payload?.balance) + return payload?.object === 'sub2api.key_balance' && Number.isFinite(value) ? value : null +} + export async function sub2ApiFetchKeys(baseUrl: string, token: string, fetcher: FetchLike): Promise { const data = await callSub2Api(baseUrl, '/api/v1/keys', { token }, fetcher) const items = Array.isArray(data?.items) ? data.items : [] diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index e1e4c0f75d..1619efe019 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -32,7 +32,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { keys: () => ipcRenderer.invoke('hermes:freemodel2api:keys'), logout: () => ipcRenderer.invoke('hermes:freemodel2api:logout'), rememberedLoad: () => ipcRenderer.invoke('hermes:freemodel2api:remembered:load'), - rememberedSave: payload => ipcRenderer.invoke('hermes:freemodel2api:remembered:save', payload) + rememberedSave: payload => ipcRenderer.invoke('hermes:freemodel2api:remembered:save', payload), + keyBalance: payload => ipcRenderer.invoke('hermes:freemodel2api:key-balance', payload) }, // Hermes 用户账号(§21):token 留在 main 进程,renderer 只见 profile/状态。 userAccount: { diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts index 10659c5b11..8e26133e0f 100644 --- a/apps/desktop/electron/user-account-ipc.test.ts +++ b/apps/desktop/electron/user-account-ipc.test.ts @@ -4,7 +4,7 @@ import { test } from 'vitest' import type { FetchLike } from './relay-account' import { registerUserAccountIpc } from './user-account-ipc' -import { loadRememberedLogin, loadUserAccount, persistUserAccount, type UserAccountStoreIo } from './user-account-store' +import { loadRememberedLogin, loadUserAccount, persistRememberedLogin, persistUserAccount, type UserAccountStoreIo } from './user-account-store' function fakeIo() { let fileText: string | null = null @@ -145,7 +145,7 @@ test('me 401 → 自动 refresh 一次重试成功,轮换后的令牌落盘', assert.deepEqual(meAuths, ['Bearer AT-1', 'Bearer AT-1', 'Bearer AT-2']) }) -test('refresh 也被拒 = 硬会话终点:本地清零并上报 sessionExpired(U-3)', async () => { +test('refresh 也被拒 = 服务端判过期:上报 sessionExpired,但保留本地会话(U-3 离线优先)', async () => { const { handlers, ipcMain } = fakeIpcMain() const { io } = fakeIo() let meCalls = 0 @@ -167,8 +167,159 @@ test('refresh 也被拒 = 硬会话终点:本地清零并上报 sessionExpired register({ ipcMain, io, fetcher, readMachineBindingState: unbound }) await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) const status = await handlers.get('hermes:account:status')!(null) + // 在线行为不变(renderer 照旧落登录页)…… assert.deepEqual(status, { loggedIn: false, sessionExpired: true }) + // ……但会话不再被当场清掉:离线时它就是放行的凭证。 + assert.notEqual(loadUserAccount(io), null) +}) + +/** + * 可切换在线的 fetcher:断网时连调用都不记(`calls` 只收在线尝试), + * 「不联网」的断言因此可以直接数 calls。 + */ +function toggleableFetcher(isOnline: () => boolean) { + const calls: { url: string; init?: any }[] = [] + + const fetcher: FetchLike = async (url, init) => { + if (!isOnline()) {throw new Error('ECONNREFUSED')} + + calls.push({ url, init }) + + if (url.endsWith('/auth/login')) {return { ok: true, status: 200, json: async () => TOKENS, text: async () => ''}} + + if (url.endsWith('/me')) {return { ok: true, status: 200, json: async () => ME, text: async () => ''}} + + return { ok: true, status: 200, json: async () => ({}), text: async () => ''} + } + + return { fetcher, calls } +} + +test('服务端判过期后断网:本地会话仍在 → status 离线放行并回缓存 profile', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + let meCalls = 0 + + const { fetcher } = makeFetcher(url => { + if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }} + + if (url.endsWith('/auth/refresh')) {return { status: 401, text: 'session_inactive' }} + + if (url.endsWith('/me')) { + meCalls += 1 + + return meCalls === 1 ? { status: 200, body: ME } : { status: 401, text: 'unauthenticated' } + } + + return { status: 200, body: {} } + }) + + register({ ipcMain, io, fetcher, readMachineBindingState: unbound }) + await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) + + // 在线:判过期,落登录页 + assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false, sessionExpired: true }) + + // 断网:这次不再砖——本地会话还在就放行,缓存 profile 与数据时间照给 + const offline = toggleableFetcher(() => false) + + register({ ipcMain, io, fetcher: offline.fetcher, readMachineBindingState: unbound }) + const status = await handlers.get('hermes:account:status')!(null) + + assert.equal(status.loggedIn, true) + assert.equal(status.offline, true) + assert.equal(status.profile.email, 'u@example.com') + assert.equal(typeof status.profileAsOf, 'string') +}) + +test('断网登录:记住的凭据命中且会话在 → 离线放行(不新建会话、不落盘)', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + let online = true + + const probe = toggleableFetcher(() => online) + + register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: unbound }) + await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) + const before = loadUserAccount(io) + + online = false + const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) + + assert.equal(result.ok, true) + assert.equal(result.offline, true) + assert.equal(result.profile.email, 'u@example.com') + // 会话原样留着 + assert.deepEqual(loadUserAccount(io), before) +}) + +test('断网登录:口令不符 → invalid_credentials;站点/邮箱不命中 → network', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + let online = true + + const probe = toggleableFetcher(() => online) + + register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: unbound }) + // 先在线登录一次,把记住槽写下来(离线登录的前提就是"这台机器上登过") + await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) + + online = false + const attemptsBefore = probe.calls.length + + assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'other@example.com', password: 'p' }), { + ok: false, + code: 'network' + }) + assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'WRONG' }), { + ok: false, + code: 'invalid_credentials' + }) + // 断网期间一次网络尝试都没有 + assert.equal(probe.calls.length, attemptsBefore) +}) + +test('断网登录:本机无会话 → network(离线登入只解锁已有会话,不新建)', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + const { fetcher } = loginOkFetcher() + + register({ ipcMain, io, fetcher, readMachineBindingState: unbound }) + await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }) + await handlers.get('hermes:account:logout')!(null) assert.equal(loadUserAccount(io), null) + + // 登出不清记住槽(U-6 全清才清),但离线登不回来:没有会话就没有可放行的东西。 + const offline = toggleableFetcher(() => false) + + register({ ipcMain, io, fetcher: offline.fetcher, readMachineBindingState: unbound }) + assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }), { + ok: false, + code: 'network' + }) + assert.equal(loadUserAccount(io), null) +}) + +test('断网登录:绑定的站与登录的站不符 → machine_bound_to_other(本地可判的 U-5 部分)', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + const probe = toggleableFetcher(() => false) + + persistUserAccount( + { + session: { site: 'https://other', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' }, + profile: null, + profileAsOf: null + }, + io + ) + + register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: () => BINDING_STATE }) + assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://other', email: 'u', password: 'p' }), { + ok: false, + code: 'machine_bound_to_other', + binderEmail: null + }) }) test('relay 不可达但本地会话在:status 标离线并回缓存 profile + 数据时间(U-3)', async () => { @@ -351,6 +502,42 @@ test('register/reset 四通道透传路径与参数', async () => { assert.deepEqual(JSON.parse(calls[3].init.body), { email: 'u@e.c', code: '123456', password: 'new' }) }) +test('successful password resets clear remembered credentials only for the matching site and email', async () => { + const { handlers, ipcMain } = fakeIpcMain() + const { io } = fakeIo() + let success = true + + const { fetcher } = makeFetcher(() => success + ? { status: 200, body: {} } + : { status: 400, text: 'reset_code_invalid_or_expired' }) + + register({ ipcMain, io, fetcher, readMachineBindingState: unbound }) + const remembered = { site: 'https://r', email: 'owner@example.com', password: 'old-password' } + const reset = handlers.get('hermes:account:reset-confirm')! + + persistRememberedLogin(remembered, io) + + for (const account of [ + { site: 'https://other', email: remembered.email }, + { site: remembered.site, email: 'other@example.com' } + ]) { + assert.deepEqual(await reset(null, { ...account, code: '123456', password: 'new-password' }), { ok: true }) + assert.deepEqual(loadRememberedLogin(io), remembered) + } + + success = false + assert.deepEqual(await reset(null, { ...remembered, code: 'bad-code' }), { + ok: false, code: 'reset_code_invalid_or_expired' + }) + assert.deepEqual(loadRememberedLogin(io), remembered) + + success = true + assert.deepEqual(await reset(null, { + site: 'https://r/', email: 'OWNER@example.com', code: '123456', password: 'new-password' + }), { ok: true }) + assert.equal(loadRememberedLogin(io), null) +}) + test('register-start 失败码透传(weak_password / registration_rate_limited)', async () => { const { handlers, ipcMain } = fakeIpcMain() const { io } = fakeIo() diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts index 145b462701..2349e2e04f 100644 --- a/apps/desktop/electron/user-account-ipc.ts +++ b/apps/desktop/electron/user-account-ipc.ts @@ -5,8 +5,14 @@ * access/refresh token——login 之后 renderer 只能拿到 profile 与状态。 * * 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试; - * refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 唯一的 - * 硬会话终点(U-3)→ 本地清零并上报 sessionExpired,由 renderer 落登录页。 + * refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 服务端判定的 + * 硬终点(U-3):**不清本地会话**,只上报 sessionExpired——在线时 renderer 照旧 + * 落登录页,离线时本地会话仍放行(离线优先,2026-09-24 用户裁定)。真正的清零 + * 只发生在 logout / unbind。 + * + * 离线登录(同一裁定):断网时 login 回落到本地校验记住的凭据,把已在盘上的 + * 会话解锁回登录态。它只是"别砖在门口"的临时通路,不是第二套运行模式—— + * 不新建会话,也不放行登录以外的任何操作。 * * U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表; * 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于 @@ -111,7 +117,11 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { persistUserAccount(account, io) } - /** 401 → refresh 一次重试;refresh 也被拒 = 硬终点,本地清零。 */ + /** + * 401 → refresh 一次重试;refresh 也被拒 = 服务端判定的硬终点,往上报 + * session_expired,但**不清本地会话**——离线时它仍是放行的凭证(2026-09-24 + * 用户裁定"离线优先")。真正的清零只发生在 logout / unbind。 + */ async function withSession(fn: (session: RelayAccountSession) => Promise): Promise { const account = current() @@ -129,7 +139,6 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { refreshed = await relayAccountRefresh(account.session, fetcher) } catch (error) { if (error instanceof RelayAccountError && error.status === 401) { - store(null) throw new RelayAccountError('session_expired', 401) } @@ -178,6 +187,50 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { return { ok: false, code: 'machine_bound_to_other', binderEmail } } + /** + * 断网回落:本地校验记住的凭据,命中即放行(离线优先,2026-09-24 用户裁定)。 + * 前提是"这台机器上登录过这个账号"——记住槽只在登录成功时写、只在校验失败时 + * 清(unbind 全清),所以这里放行的账号,其会话与锁定在创建时都过了服务端。 + * + * 不新增暴露面:口令本来就以 safeStorage 加密存在本地供表单预填,这里只是把 + * 它当凭据用。能离线做的 U-5 复核照做(绑定状态损坏 fail-closed、绑定的站 + * 不符即拒);安装归属要联网查,离线判不了。 + */ + async function offlineLogin(site: string, email: string, password: string) { + // 没有会话就没有可解锁的东西:离线登入只把盘上已有的会话放回来,不新建。 + const account = current() + + if (!account) {return { ok: false, code: 'network' }} + + let binding + + try { + binding = loadMachineBinding(readMachineBindingState) + } catch (error) { + if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }} + + throw error + } + + if (binding && binding.site !== site) { + return { ok: false, code: 'machine_bound_to_other', binderEmail: null } + } + + const remembered = loadRememberedLogin(io) + + // 本地没有这个账号的凭据:离线无解,维持 network 文案等用户联网。 + if (!remembered || remembered.site !== site || remembered.email !== email) { + return { ok: false, code: 'network' } + } + + if (remembered.password !== password) { + return { ok: false, code: 'invalid_credentials' } + } + + // 会话已在盘上(含被服务端判过期的):用缓存 profile 恢复登录态,不落盘。 + return { ok: true, site, profile: account.profile, offline: true } + } + ipcMain.handle('hermes:account:login', async (_event, payload) => { let site: string @@ -209,6 +262,10 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { return { ok: true, site, profile } } catch (error) { + if (error instanceof RelayAccountError && error.code === 'network') { + return offlineLogin(site, email, password) + } + return failure(error) } }) @@ -509,13 +566,20 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => { try { const site = canonicalizeRelaySite(String(payload?.site ?? '')) + const email = String(payload?.email ?? '').trim() await relayAccountResetConfirm( site, - String(payload?.email ?? '').trim(), + email, String(payload?.code ?? '').trim(), String(payload?.password ?? ''), fetcher ) + // A successful reset invalidates the last successful login's password. + const remembered = loadRememberedLogin(io) + + if (remembered?.site === site && remembered.email.toLowerCase() === email.toLowerCase()) { + persistRememberedLogin(null, io) + } return { ok: true } } catch (error) { @@ -525,7 +589,7 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void { /** * 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。 - * 写入只发生在 login 成功(自动)与 unbind 全清(抹除)——renderer 无写口。 + * login 成功写入;reset 成功及 unbind 清除旧凭据——renderer 无写口。 */ ipcMain.handle('hermes:account:remembered:load', async () => { return loadRememberedLogin(io) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d2b246156b..70fce0b6e6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,10 +23,10 @@ "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", "dev:mock": "node ../../tests-js/scripts/mock-server.ts", "dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174", - "dev:electron": "node scripts/patch-dev-electron-branding.mjs && tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", - "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", - "start": "npm run build && electron .", + "dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 node scripts/launch-dev-electron.mjs .", + "profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 node scripts/launch-dev-electron.mjs --inspect=9229 .", + "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_CDP_PORT=9223 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 node scripts/launch-dev-electron.mjs .", + "start": "npm run build && node scripts/launch-dev-electron.mjs .", "prebuild": "node scripts/assert-root-install.mjs && npm run clean", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", @@ -97,6 +97,7 @@ "@lezer/highlight": "1.2.3", "@nanostores/react": "1.1.0", "@nous-research/ui": "0.18.2", + "@silurus/ooxml": "0.88.0", "@streamdown/code": "1.1.1", "@streamdown/math": "1.0.2", "@tabler/icons-react": "3.44.0", @@ -236,7 +237,7 @@ "entitlements": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.inherit.plist", "extendInfo": { - "CFBundleDisplayName": "Digital Assistant", + "CFBundleDisplayName": "DeepWork", "CFBundleExecutable": "Hermes", "CFBundleName": "Hermes", "NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.", diff --git a/apps/desktop/scripts/launch-dev-electron.mjs b/apps/desktop/scripts/launch-dev-electron.mjs new file mode 100644 index 0000000000..19a07ee082 --- /dev/null +++ b/apps/desktop/scripts/launch-dev-electron.mjs @@ -0,0 +1,21 @@ +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' + +import { prepareBrandedElectron } from './patch-dev-electron-branding.mjs' + +const require = createRequire(import.meta.url) +const binary = process.platform === 'darwin' ? prepareBrandedElectron() : require('electron') +const child = spawn(binary, process.argv.slice(2), { stdio: 'inherit' }) + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGUSR2']) { + process.on(signal, () => child.kill(signal)) +} + +child.on('error', error => { + console.error('[desktop] Electron launch failed:', error) + process.exitCode = 1 +}) + +child.on('close', code => { + process.exitCode = code ?? 1 +}) diff --git a/apps/desktop/scripts/patch-dev-electron-branding.mjs b/apps/desktop/scripts/patch-dev-electron-branding.mjs index 072fa69f91..2f315a0aea 100644 --- a/apps/desktop/scripts/patch-dev-electron-branding.mjs +++ b/apps/desktop/scripts/patch-dev-electron-branding.mjs @@ -1,36 +1,64 @@ -// Dev-mode Dock tooltip: `electron .` runs the stock Electron.app bundle, whose -// CFBundleDisplayName is "Electron" — so hovering the Dock icon says "Electron" -// no matter what app.setName() does (macOS reads the bundle, not the runtime -// name). Patch the dev bundle's display name to match the packaged brand. -// Idempotent; no-op off macOS or when electron isn't installed. Deliberately -// does NOT touch the main-process APP_NAME — that one keys the userData path. +// macOS reads the Dock label from the launched .app bundle. Patching the stock +// Electron.app plist alone still leaves the running tile named "Electron", so +// launch a branded copy while keeping node_modules/electron's original intact. import { execFileSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { constants, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -const DISPLAY_NAME = 'Digital Assistant' +const DISPLAY_NAME = 'DeepWork' +const BUNDLE_ID = 'com.nousresearch.hermes.dev' +const CACHE_FORMAT = '3' +const BUDDY = '/usr/libexec/PlistBuddy' +const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -if (process.platform === 'darwin') { - const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') - const plist = path.join(desktopRoot, 'node_modules/electron/dist/Electron.app/Contents/Info.plist') - - if (existsSync(plist)) { - const buddy = '/usr/libexec/PlistBuddy' - const read = key => { - try { - return execFileSync(buddy, ['-c', `Print :${key}`, plist], { encoding: 'utf8' }).trim() - } catch { - return '' - } - } - const write = (key, value) => - execFileSync(buddy, ['-c', `Set :${key} ${value}`, plist], { stdio: 'ignore' }) - - for (const key of ['CFBundleName', 'CFBundleDisplayName']) { - if (read(key) !== DISPLAY_NAME) { - write(key, DISPLAY_NAME) - } - } - } +function plistValue(plist, key) { + return execFileSync(BUDDY, ['-c', `Print :${key}`, plist], { encoding: 'utf8' }).trim() +} + +function setPlistValue(plist, key, value) { + execFileSync(BUDDY, ['-c', `Set :${key} ${value}`, plist], { stdio: 'ignore' }) +} + +export function prepareBrandedElectron({ + electronDist = path.join(DESKTOP_ROOT, 'node_modules/electron/dist'), + cacheRoot = path.join(DESKTOP_ROOT, 'node_modules/.cache/hermes-desktop') +} = {}) { + const source = path.join(electronDist, 'Electron.app') + const sourcePlist = path.join(source, 'Contents/Info.plist') + const version = plistValue(sourcePlist, 'CFBundleShortVersionString') + const versionRoot = path.join(cacheRoot, version) + const branded = path.join(versionRoot, `${DISPLAY_NAME}.app`) + const binary = path.join(branded, 'Contents/MacOS/Electron') + const marker = path.join(branded, '.hermes-electron-version') + const cacheKey = `${version}:${CACHE_FORMAT}` + + if (existsSync(binary) && existsSync(marker) && readFileSync(marker, 'utf8') === cacheKey) { + return binary + } + + mkdirSync(versionRoot, { recursive: true }) + + const temporary = path.join(versionRoot, `${DISPLAY_NAME}.app.tmp-${process.pid}`) + + rmSync(temporary, { recursive: true, force: true }) + + try { + cpSync(source, temporary, { recursive: true, mode: constants.COPYFILE_FICLONE, verbatimSymlinks: true }) + + const plist = path.join(temporary, 'Contents/Info.plist') + + setPlistValue(plist, 'CFBundleName', DISPLAY_NAME) + setPlistValue(plist, 'CFBundleDisplayName', DISPLAY_NAME) + setPlistValue(plist, 'CFBundleIdentifier', BUNDLE_ID) + writeFileSync(path.join(temporary, '.hermes-electron-version'), cacheKey) + + rmSync(branded, { recursive: true, force: true }) + renameSync(temporary, branded) + } catch (error) { + rmSync(temporary, { recursive: true, force: true }) + throw error + } + + return binary } diff --git a/apps/desktop/scripts/patch-dev-electron-branding.test.mjs b/apps/desktop/scripts/patch-dev-electron-branding.test.mjs new file mode 100644 index 0000000000..f5f56a2992 --- /dev/null +++ b/apps/desktop/scripts/patch-dev-electron-branding.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { prepareBrandedElectron } from './patch-dev-electron-branding.mjs' + +const BUDDY = '/usr/libexec/PlistBuddy' + +function plistValue(plist, key) { + return execFileSync(BUDDY, ['-c', `Print :${key}`, plist], { encoding: 'utf8' }).trim() +} + +test('dev Electron launches from a versioned DeepWork.app without changing the stock bundle', { + skip: process.platform !== 'darwin' +}, () => { + const root = mkdtempSync(path.join(tmpdir(), 'hermes-dock-brand-')) + + try { + const electronDist = path.join(root, 'dist') + const cacheRoot = path.join(root, 'cache') + const source = path.join(electronDist, 'Electron.app/Contents') + const sourcePlist = path.join(source, 'Info.plist') + + mkdirSync(path.join(source, 'MacOS'), { recursive: true }) + mkdirSync(path.join(source, 'Frameworks/Test.framework/Versions/A/Resources'), { recursive: true }) + writeFileSync(path.join(source, 'Frameworks/Test.framework/Versions/A/Resources/icudtl.dat'), 'icu') + symlinkSync('A', path.join(source, 'Frameworks/Test.framework/Versions/Current')) + writeFileSync(path.join(source, 'MacOS/Electron'), 'binary') + writeFileSync(sourcePlist, ` + +CFBundleNameElectron +CFBundleDisplayNameElectron +CFBundleExecutableElectron +CFBundleIdentifiercom.github.Electron +CFBundleShortVersionString1.0 +`) + + const options = { electronDist, cacheRoot } + const binary = prepareBrandedElectron(options) + const brandedPlist = path.join(cacheRoot, '1.0/DeepWork.app/Contents/Info.plist') + + assert.equal(binary, path.join(cacheRoot, '1.0/DeepWork.app/Contents/MacOS/Electron')) + assert.equal(plistValue(brandedPlist, 'CFBundleDisplayName'), 'DeepWork') + assert.equal(plistValue(brandedPlist, 'CFBundleExecutable'), 'Electron') + assert.equal(plistValue(brandedPlist, 'CFBundleIdentifier'), 'com.nousresearch.hermes.dev') + assert.equal(plistValue(sourcePlist, 'CFBundleName'), 'Electron') + const copiedFramework = path.join(cacheRoot, '1.0/DeepWork.app/Contents/Frameworks/Test.framework/Versions') + assert.equal(readlinkSync(path.join(copiedFramework, 'Current')), 'A') + assert.equal(readFileSync(path.join(copiedFramework, 'Current/Resources/icudtl.dat'), 'utf8'), 'icu') + + const sentinel = path.join(cacheRoot, '1.0/DeepWork.app/sentinel') + + writeFileSync(sentinel, 'keep') + assert.equal(prepareBrandedElectron(options), binary) + assert.equal(readFileSync(sentinel, 'utf8'), 'keep') + + writeFileSync(path.join(cacheRoot, '1.0/DeepWork.app/.hermes-electron-version'), 'stale') + assert.equal(prepareBrandedElectron(options), binary) + assert.equal(existsSync(sentinel), false) + assert.equal(readFileSync(path.join(copiedFramework, 'Current/Resources/icudtl.dat'), 'utf8'), 'icu') + assert.equal(readFileSync(path.join(cacheRoot, '1.0/DeepWork.app/.hermes-electron-version'), 'utf8').startsWith('1.0:'), true) + + writeFileSync(sentinel, 'keep') + + execFileSync(BUDDY, ['-c', 'Set :CFBundleShortVersionString 2.0', sourcePlist]) + const updatedBinary = prepareBrandedElectron(options) + + assert.equal(updatedBinary, path.join(cacheRoot, '2.0/DeepWork.app/Contents/MacOS/Electron')) + assert.equal(readFileSync(sentinel, 'utf8'), 'keep') + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/src/api/freemodel2api.ts b/apps/desktop/src/api/freemodel2api.ts index 27040bbb4d..f47d6d3e50 100644 --- a/apps/desktop/src/api/freemodel2api.ts +++ b/apps/desktop/src/api/freemodel2api.ts @@ -3,6 +3,8 @@ export interface FreeModel2ApiStatus { baseUrl?: string email?: string balance?: number + username?: string + frozenBalance?: number } export interface FreeModel2ApiKey { @@ -45,3 +47,8 @@ export function fm2RememberedLoad(): Promise { + return window.hermesDesktop.freemodel2api.keyBalance({ baseUrl, apiKey }) +} diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx index 8a756b56ff..316956b2c5 100644 --- a/apps/desktop/src/app/chat/composer/attachments.test.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx @@ -132,7 +132,7 @@ describe('AttachmentList', () => { expect(lightboxImage?.getAttribute('src')).toBe(DATA_URL) await act(async () => { - fireEvent.click(lightboxImage!) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) }) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index b87e69068f..580dc30a17 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -216,7 +216,6 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme {lightboxSrc && ( { setLightboxOpen(open) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 69e7b74320..4fd31005f3 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -10,22 +10,18 @@ import type { TriggerState } from './text-utils' export const COMPOSER_STACK_BREAKPOINT_PX = 320 -// Above the stack breakpoint but still cramped: the model pill sheds its label -// for its chevron icon so the controls stop crowding the input before the whole -// row has to stack. Progressive collapse: full pill → icon pill → stacked. +// Above the tight-control breakpoint but still cramped: the model pill sheds +// its label for its chevron icon so the controls keep room for the input rail. +// Progressive collapse: full pill → icon pill → compact controls. // // Sized off what the controls actually cost, because guessing put the two // stages on top of each other. With the full pill the controls take ~284px -// (pill 111 + the icon cluster), so at the old 440 the inline input was ~156px -// — barely over its 128px minimum. A few words wrapped, wrapping is what -// stacks the row, and the pill's chevron arrived at the same moment the row -// gave up, which is the one thing progressive collapse is supposed to avoid. -// At 560 the label goes while the input still has ~276px, and the ~110px the -// chevron frees is spent keeping the row single for another stretch. +// (pill 111 + the icon cluster), so the label drops at 560px and the ~110px the +// chevron frees is available to the input rail. export const COMPOSER_COMPACT_PILL_PX = 560 -// The ladder keeps going below the stack breakpoint — a pane can be far -// narrower than even the stacked controls row. Both rungs are budgeted +// The ladder keeps going below the tight-control breakpoint — a pane can be far +// narrower than even the controls row. Both rungs are budgeted // against that row's real cost: menu ~24 + surface padding 16 + the cluster // (~190; ~218 mid-turn with the queue button). // @@ -42,11 +38,6 @@ export const COMPOSER_FOLD_VOICE_PX = 260 // spare at any width the layout tree allows (~74 all-in). export const COMPOSER_MINIMAL_PX = 180 -// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem -// vertical padding). Anything taller means the text wrapped to a second line, -// which is when the composer should expand to the stacked layout. -export const COMPOSER_SINGLE_LINE_MAX_PX = 36 - export const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.test.tsx index cf082d0202..389399876b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.test.tsx @@ -6,10 +6,6 @@ import { COMPOSER_HEIGHT_VAR, COMPOSER_SURFACE_HEIGHT_VAR } from '@/app/chat/sur import { useComposerMetrics } from './use-composer-metrics' -vi.mock('@assistant-ui/react', () => ({ - useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }) -})) - // The shared ResizeObserver delivers once per observed element after // observe(); replay that by hand so the hook's initial measurement runs where // it would in Chromium. @@ -60,16 +56,15 @@ function Harness({ dockHeight, surfaceHeight }: { dockHeight: number; surfaceHei const composerDockRef = useRef(null) const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const editorRef = useRef(null) - useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, editorRef, poppedOut: false }) + useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, poppedOut: false }) return (
-
+
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index dbc0ab24b0..f4b17be7b3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -1,4 +1,3 @@ -import { useAuiState } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { @@ -14,7 +13,6 @@ import { COMPOSER_COMPACT_PILL_PX, COMPOSER_FOLD_VOICE_PX, COMPOSER_MINIMAL_PX, - COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' @@ -22,7 +20,6 @@ interface UseComposerMetricsArgs { composerDockRef: RefObject composerRef: RefObject composerSurfaceRef: RefObject - editorRef: RefObject poppedOut: boolean } @@ -46,59 +43,25 @@ const fitForWidth = (width: number): ComposerFit => ({ const sameFit = (a: ComposerFit, b: ComposerFit) => a.compactPill === b.compactPill && a.foldVoice === b.foldVoice && a.minimal === b.minimal && a.tight === b.tight -interface UseComposerMetricsResult extends ComposerFit { - stacked: boolean -} +type UseComposerMetricsResult = ComposerFit /** - * Owns the composer's *sizing* engine: the stacked-vs-inline layout decision - * and the measured-height CSS vars the thread reads for bottom clearance. All - * work is edge-gated — the ResizeObserver only fires on real size changes, the - * height vars are 8px-bucketed so per-keystroke growth never invalidates the - * tree's computed style, and the fit only re-renders when it crosses a stage. + * Owns the composer's sizing engine: width-driven control collapse and the + * measured-height CSS vars the thread reads for bottom clearance. The input + * and controls are always separate rows, so this hook only resolves control + * density. All work is edge-gated — the ResizeObserver only fires on real size + * changes, the height vars are 8px-bucketed so per-keystroke growth never + * invalidates the tree's computed style, and the fit only re-renders when it + * crosses a stage. */ export function useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, - editorRef, poppedOut }: UseComposerMetricsArgs): UseComposerMetricsResult { - const [expanded, setExpanded] = useState(false) const [fit, setFit] = useState(ROOMY) - // Edge signals, not the live text: these only re-render when emptiness / the - // presence of a non-trailing newline actually flips, so typing within a line - // costs nothing here. - const isEmpty = useAuiState(s => s.composer.text.length === 0) - const hasHardNewline = useAuiState(s => s.composer.text.trimEnd().includes('\n')) - - // Expansion (input on its own full-width row, controls below) is driven by - // the editor's *actual* rendered height via the ResizeObserver in - // syncComposerMetrics — it only fires when the text genuinely wraps to a - // second line, so the layout flips exactly at the wrap point rather than at - // a guessed character count. We only handle the two cases the observer - // can't: an explicit newline (expand before layout settles) and an emptied - // draft (collapse back). We never read scrollHeight per keystroke. - useEffect(() => { - if (isEmpty) { - setExpanded(false) - - return - } - - if (expanded) { - return - } - - // Only a non-trailing newline forces an immediate expand. A trailing newline - // (or phantom \n from contenteditable junk) is left to the ResizeObserver, - // which expands only when the editor's real height actually grows. - if (hasHardNewline) { - setExpanded(true) - } - }, [expanded, hasHardNewline, isEmpty]) - // Bucket measured heights so we only invalidate the global CSS var when // the size crosses a meaningful threshold. Without bucketing, the editor // grows ~1px per character → setProperty fires every keystroke → entire @@ -152,18 +115,6 @@ export function useComposerMetrics({ } } - // Expand once the input has actually wrapped past a single line. The - // observer only fires on real size changes, so this reads scrollHeight at - // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem - // min-height + padding); a second line clears ~36px. We only ever expand - // here — collapse is handled by the emptied-draft effect to avoid - // oscillating across the wrap boundary as the input switches widths. - const editor = editorRef.current - - if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { - setExpanded(true) - } - if (height > 0) { const bucket = Math.round(height / 8) * 8 @@ -181,9 +132,9 @@ export function useComposerMetrics({ setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`) } } - }, [composerDockRef, composerRef, composerSurfaceRef, editorRef]) + }, [composerDockRef, composerRef, composerSurfaceRef]) - useResizeObserver(syncComposerMetrics, composerDockRef, composerRef, composerSurfaceRef, editorRef) + useResizeObserver(syncComposerMetrics, composerDockRef, composerRef, composerSurfaceRef) // Toggling pop-out changes whether the composer reserves thread clearance. // The ResizeObserver may not fire (the box can keep the same box size), so @@ -217,23 +168,12 @@ export function useComposerMetrics({ }, [composerRef]) // Every decision comes from the composer's OWN measured width, never the - // viewport's. There used to be a `(max-width: 30rem)` media query in here as - // well, and it quietly outranked everything: any window under 480px stacked - // the row AND compacted the pill in the same instant, regardless of how much - // room the composer actually had. That collapsed the whole progressive ladder - // into one step for small windows — HUD mode is ~470px, so it never saw the - // ladder at all — and it disagreed with the measured breakpoints (320 to - // stack) by 160px. The ResizeObserver knows the real width; the viewport is - // not a proxy for it. - // - // The ladder is monotonic: each stage implies the ones above it, so the pill - // is always compact by the time the row stacks, and the voice controls are - // always folded before minimal drops them. + // viewport's. The input/control split is fixed; the ladder only reduces + // control density as the available width shrinks. return { compactPill: fit.compactPill || fit.tight, foldVoice: fit.foldVoice || fit.minimal, minimal: fit.minimal, - stacked: expanded || fit.tight, tight: fit.tight } } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index cb0a16cb37..fce1c20b45 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -360,11 +360,10 @@ export function ChatBar({ return onCancel() }, [activeQueueSessionKeyRef, onCancel]) - const { compactPill, foldVoice, minimal, stacked } = useComposerMetrics({ + const { compactPill, foldVoice, minimal } = useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, - editorRef, poppedOut }) @@ -1108,17 +1107,16 @@ export function ChatBar({ ) const input = ( -
+
0 && }
{contextMenu} diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 104689fc03..a8b45c2857 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -9,6 +9,7 @@ import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/u import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' +import { StatusRow } from '@/components/chat/status-row' import { FreeTierNoticeStrip, useFreeTierNoticeOwner } from '@/components/free-tier/notice-strip' import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { Button } from '@/components/ui/button' @@ -29,6 +30,7 @@ import { stopBackgroundProcess } from '@/store/composer-status' import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier' +import { $desktopOnboarding, startManualOnboarding } from '@/store/onboarding' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control' import { $threadScrolledUpBySession } from '@/store/thread-scroll' @@ -122,6 +124,12 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat // notice once — and a non-owning stack adds no empty row to its card. const ownsFreeTierNotice = useFreeTierNoticeOwner() const freeTierNotice = ownsFreeTierNotice && freeTierStripPending(freeTierStatus, freeTierRoute) + // The unconfigured first-run wizard became a hint: the overlay only opens on + // demand, this strip is what surfaces the pending setup. + const setupPending = useStoreSelector( + $desktopOnboarding, + state => state.configured === false && !state.manual + ) const isStructuredSupported = controlEntry?.capability === 'supported' @@ -205,6 +213,10 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat sections.push({ key: 'free-tier', node: }) } + if (setupPending) { + sections.push({ key: 'setup-pending', node: }) + } + const hasControlContent = Boolean( controlEntry && (controlEntry.error || @@ -336,3 +348,29 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
) } + +function SetupPendingStrip() { + const { t } = useI18n() + + return ( + } + trailing={ + + } + trailingVisible + > + + {t.statusStack.setupPendingTitle} + + + ) +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 416b443c2b..01c919fb37 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -739,7 +739,6 @@ const ChatViewContent = memo(function ChatViewContent({ data-composer-target={composerScope.target} data-session-anchor={sessionAnchor} > - {/* Tiles get their chrome from the layout zone (chip strip); the modal prompt overlays stay active-session-scoped in the primary surface. */} {isPrimary && ( @@ -751,7 +750,7 @@ const ChatViewContent = memo(function ChatViewContent({ selectedSessionId={selectedSessionId} /> )} - {isPrimary && onChangeWorkspace && } + {isPrimary && onChangeWorkspace && } {/* Mounted for the primary AND every tile, each scoped to its own session so a tiled/background session's blocking prompt surfaces instead of @@ -771,6 +770,9 @@ const ChatViewContent = memo(function ChatViewContent({ data-slot="composer-bounds" {...dropHandlers} > + {/* Keep the optional image texture inside the conversation body. The + title/workspace chrome should remain a clean, continuous band. */} + { dir?: (tile: T) => TileDock | undefined /** Pane to dock against (default `workspace`) — a drop's target zone. */ anchor?: (tile: T) => string | undefined + /** Where a top-edge zone should render its tab strip. */ + titlebar?: 'content' | 'window' + /** Background surface for a zone containing only these panes. */ + headerSurface?: 'chat' | 'sidebar' /** Center docks: the strip slot (stack before this pane id). */ before?: (tile: T) => null | string | undefined minWidth: string @@ -89,6 +93,8 @@ export function paneMirror(cfg: PaneMirror): () => void { }, minWidth: cfg.minWidth, newTab: cfg.newTab?.(key), + headerSurface: cfg.headerSurface, + titlebar: cfg.titlebar, // Every mirrored tile is a full workspace surface docked beside main — // and closeable, which is what keeps its tab when it lands in a zone of // its own (see strip-visibility.ts). diff --git a/apps/desktop/src/app/chat/preview-tile.test.ts b/apps/desktop/src/app/chat/preview-tile.test.ts index d5cea44709..ec19437a7a 100644 --- a/apps/desktop/src/app/chat/preview-tile.test.ts +++ b/apps/desktop/src/app/chat/preview-tile.test.ts @@ -9,6 +9,8 @@ vi.mock('./right-rail/preview-console-store', () => ({ })) import { registry } from '@/contrib/registry' +import { group } from '@/components/pane-shell/tree/model' +import { $layoutTree } from '@/components/pane-shell/tree/store' import { $previewTabs, closeRightRail, noteBrowserPage, openPreview } from '@/store/preview' import { browserTabExternalUrl, browserTabLabel, watchPreviewTiles } from './preview-tile' @@ -19,6 +21,7 @@ beforeAll(() => { afterEach(() => { closeRightRail() + $layoutTree.set(null) }) describe('browserTabLabel', () => { @@ -89,6 +92,14 @@ const fileTarget = (path: string) => ({ kind: 'file', label: path.split('/').at(-1) ?? path, path, source: path, url: path }) as const describe('preview tiles stack, not split (#93610)', () => { + it('joins the working-directory tab group when files is present', () => { + $layoutTree.set(group(['files'], { id: 'grp-files' })) + + openPreview(fileTarget('/tmp/a.ts'), 'file-browser') + + expect(dockOf('preview-tile:file:/tmp/a.ts')).toMatchObject({ pane: 'files', pos: 'center' }) + }) + it('docks the first preview right and stacks the second as a center tab in the same zone', () => { openPreview(fileTarget('/tmp/a.ts'), 'file-browser') diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7777cf954c..f94e748d4d 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -12,14 +12,20 @@ import { useStore } from '@nanostores/react' -import { findGroup } from '@/components/pane-shell/tree/model' -import { $activeTreeGroup, $layoutTree, revealTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model' +import { + $activeTreeGroup, + $layoutTree, + moveTreePaneBeside, + revealTreePane, + treePanesWithPrefix +} from '@/components/pane-shell/tree/store' import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { FileTypeIcon } from '@/components/ui/file-type-icon' import { ToolIcon } from '@/components/ui/tool-icon' import { translateNow } from '@/i18n' import { openExternalLink } from '@/lib/external-link' -import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from '@/store/layout' +import { $rightRailActiveTabId, FILES_PANE_ID, type RightRailTabId, selectRightRailTab } from '@/store/layout' import { $browserPages, $dockedPreviewTabs, @@ -64,31 +70,48 @@ export function browserTabExternalUrl(tabId: string): null | string { } function browserTabMenuPrefix(tabId: string) { - if (targetFor(tabId)?.kind !== 'url') { - return undefined - } - return (kit: MenuKit) => ( <> - {canOpenBrowserWindow() - ? renderActionItem(kit, { - icon: 'empty-window', - key: 'pop-out', - label: translateNow('preview.popOut'), - onSelect: () => popOutBrowserTab(tabId) - }) - : null} {renderActionItem(kit, { - disabled: !browserTabExternalUrl(tabId), - icon: 'link-external', - key: 'open-external', - label: translateNow('preview.openInExternal'), - onSelect: () => openExternalLink(browserTabExternalUrl(tabId) ?? '') + icon: 'layout-sidebar-right', + key: 'move-to-workspace', + label: translateNow('preview.moveToWorkspace'), + onSelect: () => movePreviewToWorkspace(tabId) })} + {targetFor(tabId)?.kind === 'url' ? : null} + {targetFor(tabId)?.kind === 'url' ? ( + <> + {canOpenBrowserWindow() + ? renderActionItem(kit, { + icon: 'empty-window', + key: 'pop-out', + label: translateNow('preview.popOut'), + onSelect: () => popOutBrowserTab(tabId) + }) + : null} + {renderActionItem(kit, { + disabled: !browserTabExternalUrl(tabId), + icon: 'link-external', + key: 'open-external', + label: translateNow('preview.openInExternal'), + onSelect: () => openExternalLink(browserTabExternalUrl(tabId) ?? '') + })} + + ) : null} ) } +/** Move a preview tab into a split beside the main Workspace pane. */ +export function movePreviewToWorkspace(tabId: string) { + if (!targetFor(tabId)) { + return + } + + moveTreePaneBeside(previewPaneId(tabId), 'workspace') + selectRightRailTab(tabId as RightRailTabId) +} + /** Tab title. A URL tab is titled by the CONTRIBUTION as the surface — see * `BrowserTabLabel` for the live page name the strip actually renders — so * navigating doesn't re-register the pane on every hop. A file names the @@ -173,12 +196,19 @@ const PREVIEW_TILE_PREFIX = 'preview-tile' const previewPaneId = (tabId: string) => `${PREVIEW_TILE_PREFIX}:${tabId}` -/** The pane a NEW preview tile should stack into: another preview tile already - * in the tree, else another open tab adopted earlier in the same pass (a - * reload restores every tab at once, before any of them is in the tree). - * `undefined` means this is the first preview — it opens its own zone. */ +/** The pane a NEW preview tile should stack into. When the working-directory + * pane is present, it owns the shared Workspace-tools tab group: the tree and + * every preview are tabs in one place. Layouts without a files pane retain + * the old preview-only zone as a fallback. */ function existingPreviewAnchor(tabId: string): string | undefined { const own = previewPaneId(tabId) + + const tree = $layoutTree.get() + + if (tree && findGroupOfPane(tree, FILES_PANE_ID)) { + return FILES_PANE_ID + } + const inTree = treePanesWithPrefix(`${PREVIEW_TILE_PREFIX}:`).find(id => id !== own) if (inTree) { @@ -251,13 +281,13 @@ const watchPreviewTileMirror = paneMirror<{ id: string }>({ // `openPreview` ran and the click looked like a no-op. key: tab => tab.id, prefix: PREVIEW_TILE_PREFIX, - // The FIRST preview still opens its own zone docked beside main (identical - // to route tiles — NOT anchored to the file tree, so ⌘J can't take it - // along). Every SUBSEQUENT preview stacks into that zone as a center tab: - // without the anchor each opened file split a new zone off the right edge - // (#93610), turning three file opens into three ever-narrower columns. + // With a working-directory pane, the FIRST preview joins its tab group and + // every later preview stacks there too. Layouts without `files` retain the + // preview-only fallback zone; without an anchor each opened file would split + // a new zone off the right edge (#93610). dir: tab => (existingPreviewAnchor(tab.id) ? 'center' : 'right'), anchor: tab => existingPreviewAnchor(tab.id), + titlebar: 'content', minWidth: '22rem', title: previewTitle, tabLead: tabId => , diff --git a/apps/desktop/src/app/chat/right-rail/office-preview.test.tsx b/apps/desktop/src/app/chat/right-rail/office-preview.test.tsx new file mode 100644 index 0000000000..8ed3757eaa --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/office-preview.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The real package ships a WASM parser and a render worker; both are pointless +// here. The fakes record what the component asked of them. +const state = vi.hoisted(() => ({ + docx: [] as any[], + pptx: [] as any[], + loadError: null as Error | null +})) + +vi.mock('@silurus/ooxml/docx', () => ({ + DocxScrollViewer: class { + destroy = vi.fn() + load = vi.fn(() => (state.loadError ? Promise.reject(state.loadError) : Promise.resolve())) + options: unknown + + constructor(_host: HTMLElement, options: unknown) { + this.options = options + state.docx.push(this) + } + } +})) +vi.mock('@silurus/ooxml/pptx', () => ({ + PptxScrollViewer: class { + destroy = vi.fn() + load = vi.fn(() => (state.loadError ? Promise.reject(state.loadError) : Promise.resolve())) + options: unknown + + constructor(_host: HTMLElement, options: unknown) { + this.options = options + state.pptx.push(this) + } + } +})) + +import { OfficePreview } from './office-preview' + +const bytes = new Uint8Array([1, 2, 3]).buffer + +beforeEach(() => { + state.docx.length = 0 + state.pptx.length = 0 + state.loadError = null +}) + +afterEach(() => { + cleanup() +}) + +describe('OfficePreview', () => { + it('drives the docx viewer with the bytes it was handed', async () => { + const onError = vi.fn() + + render() + + await waitFor(() => expect(state.docx).toHaveLength(1)) + expect(state.pptx).toHaveLength(0) + expect(state.docx[0].load).toHaveBeenCalledWith(bytes) + expect(state.docx[0].options).toMatchObject({ enableTextSelection: true, onError }) + expect(onError).not.toHaveBeenCalled() + }) + + it('drives the pptx viewer with the bytes it was handed', async () => { + render() + + await waitFor(() => expect(state.pptx).toHaveLength(1)) + expect(state.docx).toHaveLength(0) + expect(state.pptx[0].load).toHaveBeenCalledWith(bytes) + }) + + it('destroys the viewer on unmount', async () => { + const { unmount } = render() + + await waitFor(() => expect(state.docx).toHaveLength(1)) + unmount() + expect(state.docx[0].destroy).toHaveBeenCalled() + }) + + it('rebuilds the viewer when the bytes change, tearing the old one down', async () => { + const onError = vi.fn() + const { rerender } = render() + + await waitFor(() => expect(state.docx).toHaveLength(1)) + rerender() + + await waitFor(() => expect(state.docx).toHaveLength(2)) + expect(state.docx[0].destroy).toHaveBeenCalled() + }) + + it('routes a rejected load to onError instead of leaving it unhandled', async () => { + state.loadError = new Error('bad zip') + + const onError = vi.fn() + + render() + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)) + expect(onError.mock.calls[0][0].message).toBe('bad zip') + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/office-preview.tsx b/apps/desktop/src/app/chat/right-rail/office-preview.tsx new file mode 100644 index 0000000000..690f5ce8a2 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/office-preview.tsx @@ -0,0 +1,74 @@ +/** office-preview.tsx — read-only inline Word/PowerPoint rendering. + * + * @silurus/ooxml draws a document into a caller-owned container via + * Docx/PptxScrollViewer: construct, hand it the raw bytes, destroy on the way + * out. Both formats share that shape, so one effect covers the pair. + * + * The package is imported dynamically: it carries a WASM parser per format + * (≈1.9 MB each) plus a render worker, none of which belong on the launch path. + */ +import { useEffect, useRef } from 'react' + +export type InlineOfficeFormat = 'docx' | 'pptx' + +export function OfficePreview({ + bytes, + format, + onError +}: { + bytes: ArrayBuffer + format: InlineOfficeFormat + onError: (error: Error) => void +}) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + + if (!host) { + return + } + + let viewer: { destroy(): void } | null = null + let cancelled = false + + void (async () => { + try { + const ScrollViewer = + format === 'docx' + ? (await import('@silurus/ooxml/docx')).DocxScrollViewer + : (await import('@silurus/ooxml/pptx')).PptxScrollViewer + + if (cancelled) { + return + } + + const instance = new ScrollViewer(host, { enableTextSelection: true, onError }) + + viewer = instance + + // The import above awaits, so an unmount can land after the check and + // before the constructor. Re-check, and tear down what we just built. + if (cancelled) { + viewer = null + instance.destroy() + + return + } + + await instance.load(bytes) + } catch (error) { + if (!cancelled) { + onError(error instanceof Error ? error : new Error(String(error))) + } + } + })() + + return () => { + cancelled = true + viewer?.destroy() + } + }, [bytes, format, onError]) + + return
+} diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx index 01564253c3..49c0d1cf59 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.test.tsx @@ -1,9 +1,35 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as OfficeFiles from '@/lib/office-files' + const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() })) -vi.mock('@/lib/office-files', () => ({ openFileWithSystemApp })) +// Only the OS hand-off is stubbed: inlineOfficeFormat/bytesFromDataUrl stay real +// so the extension routing under test is the shipping one. +vi.mock('@/lib/office-files', async importOriginal => ({ + ...(await importOriginal()), + openFileWithSystemApp +})) + +const loadDocx = vi.hoisted(() => vi.fn(async () => {})) + +vi.mock('@silurus/ooxml/docx', () => ({ + DocxScrollViewer: class { + destroy = vi.fn() + load = loadDocx + + constructor(_host: HTMLElement, _options: unknown) {} + } +})) +vi.mock('@silurus/ooxml/pptx', () => ({ + PptxScrollViewer: class { + destroy = vi.fn() + load = vi.fn(async () => {}) + + constructor(_host: HTMLElement, _options: unknown) {} + } +})) import { LocalFilePreview, MarkdownPreview } from './preview-file' @@ -60,13 +86,15 @@ const binaryTarget = { binary: true, byteSize: 8_000, kind: 'file' as const, - label: 'report.docx', + // Deliberately NOT an Office extension: .docx/.pptx now render inline, so a + // fixture named after one would never reach the refusal screen this exercises. + label: 'firmware.bin', language: 'text', large: false, - path: '/w/report.docx', + path: '/w/firmware.bin', previewKind: 'binary' as const, - source: '/w/report.docx', - url: 'file:///w/report.docx' + source: '/w/firmware.bin', + url: 'file:///w/firmware.bin' } describe('LocalFilePreview binary refusal', () => { @@ -87,7 +115,7 @@ describe('LocalFilePreview binary refusal', () => { expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull() fireEvent.click(primary) - expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/report.docx') + expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/firmware.bin') }) it('keeps Preview anyway primary for merely large text files', () => { @@ -102,3 +130,52 @@ describe('LocalFilePreview binary refusal', () => { expect(screen.queryByRole('button', { name: 'Open with system app' })).toBeNull() }) }) + +describe('LocalFilePreview inline Office', () => { + beforeEach(() => { + openFileWithSystemApp.mockReset().mockResolvedValue(true) + loadDocx.mockClear() + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => 'data:application/octet-stream;base64,AQID') } + }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'hermesDesktop') + }) + + it('renders a .docx inline, past the refusal screen it used to hit', async () => { + render( + + ) + + await waitFor(() => expect(loadDocx).toHaveBeenCalled()) + expect(screen.queryByText('This looks like a binary file')).toBeNull() + expect(screen.queryByRole('button', { name: 'Open with system app' })).toBeNull() + }) + + it('falls back to the system app when the bytes never arrive', async () => { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => Promise.reject(new Error('gateway unreachable'))) } + }) + + render( + + ) + + const primary = await screen.findByRole('button', { name: 'Open with system app' }) + + expect(loadDocx).not.toHaveBeenCalled() + fireEvent.click(primary) + expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/spec.docx') + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 198935ad61..0722cb6fae 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -7,7 +7,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react' -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Streamdown } from 'streamdown' import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus' @@ -34,13 +34,16 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo' import { isComposerChord } from '@/lib/keybinds/chords' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess' -import { openFileWithSystemApp } from '@/lib/office-files' +import { bytesFromDataUrl, inlineOfficeFormat, openFileWithSystemApp } from '@/lib/office-files' import { cn } from '@/lib/utils' import type { PreviewTarget } from '@/store/preview' import { setPreviewDirty } from '@/store/preview-edit' import { $connection, $currentCwd } from '@/store/session' import { notifyWorkspaceChanged } from '@/store/workspace-events' +import { OfficePreview } from './office-preview' +import { ImageFilePreview } from './preview-image' + const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 const SOURCE_CHUNK_LINES = 200 @@ -159,6 +162,8 @@ interface LocalPreviewState { error?: string language?: string loading: boolean + /** Raw bytes for the inline Word/PowerPoint viewer (see office-preview.tsx). */ + officeBytes?: ArrayBuffer text?: string truncated?: boolean } @@ -668,6 +673,12 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar const [forcePreview, setForcePreview] = useState(false) const [pdfError, setPdfError] = useState() const [pdfUrl, setPdfUrl] = useState() + // Set when the Office viewer rejects the bytes — the pane then offers the + // system app, its only remaining route to the document. + const [officeFailed, setOfficeFailed] = useState(false) + // Stable identity matters: OfficePreview tears its viewer down and rebuilds it + // when this changes, and an inline arrow would change on every render. + const handleOfficeError = useCallback(() => setOfficeFailed(true), []) // User-picked view; null = auto (diff when changed, else rendered markdown, // else source). Reset when the previewed file changes. const [userMode, setUserMode] = useState(null) @@ -694,6 +705,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' const isPdf = target.previewKind === 'pdf' + // Word/PowerPoint render inline by extension, not by previewKind: the main + // process sniffs them as binary, so there is no kind to branch on. + const officeFormat = inlineOfficeFormat(filePath) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { @@ -703,6 +717,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar setSaving(false) setSaveError(null) setConflict(false) + setOfficeFailed(false) draftRef.current = '' baselineRef.current = '' }, [filePath, reloadKey]) @@ -712,7 +727,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar // when the file is forcibly previewed past the binary refusal screen. const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html' - const blockedByTarget = !isImage && !isPdf && !forcePreview && (target.binary || target.large) + const blockedByTarget = !isImage && !isPdf && !officeFormat && !forcePreview && (target.binary || target.large) useEffect(() => { let active = true @@ -724,7 +739,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar return } - if (!isImage && !isPdf && !isText) { + if (!isImage && !isPdf && !isText && !officeFormat) { setState({ loading: false }) return @@ -733,13 +748,18 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar setState({ loading: true }) try { - if (isImage || isPdf) { + if (isImage || isPdf || officeFormat) { // Prefer bytes the caller already handed us (a pasted/dropped // screenshot) over re-reading a path that may be transient/unreadable. const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath)) if (active) { - setState({ dataUrl, loading: false }) + // The Office viewers take an ArrayBuffer; image/PDF keep the data URL. + setState( + officeFormat + ? { loading: false, officeBytes: bytesFromDataUrl(dataUrl).buffer } + : { dataUrl, loading: false } + ) } return @@ -777,10 +797,17 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar } } catch (error) { if (active) { - setState({ - error: error instanceof Error ? error.message : String(error), - loading: false - }) + // An unreadable Office file is not worth a raw error string: it lands + // on the viewer's own state, which offers the system app instead. + setState( + officeFormat + ? { loading: false } + : { error: error instanceof Error ? error.message : String(error), loading: false } + ) + + if (officeFormat) { + setOfficeFailed(true) + } } } } @@ -798,6 +825,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar isImage, isPdf, isText, + officeFormat, reloadKey, selfReload, target.dataUrl, @@ -1027,6 +1055,29 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar return } + // Word/PowerPoint own their whole pane: the viewer scrolls its own pages, so + // it takes no part of the text/diff/refusal machinery below. Bytes that never + // arrive (unreadable, over the remote cap) and bytes the parser rejects land + // on the same state, whose only route out is the system app. + if (officeFormat) { + if (state.officeBytes && !officeFailed) { + return ( + }> + + + ) + } + + return ( + void openFileWithSystemApp(filePath) }} + title={t.preview.unavailable} + tone="warning" + /> + ) + } + if ( !isImage && !isPdf && @@ -1055,16 +1106,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar } if (isImage && state.dataUrl) { - return ( -
- {target.label} -
- ) + return } if (isPdf && state.dataUrl && pdfUrl) { diff --git a/apps/desktop/src/app/chat/right-rail/preview-image.tsx b/apps/desktop/src/app/chat/right-rail/preview-image.tsx new file mode 100644 index 0000000000..a370b0ee16 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-image.tsx @@ -0,0 +1,22 @@ +import { useState } from 'react' + +import { ImageViewer } from '@/components/chat/image-viewer' +import { ImageLightbox } from '@/components/chat/zoomable-image' +import { useImageDownload } from '@/hooks/use-image-download' + +export interface ImageFilePreviewProps { + alt: string + src: string +} + +export function ImageFilePreview({ alt, src }: ImageFilePreviewProps) { + const { download, saving } = useImageDownload(src) + const [open, setOpen] = useState(false) + + return ( + <> + setOpen(true)} saving={saving} src={src} /> + + + ) +} diff --git a/apps/desktop/src/app/chat/session-history-nav.tsx b/apps/desktop/src/app/chat/session-history-nav.tsx new file mode 100644 index 0000000000..0d16a84aff --- /dev/null +++ b/apps/desktop/src/app/chat/session-history-nav.tsx @@ -0,0 +1,45 @@ +import { useStore } from '@nanostores/react' +import { useNavigate } from 'react-router' + +import { openSession } from '@/app/open-session' +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { $sessionHistoryFlags, navigateSessionHistory } from '@/store/session-history' + +/** Back/forward over the main pane's conversation history, rendered at the + * left of the workspace zone's titlebar band (see `stripLead` in + * track-model). The chat's own header band is suppressed inside zones, so + * this is the only home that survives every layout. */ +export function SessionHistoryNav() { + const flags = useStore($sessionHistoryFlags) + const navigate = useNavigate() + const { t } = useI18n() + + const go = (delta: number) => navigateSessionHistory(delta, id => openSession(id, navigate)) + + const buttonClass = + 'grid size-5.5 place-items-center rounded-md text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-30' + + return ( +
+ + +
+ ) +} diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 7a7822d64e..43fc829720 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -782,6 +782,7 @@ export const watchSessionTiles = paneMirror({ dir: t => t.dir, anchor: t => t.anchor, before: t => t.before, + headerSurface: 'chat', minWidth: '20rem', title: tileCaption, // The tab's status dot — the SAME primitive the sidebar row renders, keyed by diff --git a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx index d83e4221df..9c0e76c203 100644 --- a/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/chat-sidebar.integration.test.tsx @@ -9,10 +9,13 @@ import { SidebarProvider } from '@/components/ui/sidebar' import { registry } from '@/contrib/registry' import { $selectedStoredSessionId, $sessions } from '@/store/session' import { $removedSessionIds } from '@/store/session-removal' +import { $desktopVersion } from '@/store/updates' import { makeSessionInfo } from '@/test/session-info' import { type AppView, ROUTES_AREA, SIDEBAR_NAV_AREA } from '../../routes' +import { SidebarZoneLead } from './zone-tabs' + import { ChatSidebar } from './index' const noop = () => {} @@ -28,6 +31,8 @@ const renderSidebar = (pathname: string, currentView: AppView) => render( + {/* The zone's lead, which the layout tree renders above this pane. */} + { expectOnlySelectedSession(null) }) }) + +// The brand block lives in the zone's lead, above this pane — the sidebar's own +// content starts at the nav rows, so the ordering assertion is the one that +// would break if the lead were ever rendered inside the pane again. +describe('ChatSidebar brand row', () => { + beforeEach(() => { + $desktopVersion.set({ + appVersion: '9.9.9', + electronVersion: '0', + hermesRoot: '/tmp', + nodeVersion: '0', + platform: 'test' + }) + $sessions.set([]) + $selectedStoredSessionId.set(null) + $layoutTree.set(null) + }) + + afterEach(() => { + cleanup() + $desktopVersion.set(null) + }) + + it('shows the product name and version above the nav rows', () => { + renderSidebar('/', 'chat') + + const brand = screen.getByText('DeepWork') + + expect(screen.getByText('v9.9.9')).toBeTruthy() + // Above "New session": the nav row follows the brand row in the document. + expect( + brand.compareDocumentPosition(screen.getByText('New session')) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx index 657db9997d..e593ebec6f 100644 --- a/apps/desktop/src/app/chat/sidebar/filter-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/filter-menu.tsx @@ -59,7 +59,6 @@ import { requestProfileCreate, toggleShowAllProfiles } from '@/store/profile' -import { $profileRailVisible, toggleProfileRailVisible } from '@/store/profile-rail-prefs' import { runImportProfileFlow } from '@/store/profile-share' import { $projectTree } from '@/store/projects' import type { PullRequestBucket } from '@/store/pull-requests' @@ -158,7 +157,6 @@ export function SidebarFilterMenu({ className }: { className?: string }) { const ordering = useStore($sidebarOrdering) const rowMeta = useStore($sidebarRowMeta) const cardRows = useStore($sidebarCardRows) - const profileRailVisible = useStore($profileRailVisible) const showAllSessions = useStore($sidebarShowAllSessions) const statusFilter = useStore($sidebarStatusFilter) const projectFilter = useStore($sidebarProjectFilter) @@ -308,15 +306,6 @@ export function SidebarFilterMenu({ className }: { className?: string }) { onCheck={() => setSidebarCardRows(!cardRows)} option={{ icon: 'inbox', id: 'card-rows', label: 'Inbox style' }} /> - - {/* The colored strip at the sidebar foot. Off, the statusbar grows a - profile dropdown beside the gateway switcher, so nobody loses the - door — this is for people whose profiles are bots, not workspaces. */} - diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index afe4b3b37f..67206c360a 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -5,7 +5,6 @@ import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router' -import { UserAccountCard } from '@/app/chat/sidebar/user-account-card' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -75,6 +74,7 @@ import { } from '@/store/layout' import { notifyError } from '@/store/notifications' import { + $activeGatewayProfile, $newChatProfile, $profiles, $profileScope, @@ -83,7 +83,7 @@ import { normalizeProfileKey, sidebarProfileForScope } from '@/store/profile' -import { $profileRailVisible } from '@/store/profile-rail-prefs' +import { $pinnedProjectIdsByProfile, reorderPinnedProjects } from '@/store/project-pins' import { $activeProjectId, $newProjectDropPlacement, @@ -153,7 +153,6 @@ import { useGatewaySessionGroups } from './gateway-group-model' import { SidebarLoadMoreRow } from './load-more-row' import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' import { filterSessionsByProfileScope } from './profile-scope' -import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { resolveLiveProjectFilter } from './project-filter' import { @@ -161,6 +160,7 @@ import { orderProjectsByIds, overlayLiveLanes, overlayLivePreviews, + prioritizePinnedProjects, PROJECT_PREVIEW_COUNT, ProjectBackRow, ProjectMenu, @@ -186,6 +186,8 @@ import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' import { useEnteredProjectSessions } from './use-entered-project-sessions' +const NO_PINNED_PROJECT_IDS: string[] = [] + // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from // dominating the sidebar before the user asks to see it. @@ -436,6 +438,9 @@ export function ChatSidebar({ const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds) const projectOrderIds = useStore($sidebarProjectOrderIds) + const activeGatewayProfile = useStore($activeGatewayProfile) + const pinnedProjectsByProfile = useStore($pinnedProjectIdsByProfile) + const pinnedProjectIds = pinnedProjectsByProfile[normalizeProfileKey(activeGatewayProfile)] ?? NO_PINNED_PROJECT_IDS const projects = useStore($projects) const projectTree = useStore($projectTree) @@ -499,7 +504,6 @@ export function ChatSidebar({ }, []) const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null - const profileRailVisible = useStore($profileRailVisible) const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), @@ -966,7 +970,7 @@ export function ChatSidebar({ // Layer the user's manual drag-order on top of the deterministic sort. Empty // (default) returns `sorted` untouched; projects the user hasn't ordered yet // keep their sorted position rather than jumping the hand-picked list. - return orderProjectsByIds(sorted, projectOrderIds) + return prioritizePinnedProjects(orderProjectsByIds(sorted, projectOrderIds), pinnedProjectIds) }, [ projectTree, dismissedAutoProjects, @@ -974,6 +978,7 @@ export function ChatSidebar({ activeProjectId, projectFilter, projectOrderIds, + pinnedProjectIds, isHiddenFromProjects, s ]) @@ -1446,7 +1451,10 @@ export function ChatSidebar({ // Persist the new project overview order (drag-to-reorder); orderByIds applies // it over the default sort, so stale/new ids reconcile on the next render. - const reorderProjects = (ids: string[]) => setSidebarProjectOrderIds(ids) + const reorderProjects = (ids: string[]) => { + setSidebarProjectOrderIds(ids) + reorderPinnedProjects(activeGatewayProfile, ids) + } // Sortable rows carry live session ids; the pinned store is keyed by durable // (lineage-root) ids, so translate before persisting the new order. @@ -1474,7 +1482,10 @@ export function ChatSidebar({ data-tour="sessions-sidebar" > - + {/* No titlebar inset: the brand row and the zone's tabs are painted + above this pane now (see `zoneLead` in zone-tabs), so the old + `pt-[calc(var(--titlebar-height)+…)]` would space them twice. */} + {[...SIDEBAR_NAV, ...contributedNav].map(item => { @@ -1853,6 +1864,7 @@ export function ChatSidebar({ onToggleUnread={toggleUnread} open={agentsOpen} pinned={false} + pinnedProjectIds={pinnedProjectIds} projectBackRow={ inProject ? : undefined } @@ -1932,19 +1944,6 @@ export function ChatSidebar({ )} {!showSessionSections && } - - {/* §21 用户卡:signed_in 才渲染,点击进 /me 资料页。 */} -
- -
- - {/* Off, the statusbar's profile dropdown (beside the gateway switcher) - takes over — the rail is a duplicate door for bot-only setups. */} - {profileRailVisible && ( -
- -
- )}
{/* One mount for the whole app. The header of WorktreeDialog tells why. */} diff --git a/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.test.tsx index 89937fc0ae..695da63a31 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.test.tsx @@ -1,90 +1,218 @@ -// @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { atom } from 'nanostores' -import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, useLocation } from 'react-router' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { $profileRailVisible } from '@/store/profile-rail-prefs' +import { ProfileCreateHost } from '@/app/profiles/profile-create-host' +import type * as ConnectionsStore from '@/store/connections' +import { $connectionsRegistry, selectConnection } from '@/store/connections' +import { $fleetRoster } from '@/store/fleet-roster' +import type * as ProfileStore from '@/store/profile' +import { + $activeGatewayProfile, + $profileCreateRequest, + $profileOrder, + $profiles, + $showAllProfiles, + requestProfileCreate, + selectProfile, + setShowAllProfiles +} from '@/store/profile' +import { runImportProfileFlow } from '@/store/profile-share' +import { $connection } from '@/store/session' +import { $userAccount } from '@/store/user-account' +import { stubMenuDomApis } from '@/test/jsdom' -import { ProfileSwitcher } from './profile-dropdown-switcher' +import { UserAccountCard } from './user-account-card' -// With the colored rail hidden, the statusbar's profile dropdown is the ONLY -// door to switching profiles — it must list every profile of the active -// gateway and select through the same store action the rail uses. +vi.mock('@/store/profile', async importOriginal => ({ + ...(await importOriginal()), + selectProfile: vi.fn(), + setShowAllProfiles: vi.fn(), + refreshActiveProfile: vi.fn(async () => undefined) +})) +vi.mock('@/store/connections', async importOriginal => ({ + ...(await importOriginal()), + selectConnection: vi.fn(async () => undefined) +})) +vi.mock('@/store/profile-share', () => ({ runImportProfileFlow: vi.fn() })) +vi.mock('./use-profile-prewarm', () => ({ + useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() }) +})) -const selectProfile = vi.fn() -const setShowAllProfiles = vi.fn() - -vi.mock('react-router', () => ({ useNavigate: () => vi.fn() })) - -vi.mock('@/i18n', () => ({ - useI18n: () => ({ - t: { - profiles: { - allProfiles: 'All profiles', - fleet: { onGateway: (name: string, gateway: string) => `${name} · ${gateway}` }, - importProfile: 'Import profile…', - manageProfiles: 'Manage profiles…', - newProfile: 'New profile', - switchConnectionFailed: (name: string) => `Could not connect to ${name}`, - title: 'Profiles' - } +beforeEach(() => { + stubMenuDomApis() + $userAccount.set({ + status: 'signed_in', + site: 'https://account.example', + offline: false, + profileAsOf: null, + profile: { + email: 'owner@example.com', + username: '', + nickname: 'Owner', + phone: '', + planId: '', + periodEnd: '', + dataAsOf: '' } }) -})) - -vi.mock('@/store/profile', () => ({ - $activeGatewayProfile: atom('default'), - $profileColors: atom({}), - $profileCreateRequest: atom(0), - $profileOrder: atom([]), - $profiles: atom([ - { is_default: true, name: 'default' }, - { is_default: false, name: 'clippy' } - ]), - $showAllProfiles: atom(false), - ALL_PROFILES: '__all__', - normalizeProfileKey: (name: string) => name, - profileLabel: (profile: { name: string }) => profile.name, - refreshActiveProfile: vi.fn().mockResolvedValue(undefined), - selectProfile: (name: string) => selectProfile(name), - setShowAllProfiles: (value: boolean) => setShowAllProfiles(value), - sortByProfileOrder: (profiles: Array<{ name: string }>) => profiles -})) - -vi.mock('@/store/connections', () => ({ - $activeConnectionId: atom(null), - $connectionsRegistry: atom(null), - $hasMultipleConnections: atom(false), - selectConnection: vi.fn() -})) - -vi.mock('@/store/profile-share', () => ({ runImportProfileFlow: vi.fn() })) -vi.mock('./use-profile-prewarm', () => ({ useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() }) })) -vi.mock('./use-fleet-roster', () => ({ useFleetRoster: () => undefined })) -vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: () => null })) + $activeGatewayProfile.set('default') + $showAllProfiles.set(false) + $profileOrder.set(['work', 'clippy']) + $profiles.set( + ['default', 'clippy', 'work'].map(name => ({ + name, + is_default: name === 'default', + path: `/tmp/${name}`, + has_env: false, + model: null, + provider: null, + skill_count: 0 + })) + ) + $connectionsRegistry.set(null) + $connection.set(null) + $fleetRoster.set(null) + $profileCreateRequest.set(0) +}) afterEach(() => { cleanup() - $profileRailVisible.set(true) + $userAccount.set({ status: 'unknown' }) + $profiles.set([]) + $profileOrder.set([]) + $connectionsRegistry.set(null) + $connection.set(null) + $fleetRoster.set(null) vi.clearAllMocks() }) -it('switches profiles from the statusbar dropdown while the rail is hidden', async () => { - act(() => $profileRailVisible.set(false)) - render() +function Account({ compact = false }: { compact?: boolean }) { + const route = useLocation() - const trigger = screen.getByRole('button', { name: 'Profiles: default' }) - await act(async () => { - fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }) - await Promise.resolve() + return ( + <> + + + + {route.pathname} + {route.search} + + + ) +} + +async function openProfiles() { + const account = screen.getByRole('button', { name: 'Account menu' }) + await waitFor(() => expect(account.getAttribute('aria-expanded')).toBe('false')) + fireEvent.keyDown(account, { key: 'Enter' }) + const profiles = await screen.findByRole('menuitem', { name: /^Profiles/ }) + fireEvent.keyDown(profiles, { key: 'ArrowRight' }) + await waitFor(() => expect(profiles.getAttribute('aria-expanded')).toBe('true')) + await screen.findByRole('menuitemradio', { name: 'default' }) +} + +it('preserves ordered checked profiles, the all-profiles filter, management and gateway-scoped switching in both sidebar widths', async () => { + const view = render( + + + + ) + + await openProfiles() + const items = screen.getAllByRole('menuitemradio') + + expect(items[0]).toBe(screen.getByRole('menuitemradio', { name: 'default' })) + const namedIndices = $profileOrder.get().map(name => items.indexOf(screen.getByRole('menuitemradio', { name }))) + + expect(namedIndices).toEqual([...namedIndices].sort((a, b) => a - b)) + expect(screen.getByRole('menuitemradio', { name: 'default' }).getAttribute('aria-checked')).toBe('true') + fireEvent.click(screen.getByRole('menuitemradio', { name: 'work' })) + expect(selectProfile).toHaveBeenCalledWith('work') + await openProfiles() + fireEvent.click(screen.getByRole('menuitemradio', { name: 'All profiles' })) + expect(setShowAllProfiles).toHaveBeenCalledWith(true) + await openProfiles() + fireEvent.click(screen.getByRole('menuitem', { name: 'Import profile…' })) + expect(runImportProfileFlow).toHaveBeenCalledOnce() + await openProfiles() + fireEvent.click(screen.getByRole('menuitem', { name: 'Manage profiles…' })) + expect(screen.getByTestId('route').textContent).toBe('/profiles') + await openProfiles() + fireEvent.click(screen.getByRole('menuitem', { name: 'Manage gateways…' })) + expect(screen.getByTestId('route').textContent).toBe('/settings?tab=gateway') + + act(() => { + $connection.set({ + connectionId: 'local', + mode: 'local', + baseUrl: 'http://127.0.0.1:43100', + wsUrl: 'ws://127.0.0.1:43100/ws', + isFullscreen: false, + nativeOverlayWidth: 0, + token: '', + logs: [], + windowButtonPosition: null + }) + $connectionsRegistry.set({ + version: 2, + primary: 'local', + lastUsed: 'local', + launchMode: 'primary', + secureTokenStorage: true, + connections: [ + { id: 'local', kind: 'local', label: 'This device', tokenSet: false, tokenPreview: '' }, + { + id: 'remote', + kind: 'remote', + label: 'Remote gateway', + url: 'https://remote.example', + tokenSet: false, + tokenPreview: '' + } + ] + }) + $fleetRoster.set({ + sources: [{ connectionId: 'remote', kind: 'remote', label: 'Remote gateway', reachable: false }], + agents: [ + { + connectionId: 'remote', + connectionKind: 'remote', + connectionLabel: 'Remote gateway', + profile: 'builder', + handle: 'builder-remote' + } + ] + }) }) - - const clippy = await screen.findByRole('menuitemradio', { name: /clippy/ }) - await act(async () => { - fireEvent.click(clippy) - await Promise.resolve() - }) - - expect(selectProfile).toHaveBeenCalledWith('clippy') - expect(setShowAllProfiles).not.toHaveBeenCalled() + view.rerender( + + + + ) + await openProfiles() + fireEvent.click(screen.getByRole('menuitem', { name: /builder.*Remote gateway/ })) + await waitFor(() => expect(selectConnection).toHaveBeenCalledWith('remote', { profile: 'builder' })) + expect(selectProfile).not.toHaveBeenCalledWith('builder') +}) + +it('keeps the shared create dialog alive after the submenu closes and answers the hotkey exactly once', async () => { + render( + + + + ) + await openProfiles() + fireEvent.click(screen.getByRole('menuitem', { name: 'New profile' })) + await screen.findByLabelText(/SOUL\.md/i) + expect(screen.queryByRole('menuitemradio')).toBeNull() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'new-agent' } }) + act(() => requestProfileCreate()) + expect(screen.getAllByRole('dialog')).toHaveLength(1) + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe('new-agent') + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + act(() => requestProfileCreate()) + await screen.findByLabelText(/SOUL\.md/i) + expect(screen.getAllByRole('dialog')).toHaveLength(1) }) diff --git a/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.tsx index 85caf0063f..b8d8375fec 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-dropdown-switcher.tsx @@ -1,19 +1,18 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { - DropdownMenu, - DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, dropdownMenuSectionLabel, DropdownMenuSeparator, - DropdownMenuTrigger + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' import { ProfileGlyph } from '@/components/ui/profile-glyph' import { useI18n } from '@/i18n' @@ -21,44 +20,42 @@ import { triggerHaptic } from '@/lib/haptics' import { Loader2 } from '@/lib/icons' import { resolveProfileColor } from '@/lib/profile-color' import { cn } from '@/lib/utils' -import { $activeConnectionId, $connectionsRegistry, $hasMultipleConnections, selectConnection } from '@/store/connections' +import { + $activeConnectionId, + $connectionsRegistry, + $hasMultipleConnections, + selectConnection +} from '@/store/connections' import { $fleetRoster } from '@/store/fleet-roster' import { notifyError } from '@/store/notifications' import { $activeGatewayProfile, $profileColors, - $profileCreateRequest, $profileOrder, $profiles, $showAllProfiles, ALL_PROFILES, normalizeProfileKey, profileLabel, - refreshActiveProfile, + requestProfileCreate, selectProfile, setShowAllProfiles, sortByProfileOrder } from '@/store/profile' import { runImportProfileFlow } from '@/store/profile-share' -import { CreateProfileDialog } from '../../profiles/create-profile-dialog' -import { PROFILES_ROUTE } from '../../routes' +import { PROFILES_ROUTE, SETTINGS_ROUTE } from '../../routes' import { ConnectionGlyph } from './connection-glyph' import { buildRestGroups, type FleetAgent, fleetRouteKey } from './fleet-rail' -import { useFleetRoster } from './use-fleet-roster' import { useProfilePrewarm } from './use-profile-prewarm' /** - * The profile picker that sits beside the gateway switcher in the statusbar - * while the colored rail is hidden — the same choices the rail offers (this - * gateway's profiles, every other gateway's agents in fleet mode, new / import - * / manage) in a dropdown that reads like its neighbour. The rail keeps the - * gestures that need squares (drag-order, hold-to-recolor); this is the - * plain-dropdown door for people who run profiles as bots and don't want a - * strip of them. + * Account-menu profile picker. Dialogs and refresh listeners belong to the + * standing shell host, so selecting New can close this menu without losing + * its dialog. Square gestures remain in Manage Profiles. */ -export function ProfileSwitcher({ compact = false }: { compact?: boolean }) { +export function ProfileSubmenu() { const { t } = useI18n() const p = t.profiles const navigate = useNavigate() @@ -71,26 +68,8 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) { const registry = useStore($connectionsRegistry) const activeConnectionId = useStore($activeConnectionId) const roster = useStore($fleetRoster) - const [createOpen, setCreateOpen] = useState(false) const [pendingRoute, setPendingRoute] = useState(null) - useFleetRoster(multipleConnections) - - // The `profile.create` hotkey bumps this request atom; the rail answers it - // while mounted, so with the rail hidden this picker owns the dialog instead. - const createRequest = useStore($profileCreateRequest) - const lastCreateRef = useRef(createRequest) - - // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) - useEffect(() => { - if (createRequest === lastCreateRef.current) { - return - } - - lastCreateRef.current = createRequest - setCreateOpen(true) - }, [createRequest]) - const connections = registry?.connections const restGroups = useMemo( @@ -134,120 +113,93 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) { const triggerLabel = showAll ? p.allProfiles : active ? profileLabel(active) : p.title return ( -
- - - - - - - {ordered.map(profile => ( - - ))} - {/* Nothing to widen to with one profile: the ALL view only exists - once a second profile does (the rail hides its toggle then too). */} - {profiles.length > 1 && ( - - - - - )} - - {restGroups.map(group => ( -
- - - - {group.label} - {!group.reachable && - {[group.defaultAgent, ...group.named].map(agent => ( - switchToRest(agent)} - > - - - - ))} -
+ + + {pendingRoute !== null ? ( + + + + {ordered.map(profile => ( + ))} - - setCreateOpen(true)}> - - void runImportProfileFlow()}> - - navigate(PROFILES_ROUTE)}> - -
-
- - setCreateOpen(false)} - onCreated={async name => { - await refreshActiveProfile() - selectProfile(name) - }} - open={createOpen} - profiles={profiles} - /> -
+ {/* Nothing to widen to with one profile: the ALL view only exists + once a second profile does (the rail hides its toggle then too). */} + {profiles.length > 1 && ( + + + + + )} + + {restGroups.map(group => ( +
+ + + + {group.label} + {!group.reachable && + {[group.defaultAgent, ...group.named].map(agent => ( + switchToRest(agent)} + > + + + + ))} +
+ ))} + + + + void runImportProfileFlow()}> + + navigate(PROFILES_ROUTE)}> + + navigate(`${SETTINGS_ROUTE}?tab=gateway`)}> + + + ) } @@ -265,7 +217,12 @@ function ProfileItem({ const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name) return ( - +
) } + active={active} icon={leadingIcon} label={group.label} onToggle={toggleOpen} open={open} title={group.path ? displayPath(group.path) : undefined} + working={working} /> )} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 3f457c885e..23edfd8900 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -1,6 +1,7 @@ import type { HermesGitWorktree } from '@/global' import type { ProjectInfo, SessionInfo } from '@/hermes' import { normalize } from '@/lib/text' +import { sessionMatchesStoredId } from '@/store/session' import { rankSessions } from '../order' @@ -71,6 +72,24 @@ export interface SidebarProjectTree { previewSessions?: SessionInfo[] } +/** Whether a sidebar group owns the currently focused conversation. */ +export const groupContainsSession = (group: SidebarSessionGroup, storedSessionId?: null | string): boolean => + Boolean(storedSessionId && group.sessions.some(session => sessionMatchesStoredId(session, storedSessionId))) + +/** Whether a project owns the currently focused conversation. */ +export const projectContainsSession = (project: SidebarProjectTree, storedSessionId?: null | string): boolean => { + if (!storedSessionId) { + return false + } + + const sessions = [ + ...(project.repos ?? []).flatMap(repo => repo.groups.flatMap(group => group.sessions)), + ...(project.previewSessions ?? []) + ] + + return sessions.some(session => sessionMatchesStoredId(session, storedSessionId)) +} + /** Path split into segments, ignoring trailing slashes and mixed separators. */ const segments = (path: string): string[] => path diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index db4f4822c1..1a84907d66 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -194,16 +194,20 @@ export function StartWorkButton({ repoPath }: { repoPath: string }) { // button with a leading glyph, plus an optional trailing action (the +). export function WorkspaceHeader({ action, + active = false, emphasis = false, icon, label, onToggle, open, title, + working = false, ref, ...rest }: { action?: React.ReactNode + /** Marks the lane containing the focused conversation. */ + active?: boolean emphasis?: boolean icon: React.ReactNode label: string @@ -211,13 +215,19 @@ export function WorkspaceHeader({ open: boolean /** Hover tooltip — the lane's full on-disk path (worktree / repo root). */ title?: string + /** Adds a subtle pulse while any session in the lane is generating. */ + working?: boolean } & React.ComponentProps<'div'>) { return (
@@ -229,8 +239,14 @@ export function WorkspaceHeader({ onClick={onToggle} type="button" > - {icon} + {icon} + {active && ( +