feat: consolidate desktop and provider updates
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled

This commit is contained in:
m4
2026-09-27 17:37:19 +08:00
parent 226350cd04
commit fb13457f6f
174 changed files with 7669 additions and 1477 deletions
+16
View File
@@ -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
@@ -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()
+30 -2
View File
@@ -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)
+22 -2
View File
@@ -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<number | null> {
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<Sub2ApiKey[]> {
const data = await callSub2Api(baseUrl, '/api/v1/keys', { token }, fetcher)
const items = Array.isArray(data?.items) ? data.items : []
+2 -1
View File
@@ -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: {
+189 -2
View File
@@ -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()
+70 -6
View File
@@ -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<T>(fn: (session: RelayAccountSession) => Promise<T>): Promise<T> {
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)
+6 -5
View File
@@ -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.",
@@ -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
})
@@ -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
}
@@ -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, `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>CFBundleName</key><string>Electron</string>
<key>CFBundleDisplayName</key><string>Electron</string>
<key>CFBundleExecutable</key><string>Electron</string>
<key>CFBundleIdentifier</key><string>com.github.Electron</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
</dict></plist>`)
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 })
}
})
+7
View File
@@ -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<FreeModel2ApiRememberedLogin | null
export function fm2RememberedSave(payload: FreeModel2ApiRememberedLogin | null) {
return window.hermesDesktop.freemodel2api.rememberedSave(payload)
}
/** 未登录的余额读数:sub2api 凭 API key 直查,不走会话。null = 查不到(非 sub2api / 失败)。 */
export function fm2KeyBalance(baseUrl: string, apiKey: string): Promise<{ balance: number | null }> {
return window.hermesDesktop.freemodel2api.keyBalance({ baseUrl, apiKey })
}
@@ -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())
@@ -216,7 +216,6 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
{lightboxSrc && (
<ImageLightbox
alt={attachment.label}
copy={t.desktop}
onClick={download}
onOpenChange={open => {
setLightboxOpen(open)
@@ -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))'
@@ -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<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, editorRef, poppedOut: false })
useComposerMetrics({ composerDockRef, composerRef, composerSurfaceRef, poppedOut: false })
return (
<div data-chat-surface="">
<div ref={sized(composerDockRef, dockHeight)}>
<form ref={composerRef}>
<div ref={sized(composerSurfaceRef, surfaceHeight)}>
<div ref={editorRef} />
<div />
</div>
</form>
</div>
@@ -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<HTMLDivElement | null>
composerRef: RefObject<HTMLFormElement | null>
composerSurfaceRef: RefObject<HTMLDivElement | null>
editorRef: RefObject<HTMLDivElement | null>
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<ComposerFit>(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
}
}
+5 -12
View File
@@ -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 = (
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div className="relative w-full">
<div
aria-disabled={inputDisabled ? true : undefined}
aria-label={t.composer.message}
autoCapitalize="off"
autoCorrect="off"
className={cn(
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'min-h-[2.25rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1',
'w-full pl-3',
// Inside the native Wayland HUD drag region: a drag region swallows
// the page's mouse input whole, so the input must opt back out or it
// becomes unclickable. Buttons use the global no-drag rule.
@@ -1450,12 +1448,7 @@ export function ChatBar({
)}
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
<div
className={cn(
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
className='grid w-full grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
>
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
{contextMenu}
@@ -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: <FreeTierNoticeStrip /> })
}
if (setupPending) {
sections.push({ key: 'setup-pending', node: <SetupPendingStrip /> })
}
const hasControlContent = Boolean(
controlEntry &&
(controlEntry.error ||
@@ -336,3 +348,29 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
</div>
)
}
function SetupPendingStrip() {
const { t } = useI18n()
return (
<StatusRow
leading={<Codicon aria-hidden className="text-(--ui-text-tertiary)" name="zap" size="0.8rem" />}
trailing={
<Button
className="text-foreground/90 hover:text-foreground"
onClick={() => startManualOnboarding(null)}
size="micro"
type="button"
variant="text"
>
{t.statusStack.setupPendingAction}
</Button>
}
trailingVisible
>
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
{t.statusStack.setupPendingTitle}
</span>
</StatusRow>
)
}
+4 -2
View File
@@ -739,7 +739,6 @@ const ChatViewContent = memo(function ChatViewContent({
data-composer-target={composerScope.target}
data-session-anchor={sessionAnchor}
>
<Backdrop />
{/* 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 && <WorkspaceBar cwd={currentCwd} onChangeWorkspace={onChangeWorkspace} />}
{isPrimary && onChangeWorkspace && <WorkspaceBar cwd={currentCwd} selectedSessionId={selectedSessionId} />}
{/* 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. */}
<Backdrop />
<Thread
clampToComposer={showChatBar}
cwd={currentCwd}
+6
View File
@@ -27,6 +27,10 @@ export interface PaneMirror<T> {
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<T>(cfg: PaneMirror<T>): () => 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).
@@ -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')
+46 -16
View File
@@ -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,11 +70,16 @@ export function browserTabExternalUrl(tabId: string): null | string {
}
function browserTabMenuPrefix(tabId: string) {
if (targetFor(tabId)?.kind !== 'url') {
return undefined
}
return (kit: MenuKit) => (
<>
{renderActionItem(kit, {
icon: 'layout-sidebar-right',
key: 'move-to-workspace',
label: translateNow('preview.moveToWorkspace'),
onSelect: () => movePreviewToWorkspace(tabId)
})}
{targetFor(tabId)?.kind === 'url' ? <kit.Separator /> : null}
{targetFor(tabId)?.kind === 'url' ? (
<>
{canOpenBrowserWindow()
? renderActionItem(kit, {
@@ -86,9 +97,21 @@ function browserTabMenuPrefix(tabId: string) {
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 => <PreviewTabLead tabId={tabId} />,
@@ -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(<OfficePreview bytes={bytes} format="docx" onError={onError} />)
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(<OfficePreview bytes={bytes} format="pptx" onError={vi.fn()} />)
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(<OfficePreview bytes={bytes} format="docx" onError={vi.fn()} />)
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(<OfficePreview bytes={bytes} format="docx" onError={onError} />)
await waitFor(() => expect(state.docx).toHaveLength(1))
rerender(<OfficePreview bytes={new Uint8Array([9]).buffer} format="docx" onError={onError} />)
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(<OfficePreview bytes={bytes} format="docx" onError={onError} />)
await waitFor(() => expect(onError).toHaveBeenCalledTimes(1))
expect(onError.mock.calls[0][0].message).toBe('bad zip')
})
})
@@ -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<HTMLDivElement | null>(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 <div className="h-full w-full overflow-hidden" ref={hostRef} />
}
@@ -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<typeof OfficeFiles>()),
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(
<LocalFilePreview
reloadKey={0}
target={{ ...binaryTarget, label: 'spec.docx', path: '/w/spec.docx', source: '/w/spec.docx' }}
/>
)
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(
<LocalFilePreview
reloadKey={0}
target={{ ...binaryTarget, label: 'spec.docx', path: '/w/spec.docx', source: '/w/spec.docx' }}
/>
)
const primary = await screen.findByRole('button', { name: 'Open with system app' })
expect(loadDocx).not.toHaveBeenCalled()
fireEvent.click(primary)
expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/spec.docx')
})
})
@@ -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<string>()
const [pdfUrl, setPdfUrl] = useState<string>()
// 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 | PreviewViewMode>(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 <PreviewEmptyState body={pdfError} title={t.preview.unavailable} />
}
// 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 (
<Suspense fallback={<PageLoader label={t.preview.loading} />}>
<OfficePreview bytes={state.officeBytes} format={officeFormat} onError={handleOfficeError} />
</Suspense>
)
}
return (
<PreviewEmptyState
body={t.preview.officeFailed(target.label)}
primaryAction={{ label: t.preview.openWithSystemApp, onClick: () => 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 (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-transparent p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
return <ImageFilePreview alt={target.label} src={state.dataUrl} />
}
if (isPdf && state.dataUrl && pdfUrl) {
@@ -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 (
<>
<ImageViewer alt={alt} key={src} onDownload={download} onExpand={() => setOpen(true)} saving={saving} src={src} />
<ImageLightbox alt={alt} onClick={download} onOpenChange={setOpen} open={open} saving={saving} src={src} />
</>
)
}
@@ -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 (
<div className="flex items-center gap-0.5 pr-1" data-slot="session-history-nav">
<button
aria-label={t.chatNav.back}
className={buttonClass}
disabled={!flags.canBack}
onClick={() => go(-1)}
type="button"
>
<Codicon name="chevron-left" size="0.875rem" />
</button>
<button
aria-label={t.chatNav.forward}
className={buttonClass}
disabled={!flags.canForward}
onClick={() => go(1)}
type="button"
>
<Codicon name="chevron-right" size="0.875rem" />
</button>
</div>
)
}
@@ -782,6 +782,7 @@ export const watchSessionTiles = paneMirror<SessionTile>({
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
@@ -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(
<MemoryRouter initialEntries={[pathname]}>
<SidebarProvider>
{/* The zone's lead, which the layout tree renders above this pane. */}
<SidebarZoneLead />
<ChatSidebar
currentView={currentView}
onArchiveSession={noop}
@@ -162,3 +167,38 @@ describe('ChatSidebar navigation activity', () => {
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()
})
})
@@ -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. */}
<OptionCheckbox
checked={profileRailVisible}
onCheck={toggleProfileRailVisible}
option={{ icon: 'organization', id: 'profile-rail', label: t.sidebar.profileRail }}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
+19 -20
View File
@@ -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"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
<SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
{/* 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. */}
<SidebarGroup className="shrink-0 p-0 pb-2">
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
@@ -1853,6 +1864,7 @@ export function ChatSidebar({
onToggleUnread={toggleUnread}
open={agentsOpen}
pinned={false}
pinnedProjectIds={pinnedProjectIds}
projectBackRow={
inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined
}
@@ -1932,19 +1944,6 @@ export function ChatSidebar({
)}
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
{/* §21 用户卡:signed_in 才渲染,点击进 /me 资料页。 */}
<div className="shrink-0 px-0.5 pb-0.5">
<UserAccountCard />
</div>
{/* Off, the statusbar's profile dropdown (beside the gateway switcher)
takes over — the rail is a duplicate door for bot-only setups. */}
{profileRailVisible && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
</SidebarContent>
<ProjectDialog />
{/* One mount for the whole app. The header of WorktreeDialog tells why. */}
@@ -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<typeof ProfileStore>()),
selectProfile: vi.fn(),
setShowAllProfiles: vi.fn(),
refreshActiveProfile: vi.fn(async () => undefined)
}))
vi.mock('@/store/connections', async importOriginal => ({
...(await importOriginal<typeof ConnectionsStore>()),
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 | string>(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(<ProfileSwitcher compact />)
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 (
<>
<UserAccountCard compact={compact} />
<ProfileCreateHost />
<output data-testid="route">
{route.pathname}
{route.search}
</output>
</>
)
}
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(
<MemoryRouter>
<Account />
</MemoryRouter>
)
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
})
const clippy = await screen.findByRole('menuitemradio', { name: /clippy/ })
await act(async () => {
fireEvent.click(clippy)
await Promise.resolve()
$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: ''
}
]
})
expect(selectProfile).toHaveBeenCalledWith('clippy')
expect(setShowAllProfiles).not.toHaveBeenCalled()
$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'
}
]
})
})
view.rerender(
<MemoryRouter>
<Account compact />
</MemoryRouter>
)
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(
<MemoryRouter>
<Account compact />
</MemoryRouter>
)
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)
})
@@ -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 | string>(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,45 +113,25 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
const triggerLabel = showAll ? p.allProfiles : active ? profileLabel(active) : p.title
return (
<div
<DropdownMenuSub>
<DropdownMenuSubTrigger
aria-busy={pendingRoute !== null}
aria-label={p.title}
className={cn('min-w-16 shrink overflow-hidden', compact ? 'h-full max-w-40' : 'max-w-72')}
data-slot="profile-switcher"
role="group"
aria-label={showAll || active ? `${p.title}: ${triggerLabel}` : p.title}
className="min-w-0"
data-slot="account-profiles"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`${p.title}: ${triggerLabel}`}
className={cn(
'w-full min-w-0 justify-between overflow-hidden px-1 text-(--ui-text-secondary) data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground',
compact && 'h-full min-h-0 rounded-none px-1.5 text-[0.6875rem] font-normal'
)}
size="xs"
type="button"
variant="ghost"
>
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{pendingRoute !== null ? (
<Loader2 aria-hidden="true" className="size-3 shrink-0 animate-spin" />
) : showAll || !active ? (
<Codicon aria-hidden="true" className="shrink-0" name="layers" size="0.75rem" />
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
) : (
<ProfileGlyph
aria-hidden="true"
className="size-3 text-[0.4375rem]"
color={resolveProfileColor(active.name, colors)}
isDefault={active.is_default}
name={active.name}
/>
<Codicon aria-hidden="true" name="organization" size="0.875rem" />
)}
<span className="truncate">{triggerLabel}</span>
</span>
<Codicon aria-hidden="true" className="shrink-0 opacity-60" name="chevron-down" size="0.875rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-48 max-w-72" collisionPadding={8} side="top">
<span className="min-w-0 flex-1 truncate">{p.title}</span>
{(showAll || active) && <span className="max-w-24 truncate text-(--ui-text-tertiary)">{triggerLabel}</span>}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-56 min-w-0 max-w-(--radix-popper-available-width) max-h-[min(70vh,30rem)] overflow-y-auto"
collisionPadding={8}
>
<DropdownMenuRadioGroup onValueChange={choose} value={value}>
{ordered.map(profile => (
<ProfileItem
@@ -223,7 +182,7 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
</div>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
<DropdownMenuItem onSelect={requestProfileCreate}>
<Codicon aria-hidden="true" name="add" size="0.875rem" />
<span className="truncate">{p.newProfile}</span>
</DropdownMenuItem>
@@ -235,19 +194,12 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
<Codicon aria-hidden="true" name="settings-gear" size="0.875rem" />
<span className="truncate">{p.manageProfiles}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={createOpen}
profiles={profiles}
/>
</div>
<DropdownMenuItem onSelect={() => navigate(`${SETTINGS_ROUTE}?tab=gateway`)}>
<Codicon aria-hidden="true" name="plug" size="0.875rem" />
<span className="truncate">{p.connectGateway}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
@@ -265,7 +217,12 @@ function ProfileItem({
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
return (
<DropdownMenuRadioItem className="min-w-0" onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
<DropdownMenuRadioItem
className="min-w-0"
onPointerEnter={startPrewarm}
onPointerLeave={cancelPrewarm}
value={name}
>
<span className="flex min-w-0 items-center gap-1.5">
<ProfileGlyph aria-hidden="true" color={color} isDefault={isDefault} name={name} />
<span className="truncate">{label}</span>
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -10,6 +10,7 @@ import { ProfileRail } from './profile-switcher'
// gap reported against the multi-connection registry launch).
const navigate = vi.fn()
const { createDialog } = vi.hoisted(() => ({ createDialog: vi.fn(() => null) }))
vi.mock('react-router', () => ({
useNavigate: () => navigate
@@ -83,19 +84,22 @@ vi.mock('@/hermes', () => ({
}))
vi.mock('@/components/chat/code-editor', () => ({ CodeEditor: () => null }))
vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: () => null }))
vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: createDialog }))
vi.mock('../../profiles/delete-profile-dialog', () => ({ DeleteProfileDialog: () => null }))
vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () => null }))
const { $hasMultipleConnections } = await import('@/store/connections')
const hasMultipleConnections = $hasMultipleConnections as ReturnType<typeof atom<boolean>>
const { $profiles } = await import('@/store/profile')
const { $profileCreateRequest, $profiles } = await import('@/store/profile')
const createRequest = $profileCreateRequest as ReturnType<typeof atom<number>>
const profiles = $profiles as ReturnType<typeof atom<Array<{ is_default: boolean; name: string }>>>
afterEach(() => {
cleanup()
hasMultipleConnections.set(false)
profiles.set([{ is_default: true, name: 'default' }])
createRequest.set(0)
vi.clearAllMocks()
})
describe('ProfileRail multi-gateway entry point', () => {
@@ -157,4 +161,16 @@ describe('ProfileRail multi-gateway entry point', () => {
expect(screen.getByRole('group', { name: 'Profiles' }).className).toContain('min-w-0')
expect(screen.getByRole('button', { name: 'Profiles' })).toBeTruthy()
})
it('keeps management commands but leaves the shared create shortcut to the shell host', () => {
render(<ProfileRail management />)
expect(screen.getByRole('button', { name: 'default' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Import profile…' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Manage profiles…' })).toBeNull()
act(() => createRequest.set(createRequest.get() + 1))
expect(createDialog).toHaveBeenLastCalledWith(expect.objectContaining({ open: false }), undefined)
fireEvent.click(screen.getByRole('button', { name: 'New profile' }))
expect(createDialog).toHaveBeenLastCalledWith(expect.objectContaining({ open: true }), undefined)
})
})
@@ -145,7 +145,11 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// (gateway, profile); the workspace still lives on one gateway at a time, only
// the picker spans the fleet. Groups keep registry order regardless of which
// one is active, so a square never moves under the pointer that clicked it.
export function ProfileRail() {
export interface ProfileRailProps {
management?: boolean
}
export function ProfileRail({ management = false }: ProfileRailProps) {
const { t } = useI18n()
const p = t.profiles
const profiles = useStore($profiles)
@@ -341,13 +345,13 @@ export function ProfileRail() {
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (createRequest === lastCreateRef.current) {
if (management || createRequest === lastCreateRef.current) {
return
}
lastCreateRef.current = createRequest
setCreateOpen(true)
}, [createRequest])
}, [createRequest, management])
// The sortable strip of the active gateway's named profiles (unchanged
// from the single-gateway rail; fleet mode only decides where it sits).
@@ -402,7 +406,7 @@ export function ProfileRail() {
className="flex min-w-0 items-center gap-0.5"
data-slot="profile-rail"
data-tip-region=""
data-tour="profile-rail"
data-tour={management ? undefined : 'profile-rail'}
role="group"
>
{/* Fleet: every gateway carries its own home square inside its group, so
@@ -523,7 +527,7 @@ export function ProfileRail() {
overlay is the only place to edit a profile's SOUL.md, and a
single-profile user must be able to edit the default's persona
without first creating a throwaway second profile. */}
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
{!management && <ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />}
{/* Multi-gateway discoverability: before a second source exists, a plug
pinned beside Manage deep-links to the unified Gateways page. Once
@@ -18,6 +18,7 @@ import { SidebarRowStack } from '../chrome'
import { useWorkspaceNodeOpen } from './model'
import { SidebarWorkspaceGroup } from './workspace-group'
import {
groupContainsSession,
mergeRepoWorktreeGroups,
overlayRepoLanes,
type SidebarProjectTree,
@@ -32,6 +33,7 @@ import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
// headers so the folders stay distinguishable.
export function EnteredProjectContent({
project,
activeSessionId,
renderRows,
onNewSession,
onNewSessionSplit,
@@ -40,6 +42,7 @@ export function EnteredProjectContent({
removedSessionIds
}: {
project: SidebarProjectTree
activeSessionId?: null | string
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
onNewSessionSplit?: NewSessionSplitHandler
@@ -63,6 +66,7 @@ export function EnteredProjectContent({
<>
{project.repos.map(repo => (
<RepoFlatSection
activeSessionId={activeSessionId}
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
key={repo.id}
liveSessions={liveSessions}
@@ -80,6 +84,7 @@ export function EnteredProjectContent({
function RepoFlatSection({
repo,
activeSessionId,
showHeader,
renderRows,
onNewSession,
@@ -89,6 +94,7 @@ function RepoFlatSection({
removedSessionIds
}: {
repo: SidebarWorkspaceTree
activeSessionId?: null | string
showHeader: boolean
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
@@ -139,6 +145,7 @@ function RepoFlatSection({
!dismissedWorktrees.includes(group.id) ||
(removedWorktrees.includes(group.id) && group.path && discoveredWorktreePaths.has(group.path))
)
const activeRepo = Boolean(activeSessionId && ordered.some(group => groupContainsSession(group, activeSessionId)))
// Removal asks how: actually `git worktree remove` it, or just hide the lane
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
@@ -169,6 +176,7 @@ function RepoFlatSection({
<>
{ordered.map(group => (
<SidebarWorkspaceGroup
activeSessionId={activeSessionId}
group={group}
key={group.id}
// The kanban bucket is read-only: it aggregates many task worktrees, so
@@ -275,6 +283,7 @@ function RepoFlatSection({
/>
)
}
active={activeRepo}
emphasis
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
label={repo.label}
@@ -2,6 +2,7 @@
export { EnteredProjectContent } from './entered-content'
export {
orderProjectsByIds,
prioritizePinnedProjects,
PROJECT_PREVIEW_COUNT,
projectTreeCwd,
sortProjectsForOverview,
@@ -12,9 +13,11 @@ export { ProjectMenu } from './project-menu'
export { SidebarWorkspaceGroup } from './workspace-group'
export {
excludeProjectSessions,
groupContainsSession,
liveSessionProjectId,
overlayLiveLanes,
overlayLivePreviews,
projectContainsSession,
reconcileEnteredProjectSessions,
sessionMatchesProjectFilter,
sessionRecency,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { orderProjectsByIds, sortProjectsForOverview } from './model'
import { orderProjectsByIds, prioritizePinnedProjects, sortProjectsForOverview } from './model'
import { NO_PROJECT_ID, type SidebarProjectTree } from './workspace-groups'
function makeProject(id: string, sessionCount: number): SidebarProjectTree {
@@ -76,3 +76,26 @@ describe('sortProjectsForOverview', () => {
expect(ids(sortProjectsForOverview(projects, 'active'))).toEqual([NO_PROJECT_ID, 'active', 'scanned'])
})
})
describe('prioritizePinnedProjects', () => {
it('keeps Home first and pins ahead of an active or newly active project', () => {
const active = { ...makeProject('active', 5), isAuto: false }
const projects = orderProjectsByIds(
sortProjectsForOverview([makeProject('pinned', 0), active, home(), makeProject('new', 3)], 'active'),
['active', 'pinned']
)
expect(ids(prioritizePinnedProjects(projects, ['pinned']))).toEqual([
NO_PROJECT_ID, 'pinned', 'new', 'active'
])
})
it('uses pin order and ignores stale or synthetic ids', () => {
const projects = [home(), makeProject('a', 1), makeProject('b', 1)]
expect(ids(prioritizePinnedProjects(projects, ['missing', NO_PROJECT_ID, 'b', 'a']))).toEqual([
NO_PROJECT_ID, 'b', 'a'
])
})
})
@@ -117,6 +117,24 @@ export function orderProjectsByIds(projects: SidebarProjectTree[], orderIds: str
])
}
// Pin order is independent of recency and drag order. Home remains the fixed
// first row; a newly active or discovered project cannot jump above a pin.
export function prioritizePinnedProjects(projects: SidebarProjectTree[], pinnedIds: readonly string[]): SidebarProjectTree[] {
if (!pinnedIds.length) {
return projects
}
const byId = new Map(projects.map(project => [project.id, project]))
const pinned = pinnedIds.map(id => byId.get(id)).filter((project): project is SidebarProjectTree =>
Boolean(project && !project.isNoProject)
)
const pinnedSet = new Set(pinned.map(project => project.id))
return homeFirst([...pinned, ...projects.filter(project => !pinnedSet.has(project.id))])
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so
// linked worktrees still appear even when their sessions aren't in the recents
// payload currently loaded in memory.
@@ -94,6 +94,18 @@ describe('ProjectOverviewRow', () => {
expect(container.querySelector('[data-sessions-project="p1"]')).toBeTruthy()
})
it('marks the project that owns the focused session, including preview-only rows', () => {
const { container } = render(
<ProjectOverviewRow
activeSessionId="focused"
previewSessions={[{ id: 'focused' } as unknown as SessionInfo]}
project={project}
/>
)
expect(container.querySelector('[data-current-session="true"]')).toBeTruthy()
})
it('explicit projects keep the folder-library glyph and a plain accessible name', () => {
const explicit = { id: 'p1', label: 'Explicit' } as unknown as SidebarProjectTree
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $sidebarShowAllSessions } from '@/store/layout'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $pinnedProjectIdsByProfile } from '@/store/project-pins'
import {
SIDEBAR_LEAD_ICON_SIZE,
@@ -25,7 +27,7 @@ import {
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
import { ProjectContextMenu, ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
import { projectContainsSession, type SidebarProjectTree } from './workspace-groups'
import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
@@ -79,6 +81,7 @@ interface ProjectOverviewRowProps {
onNewSessionSplit?: NewSessionSplitHandler
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
activeProjectId?: null | string
activeSessionId?: null | string
previewSessions?: SessionInfo[]
reorderable?: boolean
dragging?: boolean
@@ -94,6 +97,7 @@ export function ProjectOverviewRow({
onNewSessionSplit,
renderRows,
activeProjectId,
activeSessionId,
previewSessions,
reorderable = false,
dragging = false,
@@ -104,11 +108,19 @@ export function ProjectOverviewRow({
const { t } = useI18n()
const s = t.sidebar
const isActive = project.id === activeProjectId
const isCurrentSessionProject = projectContainsSession(
previewSessions ? { ...project, previewSessions } : project,
activeSessionId
)
const isCurrentProject = isActive || isCurrentSessionProject
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
// The appearance popover anchors here (the full row) so it opens flush with
// the sidebar's content edge regardless of which side the sidebar is on.
const rowRef = useRef<HTMLDivElement>(null)
const showAllSessions = useStore($sidebarShowAllSessions)
const profile = normalizeProfileKey(useStore($activeGatewayProfile))
const pinnedByProfile = useStore($pinnedProjectIdsByProfile)
const isPinned = (pinnedByProfile[profile] ?? []).includes(project.id)
const limit = showAllSessions ? Infinity : PROJECT_PREVIEW_COUNT
const fetched = (previewSessions ?? []).slice(0, limit)
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, limit)) : []
@@ -123,7 +135,7 @@ export function ProjectOverviewRow({
{projectIcon(project)}
</SidebarRowGrab>
) : (
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
<SidebarRowLead className={cn(isCurrentProject && 'text-(--ui-accent)')}>{projectIcon(project)}</SidebarRowLead>
)
const labelLink = (
@@ -135,7 +147,7 @@ export function ProjectOverviewRow({
? `${s.projects.enter(project.label)} (${s.projects.autoDiscovered})`
: s.projects.enter(project.label)
}
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
labelClassName={cn('hover:text-foreground hover:underline', isCurrentProject && 'font-semibold text-foreground')}
onClick={() => onEnter?.(project.id)}
>
{project.label}
@@ -180,9 +192,27 @@ export function ProjectOverviewRow({
)}
</>
}
className={cn(dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
className={cn(
dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)',
isCurrentSessionProject && 'bg-(--ui-row-active-background) ring-1 ring-inset ring-(--ui-accent)/30'
)}
data-current-session={isCurrentSessionProject ? 'true' : undefined}
data-glass-opaque={dragging ? '' : undefined}
label={project.isAuto ? <Tip label={s.projects.autoDiscovered}>{labelLink}</Tip> : labelLink}
label={
<>
{project.isAuto ? <Tip label={s.projects.autoDiscovered}>{labelLink}</Tip> : labelLink}
{isCurrentSessionProject && (
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-(--ui-accent)" />
)}
{isPinned && (
<Tip label={s.projects.pinned}>
<span aria-label={s.projects.pinned} className="flex shrink-0 items-center text-(--ui-text-tertiary)" role="img">
<Codicon name="pin" size="0.6875rem" />
</span>
</Tip>
)}
</>
}
lead={lead}
// The label is grab surface too, not just the lead's grabber — same
// listeners, minus the controls that keep their own gestures. A project
@@ -1,6 +1,8 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { $pinnedProjectIdsByProfile, toggleProjectPin } from '@/store/project-pins'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
@@ -35,6 +37,9 @@ vi.mock('@/i18n', () => ({
menuDelete: 'Delete',
menuRename: 'Rename',
menuSetActive: 'Set active',
menuPin: 'Pin to top',
menuUnpin: 'Unpin',
pinned: 'Pinned',
noColor: 'No color',
removeFromSidebar: 'Remove from sidebar',
reveal: 'Reveal in file manager'
@@ -67,6 +72,18 @@ vi.mock('@/store/projects', () => ({
setProjectAppearance: vi.fn().mockResolvedValue(false)
}))
vi.mock('@/store/project-pins', async () => {
const { atom } = await import('nanostores')
return { $pinnedProjectIdsByProfile: atom({}), toggleProjectPin: vi.fn() }
})
vi.mock('@/store/profile', async () => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom('default'), normalizeProfileKey: (value: string) => value || 'default' }
})
const project = {
color: null,
icon: null,
@@ -95,6 +112,20 @@ describe('ProjectMenu', () => {
expect(tipTrigger(button)).toBeNull()
})
it('offers pin and unpin in the project menu', async () => {
$pinnedProjectIdsByProfile.set({})
render(<ProjectMenu isActive={false} project={project} />)
openTriggerMenu(screen.getByRole('button', { name: 'Actions' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Pin to top' }))
expect(toggleProjectPin).toHaveBeenCalledWith('default', 'p1')
$pinnedProjectIdsByProfile.set({ default: ['p1'] })
openTriggerMenu(screen.getByRole('button', { name: 'Actions' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Unpin' }))
expect(toggleProjectPin).toHaveBeenLastCalledWith('default', 'p1')
})
// When anchorRef is absent, PopoverAnchor wraps the dropdown trigger so the
// appearance popover positions against the kebab. asChild must still reach
// the real button (no non-forwarding wrappers inside the chain — #67500).
@@ -22,6 +22,8 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $pinnedProjectIdsByProfile, toggleProjectPin } from '@/store/project-pins'
import {
copyPath,
deleteProject,
@@ -56,6 +58,9 @@ function useProjectActions({
const p = t.sidebar.projects
const target = { id: project.id, name: project.label }
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
const profile = normalizeProfileKey(useStore($activeGatewayProfile))
const pinnedByProfile = useStore($pinnedProjectIdsByProfile)
const isPinned = (pinnedByProfile[profile] ?? []).includes(project.id)
const removeAuto = () => {
dismissAutoProject(project.id)
@@ -112,6 +117,13 @@ function useProjectActions({
}
]
const pinItem: ActionItemSpec = {
icon: 'pin',
key: 'pin',
label: isPinned ? p.menuUnpin : p.menuPin,
onSelect: () => toggleProjectPin(profile, project.id)
}
const dangerItem: ActionItemSpec = project.isAuto
? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' }
: {
@@ -134,7 +146,7 @@ function useProjectActions({
/>
)
return { confirmDialog, dangerItem, identityItems, pathItems }
return { confirmDialog, dangerItem, identityItems, pathItems, pinItem }
}
// Per-project actions. The kebab keeps its row-anchored Appearance popover; the
@@ -166,7 +178,7 @@ export function ProjectMenu({
// when the panes are flipped (sidebar on the right).
const panesFlipped = useStore($panesFlipped)
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
const { confirmDialog, dangerItem, identityItems, pathItems, pinItem } = useProjectActions({
isActive,
onExitScope,
project,
@@ -248,6 +260,8 @@ export function ProjectMenu({
<DropdownMenuSeparator />
</>
)}
{renderActionItem(DROPDOWN_KIT, pinItem)}
<DropdownMenuSeparator />
{pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
<DropdownMenuSeparator />
{renderActionItem(DROPDOWN_KIT, dangerItem)}
@@ -294,7 +308,7 @@ export function ProjectContextMenu({
const { t } = useI18n()
const p = t.sidebar.projects
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
const { confirmDialog, dangerItem, identityItems, pathItems, pinItem } = useProjectActions({
isActive,
onExitScope,
project,
@@ -327,7 +341,8 @@ export function ProjectContextMenu({
</kit.SubContent>
</kit.Sub>
)}
{(identityItems.length > 0 || canTheme) && <kit.Separator />}
{renderActionItem(kit, pinItem)}
<kit.Separator />
{pathItems.map(item => renderActionItem(kit, item))}
<kit.Separator />
{renderActionItem(kit, dangerItem)}
@@ -14,13 +14,14 @@ import { notifyError } from '@/store/notifications'
import { newSessionInProfile, pinNewChatProfile, selectProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
import { $sessionProfilesUsage } from '@/store/session'
import { $sessionDotStateById, hasLiveTurn } from '@/store/session-dot-state'
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
import { SidebarGroupRow, SidebarRowLead, SidebarRowLink, SidebarRowStack } from '../chrome'
import { rankSessions } from '../order'
import { PROJECT_PREVIEW_COUNT, SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
import type { SidebarSessionGroup } from './workspace-groups'
import { groupContainsSession, type SidebarSessionGroup } from './workspace-groups'
import {
WorkspaceAddButton,
WorkspaceContextMenu,
@@ -32,6 +33,7 @@ import {
interface SidebarWorkspaceGroupProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
activeSessionId?: null | string
onNewSession?: (path: null | string) => void
onNewSessionSplit?: NewSessionSplitHandler
// When set (linked worktree rows), shows a remove affordance that runs a real
@@ -42,6 +44,7 @@ interface SidebarWorkspaceGroupProps {
export function SidebarWorkspaceGroup({
group,
renderRows,
activeSessionId = null,
onNewSession,
onNewSessionSplit,
onRemove
@@ -52,6 +55,10 @@ export function SidebarWorkspaceGroup({
// Totals for the whole profile, not the loaded page — a selector so a refresh
// that leaves this profile's spend unchanged doesn't repaint its header.
const usage = useStoreSelector($sessionProfilesUsage, all => all[group.id])
const active = groupContainsSession(group, activeSessionId)
const working = useStoreSelector($sessionDotStateById, states =>
group.sessions.some(session => hasLiveTurn(states[session.id] ?? 'idle'))
)
const rankIds = useStore($sidebarSessionRankIds)
// The sidebar's "Show all sessions" preference lifts the lane's page too —
// the same switch that widens the project overview, read at the leaf like
@@ -212,11 +219,13 @@ export function SidebarWorkspaceGroup({
</div>
)
}
active={active}
icon={leadingIcon}
label={group.label}
onToggle={toggleOpen}
open={open}
title={group.path ? displayPath(group.path) : undefined}
working={working}
/>
</WorkspaceContextMenu>
)}
@@ -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
@@ -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 (
<div
className={cn(
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
'group/workspace flex min-h-6 items-center gap-1 rounded-md px-2 pt-1 text-[0.6875rem]',
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)',
active && 'bg-(--ui-row-active-background) text-foreground',
working && 'ring-1 ring-inset ring-(--ui-accent)/30'
)}
data-current-session={active ? 'true' : undefined}
data-working={working ? 'true' : undefined}
ref={ref}
{...rest}
>
@@ -229,8 +239,14 @@ export function WorkspaceHeader({
onClick={onToggle}
type="button"
>
<SidebarRowLead>{icon}</SidebarRowLead>
<SidebarRowLead className={cn(active && 'text-(--ui-accent)')}>{icon}</SidebarRowLead>
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
{active && (
<span
aria-hidden="true"
className={cn('size-1.5 shrink-0 rounded-full bg-(--ui-accent)', working && 'animate-pulse')}
/>
)}
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
@@ -357,7 +357,8 @@ function SidebarSessionRowImpl({
// metadata / preview lines below need the extra rows (#68119).
!card && density !== 'compact' && 'min-h-[2.75rem]',
!card && density === 'detailed' && 'min-h-[3.875rem]',
isSelected && 'bg-(--ui-row-active-background)',
isSelected &&
'bg-(--ui-row-active-background) text-foreground before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-(--ui-accent)',
// Open in another pane: the SAME band, just weaker. Its own mixed
// token rather than row opacity — dimming the whole row would take
// the title and the status dot down with it.
@@ -369,6 +370,7 @@ function SidebarSessionRowImpl({
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
className
)}
data-current-session={isSelected ? 'true' : undefined}
data-glass-opaque={dragging ? '' : undefined}
data-working={liveTurn ? 'true' : undefined}
// The row runs BOTH drags off one press, and each declines outside its
@@ -495,7 +497,10 @@ function SidebarSessionRowImpl({
<span className="min-w-0 flex-1 self-center">
<OverflowTip label={title}>
<SidebarRowLabel
className="hover-marquee block font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90"
className={cn(
'hover-marquee block font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90',
isSelected && 'font-semibold'
)}
onPointerEnter={armMarquee}
onPointerLeave={disarmMarquee}
>
@@ -27,7 +27,7 @@ import {
listGroupNodeId,
toggleWorkspaceNodeCollapsed
} from '@/store/layout'
import { sessionPinId } from '@/store/session'
import { sessionMatchesStoredId, sessionPinId } from '@/store/session'
import { $sessionDotStateById, hasLiveTurn } from '@/store/session-dot-state'
import { SidebarDateDivider, SidebarSectionMeta } from './chrome'
@@ -132,6 +132,7 @@ interface SidebarSessionsSectionProps {
// which then passes `projectContent` on the next render. Takes precedence
// over `tree` / `groups`.
projectOverview?: SidebarProjectTree[]
pinnedProjectIds?: readonly string[]
// Per-project preview rows (from the backend tree), keyed by project id.
projectOverviewPreviews?: Record<string, SessionInfo[]>
// True while the backend project tree is loading (overview skeleton).
@@ -206,6 +207,7 @@ export function SidebarSessionsSection({
footer,
groups,
projectOverview,
pinnedProjectIds,
projectOverviewPreviews,
projectsLoading = false,
onEnterProject,
@@ -269,7 +271,7 @@ export function SidebarSessionsSection({
branchStem,
card,
isPinned: pinned,
isSelected: session.id === activeSessionId,
isSelected: sessionMatchesStoredId(session, activeSessionId ?? ''),
onArchive: () => onArchiveSession(session.id),
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
onDelete: () => onDeleteSession(session.id),
@@ -485,6 +487,7 @@ export function SidebarSessionsSection({
{projectBackRow}
{hasProjectContent ? (
<EnteredProjectContent
activeSessionId={activeSessionId}
liveSessions={liveSessions}
onNewSession={onNewSessionInWorkspace}
onNewSessionSplit={onNewSessionSplit}
@@ -507,12 +510,14 @@ export function SidebarSessionsSection({
// wired — Home stays outside the sortable list, it's a fixture.
const home = projectOverview[0]?.isNoProject ? projectOverview[0] : undefined
const sortableProjects = home ? projectOverview.slice(1) : projectOverview
const projectsDraggable = sortableProjects.length > 1 && !!onReorderProjects
const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow
const pins = new Set(pinnedProjectIds ?? [])
const pinnedProjects = sortableProjects.filter(project => pins.has(project.id))
const otherProjects = sortableProjects.filter(project => !pins.has(project.id))
const projectRow = (project: SidebarProjectTree, Component: typeof ProjectOverviewRow) => (
<Component
activeProjectId={activeProjectId}
activeSessionId={activeSessionId}
key={project.id}
onEnter={onEnterProject}
onNewSession={onNewSessionInWorkspace}
@@ -527,22 +532,33 @@ export function SidebarSessionsSection({
/>
)
const rows = sortableProjects.map(project => projectRow(project, Row))
const projectList = (items: SidebarProjectTree[], other: SidebarProjectTree[], pinnedGroup: boolean) => {
if (!items.length) {
return null
}
inner = (
<>
{home && projectRow(home, ProjectOverviewRow)}
{projectsDraggable && onReorderProjects ? (
const draggable = items.length > 1 && !!onReorderProjects
const Row = draggable ? SortableProjectOverviewRow : ProjectOverviewRow
const rows = items.map(project => projectRow(project, Row))
return draggable && onReorderProjects ? (
<ReorderableList
ids={sortableProjects.map(project => project.id)}
onReorder={onReorderProjects}
ids={items.map(project => project.id)}
onReorder={ids => onReorderProjects(pinnedGroup
? [...ids, ...other.map(project => project.id)]
: [...other.map(project => project.id), ...ids])}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
rows
)}
) : rows
}
inner = (
<>
{home && projectRow(home, ProjectOverviewRow)}
{projectList(pinnedProjects, otherProjects, true)}
{projectList(otherProjects, pinnedProjects, false)}
</>
)
} else if (groups?.length && groups.every(group => group.mode === 'profile' && group.profile)) {
@@ -1,42 +1,93 @@
/**
* user-account-card.tsx — 侧栏底部的用户卡(§21 DB-T3,U-4)。
* Sidebar account entry below the pane body, including the profile submenu.
*
* signed_in 时显示:字母章 + U-4 展示名 + 邮箱;离线(U-3)在名后标
* amber 圆点,点击进 `/me` 资料页(缓存数据时间在那里注明)。
* signed_in 时只显示 U-4 展示名;离线(U-3)在名后标 amber 圆点,
* 展开时点击展示名进 `/me`,尾部菜单与收缩时的用户图标提供账号操作。
* 未登录不渲染——那时登录门(DB-T2)本就盖着全屏。
*/
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { ME_ROUTE } from '@/app/me'
import { navigateToWorkspacePage } from '@/app/routes'
import { AvatarChip } from '@/components/ui/avatar-chip'
import { ChangePasswordDialog } from '@/app/me/change-password-dialog'
import { navigateToWorkspacePage, SETTINGS_ROUTE } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { $machineBinding, $userAccount, userAccountDisplayName } from '@/store/user-account'
import { userAccountErrorText } from '@/lib/user-account-errors'
import { cn } from '@/lib/utils'
import { notify } from '@/store/notifications'
import {
$machineBinding,
$userAccount,
userAccountDisplayName,
userAccountLogout,
type UserAccountState
} from '@/store/user-account'
export function UserAccountCard() {
const { t } = useI18n()
import { ProfileSubmenu } from './profile-dropdown-switcher'
export interface UserAccountCardProps {
compact?: boolean
}
export function UserAccountCard({ compact = false }: UserAccountCardProps) {
const account = useStore($userAccount)
return account.status === 'signed_in' ? (
<SignedInAccountCard account={account} compact={compact} key={`${account.site}:${account.profile?.email ?? ''}`} />
) : null
}
interface SignedInAccountCardProps extends UserAccountCardProps {
account: Extract<UserAccountState, { status: 'signed_in' }>
}
function SignedInAccountCard({ account, compact }: SignedInAccountCardProps) {
const { t } = useI18n()
const binding = useStore($machineBinding)
const navigate = useNavigate()
const [passwordOpen, setPasswordOpen] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
if (account.status !== 'signed_in') {return null}
const name = userAccountDisplayName(account.profile) || account.profile?.email || ''
// DB-T6:自动绑定失败或 pending 待确认 → 名后 amber 点,点卡片进 /me 处理。
const bindIssue = binding != null && !binding.bound && (binding.bindError != null || binding.pending != null)
return (
<button
aria-label={t.userAccount.me.cardAria}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent"
onClick={() => navigateToWorkspacePage(navigate, ME_ROUTE)}
async function logout() {
setLoggingOut(true)
try {
await userAccountLogout()
} catch (failure) {
notify({ kind: 'error', message: userAccountErrorText(t.userAccount, failure) })
} finally {
setLoggingOut(false)
}
}
const button = (
<Button
aria-label={compact ? t.userAccount.me.menuAria : t.userAccount.me.cardAria}
className={cn('relative min-w-0 [-webkit-app-region:no-drag]', !compact && 'w-full justify-start text-left')}
data-tour={compact ? 'profile-menu' : undefined}
onClick={compact ? undefined : () => navigateToWorkspacePage(navigate, ME_ROUTE)}
size={compact ? 'icon-sm' : 'default'}
type="button"
variant="ghost"
>
<AvatarChip className="size-7" name={name} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{name}</span>
<Codicon aria-hidden="true" name="account" size="0.875rem" />
{!compact && <span className="min-w-0 flex-1 truncate">{name || t.userAccount.me.cardAria}</span>}
<span className={cn('flex shrink-0 items-center gap-1.5', compact && 'absolute right-0 top-0 gap-0.5')}>
{account.offline && (
<span
aria-label={t.userAccount.me.cardOffline}
@@ -52,10 +103,66 @@ export function UserAccountCard() {
/>
)}
</span>
{account.profile?.email && name !== account.profile.email && (
<span className="block truncate text-xs text-muted-foreground">{account.profile.email}</span>
</Button>
)
return (
<div
className={
compact
? 'grid shrink-0 place-items-center pb-1'
: 'flex min-w-0 shrink-0 items-center gap-1 px-2.5 pb-1.5 pt-0.5'
}
data-slot="sidebar-account"
>
{!compact && <div className="min-w-0 flex-1">{button}</div>}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{compact ? (
button
) : (
<Button aria-label={t.userAccount.me.menuAria} data-tour="profile-menu" size="icon-xs" variant="ghost">
<Codicon aria-hidden="true" name="ellipsis" size="0.875rem" />
</Button>
)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 min-w-0 max-w-[calc(100vw-1rem)]"
collisionPadding={8}
side="top"
>
<DropdownMenuItem onSelect={() => navigateToWorkspacePage(navigate, ME_ROUTE)}>
<Codicon aria-hidden="true" name="account" size="0.875rem" />
{t.userAccount.me.details}
</DropdownMenuItem>
<DropdownMenuItem
disabled={account.offline || !account.profile?.email}
onSelect={() => setPasswordOpen(true)}
>
<Codicon aria-hidden="true" name="key" size="0.875rem" />
{t.userAccount.me.changePassword}
</DropdownMenuItem>
<ProfileSubmenu />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate(SETTINGS_ROUTE)}>
<Codicon aria-hidden="true" name="settings-gear" size="0.875rem" />
{t.commandCenter.settings}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={loggingOut} onSelect={() => void logout()}>
<Codicon aria-hidden="true" name="sign-out" size="0.875rem" />
{t.userAccount.me.logout}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{passwordOpen && account.profile?.email && (
<ChangePasswordDialog
email={account.profile.email}
onClose={() => setPasswordOpen(false)}
site={account.site}
/>
)}
</div>
)
}
@@ -0,0 +1,173 @@
import { useStore } from '@nanostores/react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { group, split } from '@/components/pane-shell/tree/model'
import { NarrowOverlays } from '@/components/pane-shell/tree/renderer/narrow-overlays'
import { TreeSplit } from '@/components/pane-shell/tree/renderer/tree-split'
import {
$collapsedTreeSides,
$hiddenTreePanes,
$layoutTree,
$narrowViewport,
activateTreePane,
bindTreeSideVisibility
} from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import { $sidebarOpen, setSidebarOpen } from '@/store/layout'
import { $machineBinding, $userAccount } from '@/store/user-account'
import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom'
import { UserAccountCard } from './user-account-card'
import { SidebarZoneLead } from './zone-tabs'
let disposeContributions: () => void
beforeAll(() => {
stubResizeObserver()
stubMenuDomApis()
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
HTMLElement.prototype.scrollIntoView ??= () => undefined
bindTreeSideVisibility('left', $sidebarOpen, setSidebarOpen)
})
beforeEach(() => {
$hiddenTreePanes.set(new Set())
$narrowViewport.set(false)
setSidebarOpen(true)
$collapsedTreeSides.set(new Set())
$userAccount.set({
status: 'signed_in',
site: 'https://account.example',
offline: true,
profileAsOf: null,
profile: {
email: 'owner@example.com',
username: 'owner',
nickname: 'Workspace owner',
phone: '',
planId: '',
periodEnd: '',
dataAsOf: ''
}
})
$machineBinding.set({ bound: false, bindError: 'network' })
disposeContributions = registry.registerMany([
{
area: 'panes',
id: 'sessions',
title: 'Sessions',
data: {
placement: 'left',
width: '237px',
collapsible: true,
hideOnly: true,
zoneLead: (compact?: boolean) => <SidebarZoneLead compact={compact} />,
zoneFooter: (compact: boolean) => <UserAccountCard compact={compact} />
},
render: () => <button type="button">Sessions list</button>
},
{
area: 'panes',
id: 'bots',
title: 'Agents',
data: { placement: 'left', width: '237px', collapsible: true, hideOnly: true },
render: () => <div>Agent list</div>
},
{ area: 'panes', id: 'workspace', data: { placement: 'main' }, render: () => <div>Chat</div> }
])
$layoutTree.set(
split('row', [group(['sessions', 'bots'], { id: 'sidebar-zone' }), group(['workspace'], { id: 'main-zone' })])
)
})
afterEach(() => {
cleanup()
disposeContributions()
$layoutTree.set(null)
$narrowViewport.set(false)
$userAccount.set({ status: 'unknown' })
$machineBinding.set(null)
setSidebarOpen(true)
})
function Layout() {
const tree = useStore($layoutTree)
const location = useLocation()
return (
<>
{tree?.type === 'split' && <TreeSplit node={tree} root rootRow topEdge />}
<NarrowOverlays />
<output data-testid="route">{location.pathname}</output>
</>
)
}
function renderLayout() {
return render(
<MemoryRouter>
<Layout />
</MemoryRouter>
)
}
it('keeps the account below pane content and available across sidebar tabs and narrow overlays', () => {
renderLayout()
const account = screen.getByRole('button', { name: 'Open your account page' })
const footer = account.closest('[data-zone-footer]')
expect(footer).not.toBeNull()
expect(account.closest('[data-zone-lead]')).toBeNull()
expect(
screen.getByText('Sessions list').compareDocumentPosition(account) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
expect(screen.getByText('Workspace owner')).toBeTruthy()
act(() => activateTreePane('sidebar-zone', 'bots'))
expect(screen.getByRole('button', { name: 'Open your account page' })).toBe(account)
expect(screen.getByText('Agent list')).toBeTruthy()
act(() => $narrowViewport.set(true))
act(() => {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: 'sessions', mode: 'open' } }))
})
const overlayAccount = screen.getByRole('button', { name: 'Open your account page' })
expect(overlayAccount.closest('[data-zone-footer]')).not.toBeNull()
expect(screen.getByText('Workspace owner')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Updates' }).closest('[data-zone-lead]')).not.toBeNull()
expect(globalThis.document.querySelector('[data-narrow-overlay-tab="sessions"]')?.textContent).toBe('Sessions')
expect(globalThis.document.querySelector('[data-slot="sidebar-zone-tabs"]')).toBeNull()
fireEvent.pointerDown(globalThis.document.querySelector('[data-narrow-overlay-tab="bots"]')!, { button: 0 })
expect(screen.getByRole('button', { name: 'Open your account page' })).toBe(overlayAccount)
fireEvent.click(overlayAccount)
expect(screen.getByTestId('route').textContent).toBe('/me')
})
it('provides an icon-only account entry on the single collapsed rail, even when its pane is in another left zone', () => {
$layoutTree.set(
split('row', [
group(['bots'], { id: 'agents-zone' }),
group(['sessions'], { id: 'sidebar-zone' }),
group(['workspace'], { id: 'main-zone' })
])
)
renderLayout()
act(() => setSidebarOpen(false))
const rail = globalThis.document.querySelector('[data-collapsed-side-rail="left"]') as HTMLElement
const account = within(rail).getByRole('button', { name: 'Account menu' })
expect(within(rail).queryByText('Workspace owner')).toBeNull()
expect(within(rail).getByLabelText(/Offline.*cached data/)).toBeTruthy()
expect(within(rail).getByLabelText(/Machine binding needs attention/)).toBeTruthy()
fireEvent.keyDown(account, { key: 'Enter' })
fireEvent.click(screen.getByRole('menuitem', { name: 'User details' }))
expect(screen.getByTestId('route').textContent).toBe('/me')
expect($sidebarOpen.get()).toBe(false)
act(() => $userAccount.set({ status: 'signed_out' }))
expect(within(rail).queryByRole('button', { name: 'Account menu' })).toBeNull()
})
@@ -0,0 +1,173 @@
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 { $notifications } from '@/store/notifications'
import { $machineBinding, $userAccount } from '@/store/user-account'
import { stubMenuDomApis } from '@/test/jsdom'
import { UserAccountCard } from './user-account-card'
const ACCOUNT = {
status: 'signed_in' as const,
site: 'https://account.example',
offline: false,
profileAsOf: null,
profile: {
email: 'owner@example.com',
username: 'owner',
nickname: 'Workspace owner',
phone: '',
planId: '',
periodEnd: '',
dataAsOf: ''
}
}
const BINDING = { bound: false as const, bindError: 'network' }
beforeEach(() => {
stubMenuDomApis()
$userAccount.set(ACCOUNT)
$machineBinding.set(BINDING)
$notifications.set([])
})
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
$userAccount.set({ status: 'unknown' })
$machineBinding.set(null)
$notifications.set([])
})
function stubBridge() {
const api = {
logout: vi.fn(async () => ({ ok: true })),
resetRequest: vi.fn(async (_payload: unknown) => ({ ok: true })),
resetConfirm: vi.fn(async (_payload: unknown): Promise<{ ok: true } | { ok: false; code: string }> => ({
ok: true
})),
unbind: vi.fn()
}
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { userAccount: api } })
return api
}
function Account({ compact = false }: { compact?: boolean }) {
const location = useLocation()
return (
<>
<UserAccountCard compact={compact} />
<output data-testid="route">{location.pathname}</output>
</>
)
}
function openMenu() {
fireEvent.keyDown(screen.getByRole('button', { name: 'Account menu' }), { key: 'Enter' })
}
it('uses the same account menu in both widths, with scoped navigation, offline restrictions and non-destructive logout', async () => {
const api = stubBridge()
const view = render(
<MemoryRouter>
<Account />
</MemoryRouter>
)
expect(screen.getByRole('button', { name: 'Open your account page' })).toBeTruthy()
openMenu()
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'User details',
'Change password',
'Profiles',
'Settings',
'Sign out'
])
fireEvent.click(screen.getByRole('menuitem', { name: 'User details' }))
expect(screen.getByTestId('route').textContent).toBe('/me')
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Settings' }))
expect(screen.getByTestId('route').textContent).toBe('/settings')
view.rerender(
<MemoryRouter>
<Account compact />
</MemoryRouter>
)
expect(screen.queryByText('Workspace owner')).toBeNull()
act(() => $userAccount.set({ ...ACCOUNT, offline: true }))
openMenu()
expect(screen.getByRole('menuitem', { name: 'Change password' }).getAttribute('aria-disabled')).toBe('true')
api.logout.mockRejectedValueOnce(new Error('private server details'))
fireEvent.click(screen.getByRole('menuitem', { name: 'Sign out' }))
await waitFor(() => expect($notifications.get()).toHaveLength(1))
expect($notifications.get()[0].message).not.toContain('private server details')
expect($userAccount.get().status).toBe('signed_in')
expect($machineBinding.get()).toBe(BINDING)
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Sign out' }))
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
expect(api.unbind).not.toHaveBeenCalled()
})
it('changes only the signed-in account password through email verification, validates confirmation and requires a new sign-in', async () => {
const api = stubBridge()
render(
<MemoryRouter>
<Account />
</MemoryRouter>
)
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Change password' }))
expect(api.resetRequest).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(api.logout).not.toHaveBeenCalled()
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Change password' }))
fireEvent.click(screen.getByRole('button', { name: 'Send reset code' }))
await screen.findByLabelText('Reset code')
expect(api.resetRequest).toHaveBeenCalledWith({ site: ACCOUNT.site, email: ACCOUNT.profile.email })
fireEvent.change(screen.getByLabelText('Reset code'), { target: { value: '123456' } })
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'new-password' } })
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'different' } })
fireEvent.click(screen.getByRole('button', { name: 'Set new password' }))
await screen.findByText('Passwords do not match.')
expect(api.resetConfirm).not.toHaveBeenCalled()
api.resetConfirm.mockResolvedValueOnce({ ok: false, code: 'reset_code_invalid_or_expired' })
fireEvent.change(screen.getByLabelText('Confirm password'), { target: { value: 'new-password' } })
fireEvent.click(screen.getByRole('button', { name: 'Set new password' }))
await screen.findByText('The reset code is invalid or has expired.')
expect($userAccount.get().status).toBe('signed_in')
fireEvent.click(screen.getByRole('button', { name: 'Set new password' }))
await screen.findByText('Password updated. Sign in with the new password.')
expect(api.resetConfirm).toHaveBeenLastCalledWith({
site: ACCOUNT.site,
email: ACCOUNT.profile.email,
code: '123456',
password: 'new-password'
})
expect(screen.queryByLabelText('New password')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to sign in' }))
await waitFor(() => expect($userAccount.get().status).toBe('signed_out'))
expect(api.logout).toHaveBeenCalledOnce()
expect(api.unbind).not.toHaveBeenCalled()
act(() => $userAccount.set(ACCOUNT))
expect(screen.queryByRole('dialog')).toBeNull()
openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'Change password' }))
act(() => $userAccount.set({ status: 'signed_out', sessionExpired: true }))
act(() => $userAccount.set(ACCOUNT))
expect(screen.queryByRole('dialog')).toBeNull()
})
@@ -0,0 +1,114 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { UpdatesOverlay } from '@/app/updates-overlay'
import { $connection } from '@/store/session'
import {
$backendUpdateChecking,
$backendUpdateStatus,
$desktopVersion,
$updateChecking,
$updateOverlayOpen,
$updateOverlayTarget,
$updateStatus,
resetUpdateApplyState
} from '@/store/updates'
import { stubMenuDomApis } from '@/test/jsdom'
import { SidebarVersionMenu } from './version-menu'
const version = { appVersion: '0.77.3', electronVersion: '0', nodeVersion: '0', platform: 'test', hermesRoot: '/tmp' }
const check = vi.fn(async () => ({ supported: true, updateAvailable: true, targetSha: 'next', behind: 1 }))
const apply = vi.fn()
const api = vi.fn(async () => ({ current_version: '0.22.1', update_available: false, can_apply: true }))
beforeEach(() => {
stubMenuDomApis()
vi.stubGlobal('hermesDesktop', { getVersion: async () => version, api, updates: { check, apply } })
$connection.set(null)
$desktopVersion.set(version)
$updateStatus.set(null)
$backendUpdateStatus.set(null)
$updateChecking.set(false)
$backendUpdateChecking.set(false)
$updateOverlayOpen.set(false)
resetUpdateApplyState()
})
afterEach(() => {
cleanup()
$connection.set(null)
$desktopVersion.set(null)
$updateStatus.set(null)
$backendUpdateStatus.set(null)
$updateOverlayOpen.set(false)
resetUpdateApplyState()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
function openMenu() {
fireEvent.keyDown(screen.getByRole('button', { name: 'Updates' }), { key: 'Enter' })
}
it('checks the desktop through the existing overlay without starting installation', async () => {
render(
<>
<SidebarVersionMenu />
<UpdatesOverlay />
</>
)
expect(screen.getByText(`v${version.appVersion}`)).toBeTruthy()
openMenu()
expect(screen.getByText(`Hermes Desktop v${version.appVersion}`)).toBeTruthy()
fireEvent.click(screen.getByRole('menuitem', { name: 'Check now' }))
await screen.findByText('New update available')
expect($updateOverlayTarget.get()).toBe('client')
expect(check).toHaveBeenCalledWith({ force: true })
expect(apply).not.toHaveBeenCalled()
act(() => $updateOverlayOpen.set(false))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
openMenu()
expect(screen.getByRole('menuitem', { name: 'Update now' })).toBeTruthy()
expect(apply).not.toHaveBeenCalled()
})
it('keeps the remote-backend target in the compact menu and routes its forced check separately', async () => {
$connection.set({
mode: 'remote',
connectionId: 'remote',
baseUrl: 'https://gateway.example',
wsUrl: 'wss://gateway.example/ws',
isFullscreen: false,
nativeOverlayWidth: 0,
token: '',
logs: [],
windowButtonPosition: null
})
$backendUpdateStatus.set({ supported: true, currentVersion: '0.22.1' })
render(
<>
<SidebarVersionMenu compact />
<UpdatesOverlay />
</>
)
expect(screen.queryByText(`v${version.appVersion}`)).toBeNull()
openMenu()
const backendGroup = screen.getByText('Backend v0.22.1').parentElement!
fireEvent.click(within(backendGroup).getByRole('menuitem', { name: 'Check now' }))
await screen.findByText('The backend is running the latest version.')
expect($updateOverlayTarget.get()).toBe('backend')
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/hermes/update/check?force=true' }))
expect(check).not.toHaveBeenCalled()
expect(apply).not.toHaveBeenCalled()
act(() => {
$updateOverlayOpen.set(false)
$connection.set(null)
})
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
openMenu()
expect(screen.queryByText('Backend v0.22.1')).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Check now' })).toBeTruthy()
})
@@ -0,0 +1,156 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { ChevronDown, Download, Loader2, RefreshCw } from '@/lib/icons'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { resolveVersionStatus } from '@/lib/version-status'
import { $connection } from '@/store/session'
import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateChecking,
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
export interface SidebarVersionMenuProps {
compact?: boolean
}
/** One entry for the existing desktop and remote-backend update flows. */
export function SidebarVersionMenu({ compact = false }: SidebarVersionMenuProps) {
const { t } = useI18n()
const copy = t.shell.statusbar
const version = useStore($desktopVersion)?.appVersion
const remote = useStoreSelector($connection, connection => connection?.mode === 'remote')
const clientCheck = useStore($updateChecking)
const clientApply = useStore($updateApply)
const clientUpdate = useStore($updateStatus)
const backendCheck = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const backendUpdate = useStore($backendUpdateStatus)
const targets = [
{ target: 'client' as const, version, checking: clientCheck, apply: clientApply, update: clientUpdate },
...(remote
? [
{
target: 'backend' as const,
version: backendUpdate?.currentVersion,
checking: backendCheck,
apply: backendApply,
update: backendUpdate
}
]
: [])
].map(entry => {
const applying = entry.apply.applying || entry.apply.stage === 'restart'
return {
...entry,
applying,
status: resolveVersionStatus({
applying,
applyMessage: entry.apply.message,
behind: entry.update?.behind ?? 0,
branch: entry.update?.branch,
copy,
remote,
restarting: entry.apply.stage === 'restart',
sha: entry.update?.currentSha?.slice(0, 7),
target: entry.target,
updateAvailable: entry.update?.updateAvailable,
version: entry.version
})
}
})
const busy = targets.some(entry => entry.applying || entry.checking)
const available = targets.some(entry => entry.status.hasUpdate)
return (
<DropdownMenu>
<Tip label={t.settings.about.updates} side={compact ? 'right' : 'bottom'}>
<DropdownMenuTrigger asChild>
<Button
aria-label={t.settings.about.updates}
className={cn('min-w-0 max-w-full [-webkit-app-region:no-drag]', available && 'text-primary')}
data-slot="sidebar-version-menu"
size={compact ? 'icon-sm' : 'inline'}
variant={compact ? 'ghost' : 'text'}
>
{compact ? (
busy ? (
<Loader2 aria-hidden className="animate-spin" />
) : (
<Download aria-hidden />
)
) : (
<>
<span className="truncate text-[0.6875rem]">
{version ? `v${version}` : t.settings.about.versionUnavailable}
</span>
{busy ? (
<Loader2 aria-hidden className="animate-spin" />
) : available ? (
<Download aria-hidden />
) : (
<ChevronDown aria-hidden />
)}
</>
)}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="start" className="w-64 min-w-0 max-w-[calc(100vw-1rem)]" side="bottom">
{targets.map((entry, index) => (
<div key={entry.target}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="truncate">
{entry.target === 'client'
? copy.desktopVersion(entry.version ?? copy.unknown)
: copy.backendVersion(entry.version ?? copy.unknown)}
</DropdownMenuLabel>
{entry.target === 'client' && entry.status.detail && (
<DropdownMenuLabel className="truncate font-normal text-(--ui-text-tertiary)">
{copy.commit(entry.status.detail)}
</DropdownMenuLabel>
)}
<DropdownMenuItem onSelect={() => openUpdateOverlayFor(entry.target)}>
{entry.applying || entry.checking ? (
<Loader2 aria-hidden className="animate-spin" />
) : entry.status.hasUpdate ? (
<Download aria-hidden />
) : (
<RefreshCw aria-hidden />
)}
<span className="truncate">
{entry.applying
? copy.updateInProgress
: entry.checking
? t.updates.checking
: entry.status.hasUpdate
? t.updates.updateNow
: t.settings.about.checkNow}
</span>
</DropdownMenuItem>
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,169 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { findGroupOfPane, group } from '@/components/pane-shell/tree/model'
import {
$hiddenTreePanes,
$layoutTree,
activateTreePane,
markCollapsePane,
setTreeGroupTabStrip,
tabStripVisibleForGroup
} from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import { $desktopVersion } from '@/store/updates'
import { SidebarZoneLead, SidebarZoneTabs } from './zone-tabs'
const STANDING_CHROME = [
{ area: 'panes', id: 'sessions', title: 'sessions', data: { hideOnly: true }, render: () => null },
{ area: 'panes', id: 'hermes-bots:pane', title: 'bots', data: { hideOnly: true }, render: () => null },
// A tool pane docks as a tab too — it counts as standing chrome via
// `isCollapsePane`, not via the hide-only flag.
{ area: 'panes', id: 'terminal', title: 'terminal', data: { placement: 'bottom' }, render: () => null }
]
const leftZone = () => {
const tree = $layoutTree.get()
return tree ? findGroupOfPane(tree, 'sessions') : null
}
const setZone = (...panes: string[]) => $layoutTree.set(group(panes, { active: panes[0], id: 'left-zone' }))
describe('SidebarZoneTabs', () => {
let disposeContributions: () => void
beforeEach(() => {
disposeContributions = registry.registerMany(STANDING_CHROME)
markCollapsePane('terminal')
$hiddenTreePanes.set(new Set())
setZone('sessions', 'hermes-bots:pane', 'terminal')
})
afterEach(() => {
cleanup()
disposeContributions()
$hiddenTreePanes.set(new Set())
$layoutTree.set(null)
$desktopVersion.set(null)
})
// The point of the row: the zone's own strip gives up the window's titlebar
// band, so unless this fires the tabs are painted twice or not at all.
it("lists the zone's chrome tabs and takes over the zone's strip", () => {
render(<SidebarZoneTabs />)
expect(screen.getByRole('tab', { name: 'sessions' })).toBeTruthy()
expect(screen.getByRole('tab', { name: 'bots' })).toBeTruthy()
expect(leftZone()?.tabStrip).toBe('never')
expect(tabStripVisibleForGroup(leftZone()!)).toBe(false)
})
it('switches the zone to the tab it is asked for', () => {
render(<SidebarZoneTabs />)
fireEvent.click(screen.getByRole('tab', { name: 'bots' }))
expect(leftZone()?.active).toBe('hermes-bots:pane')
})
// A zone holding a tile needs its own strip — those tabs carry verbs
// (close, drag, session menus) this row does not offer.
it('stays out of a zone that holds a tile', () => {
setZone('sessions', 'session-tile:abc')
render(<SidebarZoneTabs />)
expect(screen.queryByRole('tab')).toBeNull()
expect(leftZone()?.tabStrip).toBeUndefined()
})
// ⌘⌥T restores the strip; the row retires rather than fighting it back.
it('retires while the zone is showing its own strip', () => {
setTreeGroupTabStrip('left-zone', 'always')
render(<SidebarZoneTabs />)
expect(screen.queryByRole('tab')).toBeNull()
expect(leftZone()?.tabStrip).toBe('always')
})
it('drops a chrome-hidden pane from the row', () => {
render(<SidebarZoneTabs />)
act(() => $hiddenTreePanes.set(new Set(['hermes-bots:pane'])))
expect(screen.queryByRole('tab', { name: 'bots' })).toBeNull()
expect(screen.getByRole('tab', { name: 'sessions' })).toBeTruthy()
expect(screen.getByRole('tab', { name: 'terminal' })).toBeTruthy()
})
// A lone pane left in the zone is not a tab row, so the row retires.
it('retires when a hide leaves a single pane', () => {
render(<SidebarZoneTabs />)
act(() => $hiddenTreePanes.set(new Set(['hermes-bots:pane', 'terminal'])))
expect(screen.queryByRole('tab')).toBeNull()
})
})
// The lead is the ZONE's, not the sessions pane's: the brand and the tabs stay
// up while the zone shows a surface stacked beside the sidebar. Rendered from
// inside the pane they vanished with it, taking the only way back.
describe('SidebarZoneLead', () => {
let disposeContributions: () => void
beforeEach(() => {
disposeContributions = registry.registerMany(STANDING_CHROME)
markCollapsePane('terminal')
$hiddenTreePanes.set(new Set())
$desktopVersion.set({
appVersion: '9.9.9',
electronVersion: '0',
hermesRoot: '/tmp',
nodeVersion: '0',
platform: 'test'
})
setZone('sessions', 'hermes-bots:pane', 'terminal')
})
afterEach(() => {
cleanup()
disposeContributions()
$hiddenTreePanes.set(new Set())
$layoutTree.set(null)
$desktopVersion.set(null)
})
const renderLead = () =>
render(
<MemoryRouter>
<SidebarZoneLead />
</MemoryRouter>
)
it('keeps the brand and the tabs up under another tab', () => {
renderLead()
expect(screen.getByText('DeepWork')).toBeTruthy()
expect(screen.getByText('v9.9.9')).toBeTruthy()
act(() => activateTreePane('left-zone', 'hermes-bots:pane'))
expect(leftZone()?.active).toBe('hermes-bots:pane')
expect(screen.getByText('DeepWork')).toBeTruthy()
expect(screen.getByRole('tab', { name: 'sessions' })).toBeTruthy()
})
it('does not bring back the retired brand-row picker from an old saved preference', () => {
window.localStorage.setItem('hermes.desktop.profileRailVisible', 'false')
renderLead()
expect(screen.getByRole('button', { name: 'Updates' })).toBeTruthy()
expect(screen.queryByRole('button', { name: /Profiles/ })).toBeNull()
expect(screen.getByRole('tab', { name: 'sessions' })).toBeTruthy()
window.localStorage.removeItem('hermes.desktop.profileRailVisible')
})
})
@@ -0,0 +1,152 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import {
$hiddenTreePanes,
$layoutTree,
activateTreePane,
isCollapsePane,
isHideOnlyPane,
restoreTreePane,
setTreeGroupTabStrip,
shownPanesInGroup,
tabStripVisibleForGroup
} from '@/components/pane-shell/tree/store'
import { clearTabSelection } from '@/components/pane-shell/tree/tab-selection'
import { PaneTab, PaneTabLabel, PaneTabStrip } from '@/components/ui/pane-tab'
import { useContributions } from '@/contrib/react/use-contributions'
import { SidebarVersionMenu } from './version-menu'
/** The pane whose zone this sidebar IS. Its zone's tabs are the ones this row
* offers — the sessions / Bots standing chrome, plus any tool pane the user
* has stacked beside them. */
const SIDEBAR_PANE = 'sessions'
/** Standing side chrome: no close verb and no live title, so its tab is a
* plain switch. A zone holding anything else (a session tile, a page) keeps
* its own strip — those tabs carry verbs this row does not. */
const isStandingChrome = (paneId: string) => isHideOnlyPane(paneId) || isCollapsePane(paneId)
/**
* The sidebar zone's lead: the brand block and the zone's tab row.
*
* All of them are the ZONE's chrome, not the sessions pane's, so they are
* rendered by the zone (see `zoneLead` in track-model) rather than inside the
* pane they happen to name. Inside the pane they went dark the moment the user
* switched to a surface stacked beside it — Bots, a terminal — taking the only
* way back with them.
*/
export interface SidebarZoneLeadProps {
compact?: boolean
}
export function SidebarZoneLead({ compact = false }: SidebarZoneLeadProps) {
if (compact) {
return (
<div className="grid shrink-0 place-items-center py-1">
<SidebarVersionMenu compact />
</div>
)
}
return (
<div className="bg-(--ui-sidebar-surface-background) px-2.5 pt-1.5">
<div className="flex items-center gap-1 px-2 pb-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-[0.8125rem] font-semibold tracking-tight text-foreground">DeepWork</p>
<SidebarVersionMenu />
</div>
</div>
<SidebarZoneTabs />
</div>
)
}
/**
* The sidebar zone's tabs, under the brand instead of in the titlebar band.
*
* Standing chrome is just a switch between fixed surfaces, so its strip spends
* the window's titlebar band on three labels. Moved here it reads as part of
* the sidebar it labels, and the band goes back to the window controls.
*
* The zone's own strip is switched off while this row is up, and the row
* retires the moment the strip comes back (⌘⌥T) — one of the two is always
* painted, never both, and neither can strand a pane.
*/
export function SidebarZoneTabs() {
const tree = useStore($layoutTree)
const hidden = useStore($hiddenTreePanes)
const panes = useContributions('panes')
const group = tree ? findGroupOfPane(tree, SIDEBAR_PANE) : null
const tabs = useMemo(() => {
if (!group) {
return []
}
const shown = shownPanesInGroup(group)
// One tab is not a tab row, and a zone holding a tile keeps its own strip.
if (shown.length < 2 || !shown.every(isStandingChrome) || tabStripVisibleForGroup(group)) {
return []
}
const titles = new Map(panes.map(pane => [pane.id, pane.title ?? pane.id]))
return shown.map(id => ({ id, title: titles.get(id) ?? id }))
// `hidden` and `panes` are read through the store helpers, not referenced
// here: the first decides which panes are chips, the second labels them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group, hidden, panes])
// Claim the zone's tabs once, only from AUTO: a zone the user has since told
// to show its strip again ('always') is left alone, so ⌘⌥T keeps working.
useEffect(() => {
if (!group || group.tabStrip !== undefined) {
return
}
const shown = shownPanesInGroup(group)
if (shown.length >= 2 && shown.every(isStandingChrome)) {
setTreeGroupTabStrip(group.id, 'never')
}
}, [group, hidden])
if (!tabs.length || !group) {
return null
}
return (
<div className="px-1 pb-1.5 [-webkit-app-region:no-drag]" data-slot="sidebar-zone-tabs">
<PaneTabStrip>
{tabs.map(tab => {
const active = group.active === tab.id && !group.minimized
return (
<PaneTab
active={active}
aria-selected={active}
key={tab.id}
onClick={() => {
clearTabSelection()
if (group.minimized) {
restoreTreePane(tab.id)
}
activateTreePane(group.id, tab.id)
}}
role="tab"
>
<PaneTabLabel>{tab.title}</PaneTabLabel>
</PaneTab>
)
})}
</PaneTabStrip>
</div>
)
}
@@ -1,62 +1,54 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const pickAndChangeWorkspace = vi.fn()
vi.mock('@/app/shell/workspace-menu', () => ({
pickAndChangeWorkspace: (...args: any[]) => pickAndChangeWorkspace(...args)
}))
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $projectTree } from '@/store/projects'
import { $currentBranch } from '@/store/session'
import { $currentBranch, $sessions } from '@/store/session'
import { WorkspaceBar } from './workspace-bar'
afterEach(() => {
cleanup()
vi.clearAllMocks()
$projectTree.set([])
$sessions.set([])
$currentBranch.set('')
$projectTree.set([])
})
describe('WorkspaceBar', () => {
it('names the owning project and branch for a cwd inside a named project', () => {
it("reads the open session's title, then its branch and project name", () => {
$sessions.set([{ id: 's_1', title: 'Redesign slides' } as any])
$projectTree.set([{ id: 'p_web', label: 'Website', path: '/repos/website', repos: [], sessionCount: 0 } as any])
$currentBranch.set('main')
render(<WorkspaceBar cwd="/repos/website/src" onChangeWorkspace={vi.fn()} />)
render(<WorkspaceBar cwd="/repos/website/src" selectedSessionId="s_1" />)
expect(screen.getByText('Website')).toBeTruthy()
expect(screen.getByText('· main')).toBeTruthy()
expect(screen.getByText(/Redesign slides/)).toBeTruthy()
expect(screen.getByText('· main Website')).toBeTruthy()
})
it('falls back to the cwd leaf when no project owns the path', () => {
render(<WorkspaceBar cwd="/somewhere/else" onChangeWorkspace={vi.fn()} />)
it('resolves the title through the lineage id, like every other session lookup', () => {
$sessions.set([{ id: 's_tip', _lineage_root_id: 's_root', title: 'Compressed chat' } as any])
expect(screen.getByText('else')).toBeTruthy()
render(<WorkspaceBar cwd="/somewhere/else" selectedSessionId="s_root" />)
expect(screen.getByText(/Compressed chat/)).toBeTruthy()
expect(screen.getByText('· else')).toBeTruthy()
})
it('shows a choose-workspace placeholder without a cwd', () => {
render(<WorkspaceBar cwd="" onChangeWorkspace={vi.fn()} />)
it('falls back to the new-session placeholder while no row exists yet', () => {
render(<WorkspaceBar cwd="" selectedSessionId={null} />)
expect(screen.getAllByText(/choose workspace/i).length).toBeGreaterThan(0)
expect(screen.getByText(/New session/)).toBeTruthy()
})
it('opens the picker seeded with the current cwd on change click', () => {
const onChangeWorkspace = vi.fn()
render(<WorkspaceBar cwd="/repos/website" onChangeWorkspace={onChangeWorkspace} />)
// The path and the switcher moved out: the full path was the long half of the
// strip, and the statusbar's cwd item already owns switching workspaces.
it('shows the project name only, with no path and no workspace action', () => {
$projectTree.set([{ id: 'p_web', label: 'Website', path: '/repos/website', repos: [], sessionCount: 0 } as any])
fireEvent.click(screen.getByRole('button', { name: /change workspace/i }))
render(<WorkspaceBar cwd="/repos/website/src" selectedSessionId={null} />)
expect(pickAndChangeWorkspace).toHaveBeenCalledWith('/repos/website', onChangeWorkspace)
})
it('opens the picker from the placeholder state too', () => {
const onChangeWorkspace = vi.fn()
render(<WorkspaceBar cwd="" onChangeWorkspace={onChangeWorkspace} />)
fireEvent.click(screen.getByRole('button', { name: /choose workspace/i }))
expect(pickAndChangeWorkspace).toHaveBeenCalledWith('', onChangeWorkspace)
expect(screen.getByText(/Website/)).toBeTruthy()
expect(screen.queryByText(/repos/)).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})
})
+25 -35
View File
@@ -1,49 +1,39 @@
import { useStore } from '@nanostores/react'
import { pickAndChangeWorkspace } from '@/app/shell/workspace-menu'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { displayPath, pathLeaf } from '@/lib/display-path'
import { FolderOpen } from '@/lib/icons'
import { NEW_SESSION_TITLE, sessionTitle } from '@/lib/chat-runtime'
import { pathLeaf } from '@/lib/display-path'
import { $projectTree, projectNameForCwd } from '@/store/projects'
import { $currentBranch } from '@/store/session'
import { $currentBranch, $sessions, sessionMatchesStoredId } from '@/store/session'
/** Slim workspace strip under the chat header: which folder this conversation
* runs in, plus the switch action. Always rendered (even for a folder-less
* draft) so the entry point never disappears — the draft case is exactly
* where picking a workspace matters. */
export function WorkspaceBar({ cwd, onChangeWorkspace }: { cwd: string; onChangeWorkspace: (cwd: string) => Promise<void> }) {
const { t } = useI18n()
const copy = t.shell.statusbar
/** Slim strip under the chat header: what this conversation is called, on which
* branch, in which project. Always rendered (even for a draft) so the strip
* never disappears — an unsent chat is exactly where the name matters.
*
* The name is the session's title — the same string the titlebar header would
* carry, but that header is collapsed inside the pane tree, where the zone tab
* owns the title. The project rides as its NAME, not its path: the full path was
* the long half of the strip and the workspace SWITCHER lives on the statusbar's
* cwd item, so nothing here needs the path to stay reachable. */
export function WorkspaceBar({ cwd, selectedSessionId }: { cwd: string; selectedSessionId: null | string }) {
const branch = useStore($currentBranch)
const projectTree = useStore($projectTree)
const sessions = useStore($sessions)
const trimmed = cwd.trim()
const name = trimmed ? projectNameForCwd(trimmed) || pathLeaf(trimmed) : ''
const project = trimmed ? projectNameForCwd(trimmed) || pathLeaf(trimmed) : ''
const stored = selectedSessionId
? (sessions.find(session => sessionMatchesStoredId(session, selectedSessionId)) ?? null)
: null
const title = stored ? sessionTitle(stored) : NEW_SESSION_TITLE
const meta = [branch, project].filter(Boolean).join(' ')
return (
<div className="flex h-7 shrink-0 items-center gap-1.5 border-b border-border/50 px-3 text-xs text-muted-foreground">
<FolderOpen className="size-3.5 shrink-0" />
<div className="flex h-7 shrink-0 items-center bg-(--ui-chat-surface-background) px-3 text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
{trimmed ? name : copy.chooseWorkspace}
{trimmed && branch ? <span className="text-muted-foreground/60"> · {branch}</span> : null}
{/* Breadcrumb trail: the chat header (which would carry this) is hidden
inside the pane tree — the title lives in the zone tab — so the
full workspace path rides here where it stays visible. */}
{trimmed ? (
<span className="text-muted-foreground/50" title={trimmed}>
{' '}
· {displayPath(trimmed)}
{title}
{meta ? <span className="text-muted-foreground/60"> · {meta}</span> : null}
</span>
) : null}
</span>
<Button
className="h-5 px-1.5 text-xs text-muted-foreground"
onClick={() => void pickAndChangeWorkspace(trimmed, onChangeWorkspace)}
size="xs"
variant="ghost"
>
{trimmed ? copy.changeWorkspace : copy.chooseWorkspace}
</Button>
</div>
)
}
@@ -23,7 +23,6 @@ import { isRemoteGateway } from '@/lib/media'
import { reachablePreviewUrl } from '@/lib/preview-reach'
import { openCommandPalette } from '@/store/command-palette'
import { openPreview } from '@/store/preview'
import { toggleProfileRailVisible } from '@/store/profile-rail-prefs'
import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { requestActiveUpdate } from '@/store/updates'
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
@@ -574,12 +573,6 @@ function shellSections({ navigate, t }: ShellVerbs): ReactNode[][] {
label={t.keybinds.actions['view.toggleStatusbar']}
onSelect={toggleStatusbarVisible}
/>,
<Item
icon="organization"
key="shell-profile-rail"
label={t.keybinds.actions['view.toggleProfileRail']}
onSelect={toggleProfileRailVisible}
/>,
// The pointer-only way back to a hidden tab strip: right-clicking the
// shell reaches this menu from anywhere, including a zone that has no
// chrome left to right-click.
+39 -14
View File
@@ -45,7 +45,7 @@ import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { translateNow } from '@/i18n'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Users, Zap } from '@/lib/icons'
import { Download, FileText, LayoutDashboard, PanelBottom, PanelTop, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
@@ -63,7 +63,6 @@ import {
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { $profileRailVisible } from '@/store/profile-rail-prefs'
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
import {
$reviewOpen,
@@ -73,7 +72,14 @@ import {
openReview,
REVIEW_PANE_ID
} from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import {
$currentCwd,
$selectedStoredSessionId,
$sessions,
$workspaceCwdOwner,
$yoloActive,
sessionMatchesStoredId
} from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { $botChatScopes } from '@/store/session-states'
import { watchUnreadWriteGuard } from '@/store/session-unread-remote'
@@ -85,6 +91,7 @@ import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchPreviewTiles } from '../chat/preview-tile'
import { watchRouteTiles } from '../chat/route-tile'
import { startSessionDrag } from '../chat/session-drag'
import { SessionHistoryNav } from '../chat/session-history-nav'
import {
SessionTileCloseConfirm,
stackSessionTilesIntoMain,
@@ -92,6 +99,8 @@ import {
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { UserAccountCard } from '../chat/sidebar/user-account-card'
import { SidebarZoneLead } from '../chat/sidebar/zone-tabs'
import { AppContextMenu } from '../context-menu/app-context-menu'
import { HudShell } from '../hud/hud-shell'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
@@ -145,6 +154,20 @@ const workspaceDragPayload = (): SessionDragPayload | null => {
return { id: selected, profile: stored?.profile ?? '', title: stored ? storedSessionTitle(stored) : '' }
}
function FilesTabTitle() {
const cwd = useStore($currentCwd).trim()
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const workspaceCwdOwner = useStore($workspaceCwdOwner)
const hasWorkspace = Boolean(cwd) && (workspaceCwdOwner ?? null) === (selectedStoredSessionId ?? null)
const folderName =
cwd
.split(/[\\/]+/)
.filter(Boolean)
.at(-1) ?? cwd
return <>{hasWorkspace && folderName ? folderName : translateNow('rightSidebar.files')}</>
}
// The main tab drags like a session tile — drop it on a composer to link the
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
// move when there's no loaded session to carry.
@@ -178,7 +201,12 @@ registry.registerMany([
hideOnly: true,
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
minWidth: `${SIDEBAR_DEFAULT_WIDTH}px`,
maxWidth: `${SIDEBAR_MAX_WIDTH}px`
maxWidth: `${SIDEBAR_MAX_WIDTH}px`,
// The brand row and the zone's tabs are this ZONE's chrome: rendered
// above the pane body so Bots / the terminal, stacked beside this pane,
// can't take the sidebar's own name and its way back off the screen.
zoneLead: (compact?: boolean) => <SidebarZoneLead compact={compact} />,
zoneFooter: (compact: boolean) => <UserAccountCard compact={compact} />
},
render: () => <WiredPane part="sidebar" />
},
@@ -188,8 +216,10 @@ registry.registerMany([
// Live-retitled to the loaded session by syncWorkspaceTitle below.
title: NEW_SESSION_TITLE,
data: {
headerSurface: 'chat',
placement: 'main',
minWidth: '22vw',
stripLead: () => <SessionHistoryNav />,
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
@@ -230,7 +260,9 @@ registry.registerMany([
revealAliases: ['file-browser'],
width: FILE_BROWSER_DEFAULT_WIDTH,
minWidth: FILE_BROWSER_MIN_WIDTH,
maxWidth: FILE_BROWSER_MAX_WIDTH
maxWidth: FILE_BROWSER_MAX_WIDTH,
titlebar: 'content',
tabTitle: () => <FilesTabTitle />
},
render: () => idle(<FilesPane />)
},
@@ -364,15 +396,6 @@ registry.registerMany([
get: () => $statusbarVisible.get(),
set: enabled => $statusbarVisible.set(enabled)
}),
paletteToggle({
id: 'view.toggleProfileRail',
label: 'Toggle profile rail',
action: 'view.toggleProfileRail',
icon: Users,
keywords: ['profile rail', 'profile bar', 'profile strip', 'profiles', 'sidebar', 'hide', 'show', 'chrome'],
get: () => $profileRailVisible.get(),
set: enabled => $profileRailVisible.set(enabled)
}),
paletteToggle({
id: 'view.toggleTabStrip',
label: 'Toggle tabs',
@@ -484,6 +507,7 @@ const syncWorkspaceTitle = () => {
selected ? $botChatScopes.get()[selected] : undefined
),
data: {
headerSurface: 'chat',
// The tab's status dot — the SAME primitive the sidebar row and session
// tiles render, so the main tab never disagrees with its sidebar row. A
// fresh draft has no session to key by, which IS its status: the dot
@@ -497,6 +521,7 @@ const syncWorkspaceTitle = () => {
headerVeto: $workspaceIsPage.get(),
placement: 'main',
minWidth: '22vw',
stripLead: () => <SessionHistoryNav />,
tabDrag: workspaceTabDrag,
tabWrap: wrapWorkspaceTab,
uncloseable: true
@@ -11,10 +11,9 @@ import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
// zone. Review collapses to nothing while its pane is hidden (⌘G off).
//
// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one:
// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands
// each one directly beside the file tree wherever that currently lives — so a
// file double-click still slides a preview open as its own pane next to the
// tree, never as a tab stacked into the files sidebar.
// they're registered by watchPreviewTiles as tabs open and join the files pane's
// tab group wherever that pane currently lives. Layouts without a files pane
// fall back to a preview-only zone beside the main workspace.
export const DEFAULT_TREE = split(
'row',
[
+2 -1
View File
@@ -113,6 +113,7 @@ import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { mainChatOccupied, openSession, openSessionForOwner } from '../open-session'
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
import { ProfileCreateHost } from '../profiles/profile-create-host'
import { FileActionDialogs } from '../right-sidebar/file-actions'
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
@@ -1307,7 +1308,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{!isHudWindow() && !isBrowserWindow() && (
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
)}
@@ -1451,6 +1451,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{/* Backs confirm() from @/store/confirm — renders only while one is open. */}
<ConfirmHost />
{!isAuxiliaryWindow() && <ProfileCreateHost />}
{/* Send Diagnostics consent/upload dialog — driven by $sendDiagnostics
(error card action); renders nothing until requested. */}
@@ -46,7 +46,6 @@ import {
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { toggleProfileRailVisible } from '@/store/profile-rail-prefs'
import { openFolderAsProject } from '@/store/projects'
import { toggleReview } from '@/store/review'
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
@@ -250,7 +249,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'),
'view.toggleReview': toggleReview,
'view.toggleStatusbar': toggleStatusbarVisible,
'view.toggleProfileRail': toggleProfileRailVisible,
'view.toggleTabStrip': () => void toggleTargetZoneTabStrip(),
'view.showFiles': showFiles,
'view.showBrowser': openBrowserTab,
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react'
import { StrictMode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $activeGatewayProfile } from '@/store/profile'
import { useOnProfileSwitch } from './use-on-profile-switch'
describe('useOnProfileSwitch', () => {
beforeEach(() => {
act(() => $activeGatewayProfile.set('default'))
})
it('never fires on mount', () => {
const onSwitch = vi.fn()
renderHook(() => useOnProfileSwitch(onSwitch))
expect(onSwitch).not.toHaveBeenCalled()
})
// StrictMode replays mount effects, so a "first mount" boolean is spent by
// the first pass and the replay fires onSwitch — wiping per-profile state on
// every mount instead of only on a real switch.
it('never fires on mount under the StrictMode effect replay', () => {
const onSwitch = vi.fn()
renderHook(() => useOnProfileSwitch(onSwitch), { wrapper: StrictMode })
expect(onSwitch).not.toHaveBeenCalled()
})
it('fires once per real profile change', () => {
const onSwitch = vi.fn()
renderHook(() => useOnProfileSwitch(onSwitch), { wrapper: StrictMode })
act(() => $activeGatewayProfile.set('scout'))
expect(onSwitch).toHaveBeenCalledTimes(1)
})
})
@@ -8,16 +8,19 @@ import { $activeGatewayProfile } from '@/store/profile'
* when the backend the app talks to swaps underneath a still-mounted view. */
export function useOnProfileSwitch(onSwitch: () => void): void {
const profile = useStore($activeGatewayProfile)
const first = useRef(true)
// Latch the last-SEEN profile rather than a "first mount" boolean: StrictMode
// replays mount effects, which spends such a boolean on the first pass and
// then fires onSwitch on the replay — wiping per-profile state on every mount
// instead of only on a real switch.
const seen = useRef(profile)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (first.current) {
first.current = false
if (seen.current === profile) {
return
}
seen.current = profile
onSwitch()
// Fire on profile change only; onSwitch identity is intentionally ignored.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -0,0 +1,178 @@
import { type FormEvent, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { userAccountErrorText } from '@/lib/user-account-errors'
import { userAccountLogout, userAccountResetConfirm, userAccountResetRequest } from '@/store/user-account'
export interface ChangePasswordDialogProps {
site: string
email: string
onClose: () => void
}
export function ChangePasswordDialog({ site, email, onClose }: ChangePasswordDialogProps) {
const { t } = useI18n()
const copy = t.userAccount
const [sent, setSent] = useState(false)
const [changed, setChanged] = useState(false)
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [confirmation, setConfirmation] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function signOut() {
setBusy(true)
setError(null)
try {
await userAccountLogout()
} catch (failure) {
setError(userAccountErrorText(copy, failure))
} finally {
setBusy(false)
}
}
function close() {
if (busy) {
return
}
if (changed) {
void signOut()
} else {
onClose()
}
}
async function submit(event: FormEvent) {
event.preventDefault()
setError(null)
if (sent && password !== confirmation) {
setError(copy.passwordMismatch)
return
}
setBusy(true)
try {
if (sent) {
await userAccountResetConfirm(site, email, code, password)
setPassword('')
setConfirmation('')
setCode('')
setChanged(true)
} else {
await userAccountResetRequest(site, email)
setSent(true)
}
} catch (failure) {
setError(userAccountErrorText(copy, failure))
} finally {
setBusy(false)
}
}
return (
<Dialog
onOpenChange={open => {
if (!open) {
close()
}
}}
open
>
<DialogContent className="w-[calc(100%-2rem)] max-w-sm" showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>{copy.me.changePassword}</DialogTitle>
<DialogDescription>{copy.me.passwordChangeDescription}</DialogDescription>
</DialogHeader>
{changed ? (
<>
<p className="text-sm" role="status">
{copy.resetDoneBody}
</p>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<DialogFooter>
<Button loading={busy} onClick={() => void signOut()}>
{copy.backToSignIn}
</Button>
</DialogFooter>
</>
) : (
<form className="space-y-4" onSubmit={event => void submit(event)}>
<p className="break-all text-sm text-(--ui-text-secondary)">{email}</p>
{sent && (
<>
<p className="text-sm text-(--ui-text-tertiary)">{copy.resetSentBody}</p>
<label className="block space-y-1 text-sm">
<span>{copy.codeLabel}</span>
<Input
autoComplete="one-time-code"
autoFocus
disabled={busy}
onChange={event => setCode(event.target.value)}
required
value={code}
/>
</label>
<label className="block space-y-1 text-sm">
<span>{copy.newPasswordLabel}</span>
<Input
autoComplete="new-password"
disabled={busy}
onChange={event => setPassword(event.target.value)}
required
type="password"
value={password}
/>
</label>
<label className="block space-y-1 text-sm">
<span>{copy.confirmPasswordLabel}</span>
<Input
autoComplete="new-password"
disabled={busy}
onChange={event => setConfirmation(event.target.value)}
required
type="password"
value={confirmation}
/>
</label>
</>
)}
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="text">
{t.common.cancel}
</Button>
<Button loading={busy} type="submit">
{sent ? copy.resetConfirm : copy.resetSend}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}
+15 -4
View File
@@ -17,7 +17,14 @@ const PROFILE = {
}
function signedIn(overrides: Record<string, unknown> = {}) {
$userAccount.set({ status: 'signed_in', site: 'https://r', offline: false, profile: PROFILE, profileAsOf: null, ...overrides })
$userAccount.set({
status: 'signed_in',
site: 'https://r',
offline: false,
profile: PROFILE,
profileAsOf: null,
...overrides
})
}
function stubBridge(overrides: Record<string, unknown> = {}) {
@@ -177,7 +184,12 @@ describe('MePage', () => {
it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
stubBridge({
bindingStatus: vi.fn(async () => ({ bound: true, site: 'https://r', installationId: 'inst-1', machineId: 'MR-ABCD-1234' }))
bindingStatus: vi.fn(async () => ({
bound: true,
site: 'https://r',
installationId: 'inst-1',
machineId: 'MR-ABCD-1234'
}))
})
signedIn()
render(<MePage />)
@@ -204,7 +216,7 @@ describe('MePage', () => {
})
describe('UserAccountCard', () => {
it('signed_in:渲染展示名+邮箱;未登录不渲染', () => {
it('signed_in:只渲染展示名;未登录不渲染', () => {
stubBridge()
signedIn()
@@ -215,7 +227,6 @@ describe('UserAccountCard', () => {
)
expect(screen.getByText('小赫')).toBeTruthy()
expect(screen.getByText('u@example.com')).toBeTruthy()
unmount()
$userAccount.set({ status: 'signed_out' })
@@ -36,7 +36,6 @@ export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
<ImageLightbox
alt={name}
copy={t.desktop}
onClick={download}
onOpenChange={setViewing}
open={viewing}
@@ -27,6 +27,8 @@ vi.mock('@/components/chat/code-editor', () => ({
CodeEditor: () => null
}))
vi.mock('../chat/sidebar/profile-switcher', () => ({ ProfileRail: () => <div data-testid="profile-controls" /> }))
vi.mock('@/hermes', () => ({
createProfile: vi.fn(async () => ({ name: 'x', ok: true, path: '/x' })),
deleteProfile: vi.fn(async () => ({ ok: true, path: '/x' })),
+4
View File
@@ -15,6 +15,7 @@ import { normalize } from '@/lib/text'
import { notify, notifyError } from '@/store/notifications'
import { $profileColors, profileLabel, refreshProfiles } from '@/store/profile'
import { ProfileRail } from '../chat/sidebar/profile-switcher'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
Panel,
@@ -120,6 +121,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
) : (
<>
<PanelHeader subtitle={p.count(profiles.length)} title={p.title} />
<div className="mb-3 min-w-0 shrink-0" data-slot="profile-management-controls">
<ProfileRail management />
</div>
<PanelBody>
<PanelList
onSearchChange={setQuery}
@@ -0,0 +1,50 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useFleetRoster } from '@/app/chat/sidebar/use-fleet-roster'
import { useProfileRailRefreshOnActive } from '@/app/chat/sidebar/use-profile-rail-refresh-on-active'
import { $hasMultipleConnections } from '@/store/connections'
import { $profileCreateRequest, $profiles, refreshActiveProfile, selectProfile } from '@/store/profile'
import { $userAccount } from '@/store/user-account'
import { CreateProfileDialog } from './create-profile-dialog'
/** One shell-owned dialog, shared by menu commands and the create hotkey. */
export function ProfileCreateHost() {
const account = useStore($userAccount)
return account.status === 'signed_in' ? (
<SignedInProfileCreateHost key={`${account.site}:${account.profile?.email ?? ''}`} />
) : null
}
function SignedInProfileCreateHost() {
const profiles = useStore($profiles)
const multipleConnections = useStore($hasMultipleConnections)
const request = useStore($profileCreateRequest)
const lastRequest = useRef(request)
const [open, setOpen] = useState(false)
useProfileRailRefreshOnActive()
useFleetRoster(multipleConnections)
// eslint-disable-next-line no-restricted-syntax -- tracks a command sequence, not render state
useEffect(() => {
if (lastRequest.current !== request) {
lastRequest.current = request
setOpen(true)
}
}, [request])
return (
<CreateProfileDialog
onClose={() => setOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={open}
profiles={profiles}
/>
)
}
@@ -7,7 +7,8 @@ import type * as FileActions from '../file-actions'
const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() }))
vi.mock('@/lib/office-files', () => ({
isOfficeFilePath: (value: string) => /\.docx$|\.pptx$/i.test(value),
inlineOfficeFormat: (value: string) => (/\.docx$/i.test(value) ? 'docx' : /\.pptx$/i.test(value) ? 'pptx' : null),
isOfficeFilePath: (value: string) => /\.(doc|docx|ppt|pptx)$/i.test(value),
openFileWithSystemApp
}))
vi.mock('../file-actions', async importOriginal => {
@@ -58,12 +59,30 @@ afterEach(() => {
})
describe('ProjectTreeRow double-click', () => {
it('opens office files with the system app instead of the preview', () => {
it('previews inline-renderable office files instead of system-opening them', () => {
const { onPreviewFile, view } = renderRow('/w/report.docx')
fireEvent.doubleClick(view.container.querySelector('[title="/w/report.docx"]')!)
expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/report.docx')
expect(onPreviewFile).toHaveBeenCalledWith('/w/report.docx')
expect(openFileWithSystemApp).not.toHaveBeenCalled()
})
it('previews pptx inline', () => {
const { onPreviewFile, view } = renderRow('/w/deck.pptx')
fireEvent.doubleClick(view.container.querySelector('[title="/w/deck.pptx"]')!)
expect(onPreviewFile).toHaveBeenCalledWith('/w/deck.pptx')
expect(openFileWithSystemApp).not.toHaveBeenCalled()
})
it('still opens legacy office formats with the system app', () => {
const { onPreviewFile, view } = renderRow('/w/old.doc')
fireEvent.doubleClick(view.container.querySelector('[title="/w/old.doc"]')!)
expect(openFileWithSystemApp).toHaveBeenCalledWith('/w/old.doc')
expect(onPreviewFile).not.toHaveBeenCalled()
})
@@ -7,7 +7,7 @@ import { TreeSkeleton } from '@/components/chat/skeletons'
import { Codicon } from '@/components/ui/codicon'
import { markRightPanePerf } from '@/debug/right-pane-events'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { isOfficeFilePath, openFileWithSystemApp } from '@/lib/office-files'
import { inlineOfficeFormat, isOfficeFilePath, openFileWithSystemApp } from '@/lib/office-files'
import { cn } from '@/lib/utils'
import { type RepoChangeKind, repoChangeKindForPath } from '@/store/coding-status'
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
@@ -327,9 +327,9 @@ export function ProjectTreeRow({
event.stopPropagation()
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
// Office documents have no inline renderer — hand them to the OS
// default application instead of the (useless) binary refusal page.
if (isOfficeFilePath(node.data.id)) {
// Only Office formats the preview pane can't render (legacy .doc/.ppt
// etc.) go to the OS default application; .docx/.pptx preview inline.
if (isOfficeFilePath(node.data.id) && !inlineOfficeFormat(node.data.id)) {
void openFileWithSystemApp(node.data.id)
return
+1 -1
View File
@@ -77,7 +77,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSide
<aside
aria-label={r.aria}
className={cn(
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-editor-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
panesFlipped
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
@@ -474,6 +474,10 @@ export function useSessionActions({
const workspaceTarget = hasWorkspaceTarget
? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget)
: undefined
// Resolve before clearing the selected stored id below. In the unscoped
// sidebar view, a new draft intentionally inherits the previous
// conversation's owned workspace.
const resolvedWorkspaceCwd = !hasWorkspaceTarget ? resolveNewSessionCwd() : ''
resetViewSync()
busyRef.current = false
@@ -525,11 +529,11 @@ export function useSessionActions({
setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined)
if (!hasWorkspaceTarget) {
// In a project → the repo's default-branch checkout; not in a project →
// detached. So cmd-n does not inherit an unrelated linked worktree.
// In a project → the repo's default-branch checkout; outside a project
// → the previous conversation's workspace, then the configured default.
// Transient: a resolved default is not the user naming a workspace, and
// remembering it here would make the NEXT new chat inherit it.
setCurrentCwdTransient(resolveNewSessionCwd())
setCurrentCwdTransient(resolvedWorkspaceCwd)
} else if (workspaceTarget === null) {
setCurrentCwdTransient('')
} else if (typeof workspaceTarget === 'string') {
@@ -93,6 +93,20 @@ export function useEnvCredentials(profile?: string): UseEnvCredentials {
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload per target profile; copy is stable
}, [profile])
// Re-read for callers that write to the env store themselves (the
// FreeModel2API panel owns its own Save). The reload effect above only fires
// on a profile change, so without this `vars` would keep describing the
// pre-save store and the Keys page would show the fresh slots as unset.
// Deliberately leaves `edits`/`revealed` alone: those belong to the user's
// in-progress typing, not to the store being re-read.
async function refresh() {
try {
setVars(await getEnvVars(profile))
} catch (err) {
notifyError(err, t.settings.keys.failedLoad)
}
}
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
@@ -186,6 +200,7 @@ export function useEnvCredentials(profile?: string): UseEnvCredentials {
}
return {
refresh,
saveValue,
vars,
rowProps: {
@@ -207,6 +222,7 @@ interface CategoryHeadingProps {
}
interface UseEnvCredentials {
refresh: () => Promise<void>
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
@@ -1,6 +1,8 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { notify } from '@/store/notifications'
import { FreeModel2ApiSettings } from './freemodel2api-settings'
const fm2 = {
@@ -9,25 +11,86 @@ const fm2 = {
keys: vi.fn(),
logout: vi.fn(),
rememberedLoad: vi.fn(),
rememberedSave: vi.fn()
rememberedSave: vi.fn(),
keyBalance: vi.fn()
}
const saveCustomEndpoint = vi.fn()
const validateCustomEndpoint = vi.fn()
const getGlobalModelOptions = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setModelAssignment = vi.fn()
const setEnvVar = vi.fn()
const deleteEnvVar = vi.fn()
const getEnvVars = vi.fn()
const revealEnvVar = vi.fn()
const getHermesConfig = vi.fn()
const saveHermesConfig = vi.fn()
const confirm = vi.fn()
// 面板自己写 env 槽,宿主靠这个信号重读 Keys 页(providers-settings.tsx)
const onConfigSaved = vi.fn()
vi.mock('@/hermes', () => ({
saveCustomEndpoint: (...args: any[]) => saveCustomEndpoint(...args),
validateCustomEndpoint: (...args: any[]) => validateCustomEndpoint(...args)
getApiRequestProfile: () => 'default',
validateCustomEndpoint: (...args: any[]) => validateCustomEndpoint(...args),
getGlobalModelOptions: (...args: any[]) => getGlobalModelOptions(...args),
getRecommendedDefaultModel: (...args: any[]) => getRecommendedDefaultModel(...args),
setModelAssignment: (...args: any[]) => setModelAssignment(...args),
setEnvVar: (...args: any[]) => setEnvVar(...args),
deleteEnvVar: (...args: any[]) => deleteEnvVar(...args),
getEnvVars: (...args: any[]) => getEnvVars(...args),
revealEnvVar: (...args: any[]) => revealEnvVar(...args),
getHermesConfig: (...args: any[]) => getHermesConfig(...args),
saveHermesConfig: (...args: any[]) => saveHermesConfig(...args)
}))
vi.mock('@/store/confirm', () => ({ confirm: (...args: any[]) => confirm(...args) }))
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
const KEY = { id: 7, name: 'main', key: 'sk-full-value-1234', status: 'enabled', quota: 0, quotaUsed: 0, createdAt: '2026-01-01', lastUsedAt: null }
/** 面板后半的「绑定 FreeModelAPI Key」卡片。取整段而不是单个文本节点:key 名在前后两处都出现 */
function card(): HTMLElement {
const box = screen.getByText('绑定 FreeModelAPI Key').closest('section')
if (!box) {
throw new Error('current-key card not rendered')
}
return box
}
/** 面板最后的「测试」那一段(标题 `测试`,按钮 `测试所选 Key`) */
function testBox(): HTMLElement {
const box = screen.getByText('测试').closest('section')
if (!box) {
throw new Error('test section not rendered')
}
return box
}
beforeEach(() => {
vi.clearAllMocks()
fm2.rememberedLoad.mockResolvedValue(null)
setEnvVar.mockResolvedValue({ ok: true })
deleteEnvVar.mockResolvedValue({ ok: true })
// 默认没有已保存的 key(首次配置),也没有目录价格
getHermesConfig.mockResolvedValue({})
getEnvVars.mockResolvedValue({})
revealEnvVar.mockResolvedValue({ key: '', value: '' })
getGlobalModelOptions.mockResolvedValue({ providers: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'freemodel2api', model: '', free_tier: null })
setModelAssignment.mockResolvedValue({
ok: true,
provider: 'freemodel2api',
model: 'gpt-5.4',
gateway_tools: []
})
saveHermesConfig.mockResolvedValue({ ok: true })
confirm.mockResolvedValue(true)
fm2.keyBalance.mockResolvedValue({ balance: null })
;(window as any).hermesDesktop = { ...(window as any).hermesDesktop, freemodel2api: fm2 }
})
@@ -37,16 +100,42 @@ describe('FreeModel2ApiSettings', () => {
it('shows login form when not configured', async () => {
fm2.status.mockResolvedValue({ configured: false })
render(<FreeModel2ApiSettings />)
await screen.findByLabelText(/base url/i)
expect(screen.getByLabelText(/email/i)).toBeTruthy()
expect(screen.getByLabelText(/password/i)).toBeTruthy()
await screen.findByLabelText(/FreeModelAPI URL/i)
expect(screen.getByLabelText(/用户\(邮箱\)/)).toBeTruthy()
expect(screen.getByLabelText(/FreeModelAPI 密码/i)).toBeTruthy()
// 登录和管理在同一页:未登录时只是下半部分还没长出来
expect(screen.getByRole('checkbox', { name: /记住用户名密码/ })).toBeTruthy()
expect(screen.queryByText('账户')).toBeNull()
})
it('shows the saved api key instead of account info when not logged in', async () => {
fm2.status.mockResolvedValue({ configured: false })
// 凭据还在 env 里(sub2api 会话没了),面板降级成「选中的 API Key」读数
getEnvVars.mockResolvedValue({
FREEMODEL2API_API_KEY: { is_set: true, redacted_value: 'sk-dyw...6a3f' },
FREEMODEL2API_BASE_URL: { is_set: true, redacted_value: 'http...0/v1' }
})
revealEnvVar.mockResolvedValue({ key: 'FREEMODEL2API_BASE_URL', value: 'http://124.232.163.75:3020/v1' })
render(<FreeModel2ApiSettings />)
expect(await screen.findByText('已配置 sk-dyw...6a3f')).toBeTruthy()
// base url 不是机密:显示 reveal 回来的全值,不是掩码行那个「http...0/v1」
expect(screen.getByText('http://124.232.163.75:3020/v1')).toBeTruthy()
// 没登录就没有账户读数,也不该出现 key 管理
expect(screen.queryByText('账号')).toBeNull()
expect(screen.queryByRole('combobox', { name: /api key/i })).toBeNull()
})
it('auto-connects from stored credentials', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [] })
render(<FreeModel2ApiSettings />)
await screen.findByText(/\$3\.00/)
// 已登录:登录按钮换成绿色「已登录」章(点它退会话),Disconnect 不复存在
await screen.findByRole('button', { name: '已登录' })
expect(screen.queryByRole('button', { name: /disconnect/i })).toBeNull()
// 余额在账户区和绑定卡片各显示一份
await screen.findAllByText(/\$3\.00/)
expect(screen.getByText(/no api keys/i)).toBeTruthy()
})
@@ -56,52 +145,261 @@ describe('FreeModel2ApiSettings', () => {
fm2.keys.mockResolvedValue({ keys: [KEY] })
render(<FreeModel2ApiSettings />)
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com/' } })
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://sub.example.com/' } })
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
const balance = await screen.findByText(/\$9\.50/)
// 余额必须在 key 下拉列表上方(取文档序第一份 = 账户区那格)
const [balance] = await screen.findAllByText(/\$9\.50/)
const select = await screen.findByRole('combobox', { name: /api key/i })
// 余额必须在 key 下拉列表上方
expect(balance.compareDocumentPosition(select) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// 下拉项显示名称 + 首尾片段,不露完整 key
// 下拉项显示名称 + 状态;掩码在下面那行,完整 key 哪里都不出现
const option = screen.getByRole('option', { name: /main/ })
expect(option.textContent).toContain('sk-ful')
expect(option.textContent).toContain('1234')
expect(option.textContent).toContain('可用')
expect(option.textContent).not.toContain('sk-full-value-1234')
})
it('tests models for the selected key, then writes provider with /v1', async () => {
it('auto-loads models for the selected key, then saves it as the main default', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4', 'claude-x'] })
saveCustomEndpoint.mockResolvedValue({ ok: true, endpoints: [], current: null })
render(<FreeModel2ApiSettings />)
fireEvent.click(await screen.findByRole('button', { name: /test models/i }))
render(<FreeModel2ApiSettings onConfigSaved={onConfigSaved} />)
// 不点任何按钮,选中 key 就位即自动拉取
await waitFor(() => expect(validateCustomEndpoint).toHaveBeenCalled())
expect(validateCustomEndpoint.mock.calls[0][0]).toMatchObject({
base_url: 'https://sub.example.com/v1',
api_key: 'sk-full-value-1234'
})
// 模型列表展示出来
await screen.findByText('gpt-5.4')
await screen.findByText('claude-x')
fireEvent.click(screen.getByRole('button', { name: /write provider/i }))
await waitFor(() => expect(saveCustomEndpoint).toHaveBeenCalled())
expect(saveCustomEndpoint.mock.calls[0][0]).toMatchObject({
id: 'FreeModel2API',
name: 'FreeModel2API',
base_url: 'https://sub.example.com/v1',
api_key: 'sk-full-value-1234',
model: 'gpt-5.4',
models: ['gpt-5.4', 'claude-x'],
discover_models: true,
make_default: false
// 该 key 的模型只是「显示」:keys 区没有勾选框,也没有主模型下拉
// (页面顶部的「记住用户名密码」勾选框不属于这个 section)
await screen.findAllByText('gpt-5.4')
await screen.findAllByText('claude-x')
const keysSection = screen.getByRole('combobox', { name: /api key/i }).closest('section')
expect(keysSection?.querySelectorAll('input[type="checkbox"]').length).toBe(0)
expect(screen.queryByRole('combobox', { name: /main model/i })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /保存并设为默认/ }))
await waitFor(() => expect(setEnvVar).toHaveBeenCalledTimes(2))
expect(setEnvVar.mock.calls[0]).toEqual(['FREEMODEL2API_API_KEY', 'sk-full-value-1234', undefined])
expect(setEnvVar.mock.calls[1]).toEqual(['FREEMODEL2API_BASE_URL', 'https://sub.example.com/v1', undefined])
// config 里只留「保存的是哪把 key」;models 写空数组是为了抹掉旧白名单,
// discover_models 必须回 true,否则旧条目的 false 会把这行钉死在空清单上
expect(saveHermesConfig).toHaveBeenCalledWith(
{ providers: { freemodel2api: { discover_models: true, key_id: 7, models: [] } } },
undefined
)
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith(
{ provider: 'freemodel2api', model: 'gpt-5.4', scope: 'main' }
)
)
expect(onConfigSaved).toHaveBeenCalled()
})
it('copies the selected api key to the clipboard', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
fireEvent.click(screen.getByRole('button', { name: /copy api key/i }))
expect(writeText).toHaveBeenCalledWith('sk-full-value-1234')
// 复制有反馈:成功后弹 notify,不然用户不知道复制上了没有
await waitFor(() => expect(notify).toHaveBeenCalled())
})
it('shows each model with the price the catalog reports', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4', 'claude-x', 'half-priced'] })
getGlobalModelOptions.mockResolvedValue({
providers: [
{
slug: 'freemodel2api',
pricing: {
'gpt-5.4': { input: '$0.10', output: '$0.40', free: false },
'claude-x': { input: '', output: '', free: true },
'half-priced': { input: '$2.00', output: '', free: false }
}
}
]
})
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
// 有价的显示 in/out,免费的写 free,目录没报价的就空着(不编一个 0 出来);
// 只报半边价的,缺的那半写 — 而不是 ?
await screen.findByText('$0.10 / $0.40')
expect(screen.getByText('free')).toBeTruthy()
await screen.findByText('$2.00 / —')
})
it('tests the selected key: group multiplier, model count and every price', async () => {
const KEY2 = { ...KEY, id: 8, name: 'backup', key: 'sk-other-9999' }
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
validateCustomEndpoint.mockResolvedValue({
ok: true,
reachable: true,
message: '',
models: ['gpt-5.4', 'claude-x', 'no-price-model'],
pricing: {
'gpt-5.4': { input: '$0.30', output: '$1.20', free: false },
'claude-x': { input: '$2.00', output: '$4.00', free: false }
},
rate_multiplier: 2
})
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
// 点之前只有提示,没有结果
expect(testBox().textContent).not.toContain('×2')
fireEvent.click(screen.getByRole('button', { name: /测试所选 Key/ }))
await waitFor(() => expect(testBox().textContent).toContain('×2'))
// 这一次探测带价:价格里已经乘过倍率,倍率另给一个数
expect(validateCustomEndpoint.mock.calls.at(-1)?.[0]).toMatchObject({
api_key: 'sk-full-value-1234',
with_pricing: true
})
expect(testBox().textContent).toContain('$0.30 / $1.20')
expect(testBox().textContent).toContain('$2.00 / $4.00')
// 端点没报价的模型照样列出来(价格那格是不知道)
expect(testBox().textContent).toContain('no-price-model')
// 换一把 key:上一次的结果不能留在面板上冒充这把 key 的
fireEvent.change(screen.getByRole('combobox', { name: /api key/i }), { target: { value: '8' } })
await waitFor(() => expect(testBox().textContent).not.toContain('×2'))
})
it('says the multiplier is unknown when the endpoint never reports one', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({
ok: true,
reachable: true,
message: '',
models: ['gpt-5.4'],
pricing: {},
rate_multiplier: 0
})
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
fireEvent.click(screen.getByRole('button', { name: /测试所选 Key/ }))
await waitFor(() => expect(testBox().textContent).toContain('模型数'))
// 倍率 0 = 不知道:不写「×0」,价格那格也照实空着
expect(testBox().textContent).not.toContain('×')
expect(testBox().textContent).toContain('—')
})
it('re-pulls models for the key that replaced the previous one', async () => {
const KEY2 = { ...KEY, id: 8, name: 'backup', key: 'sk-other-9999' }
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
validateCustomEndpoint
.mockResolvedValueOnce({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
.mockResolvedValueOnce({ ok: true, reachable: true, message: '', models: ['claude-x'] })
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
fireEvent.change(screen.getByRole('combobox', { name: /api key/i }), { target: { value: '8' } })
await screen.findAllByText('claude-x')
expect(validateCustomEndpoint.mock.calls.at(-1)?.[0]).toMatchObject({ api_key: 'sk-other-9999' })
// 上一把 key 的模型不再残留
expect(screen.queryByText('gpt-5.4')).toBeNull()
})
it('logs out from the 已登录 chip without touching the saved key', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
fm2.logout.mockResolvedValue({ ok: true })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
getEnvVars.mockResolvedValue({
FREEMODEL2API_API_KEY: { is_set: true, redacted_value: 'sk-ful...1234' },
FREEMODEL2API_BASE_URL: { is_set: true, redacted_value: 'https.../v1' }
})
revealEnvVar.mockImplementation((key: any) =>
key === 'FREEMODEL2API_BASE_URL'
? Promise.resolve({ key, value: 'https://sub.example.com/v1' })
: Promise.resolve({ key, value: 'sk-full-value-1234' }))
fm2.keyBalance.mockResolvedValue({ balance: 5.25 })
render(<FreeModel2ApiSettings onConfigSaved={onConfigSaved} />)
fireEvent.click(await screen.findByRole('button', { name: '已登录' }))
await waitFor(() => expect(fm2.logout).toHaveBeenCalled())
// env 和 config 都不动 —— 删凭据是「删除当前 Key」的事,退会话不该顺手删
expect(deleteEnvVar).not.toHaveBeenCalled()
expect(saveHermesConfig).not.toHaveBeenCalled()
expect(onConfigSaved).not.toHaveBeenCalled()
// 退完仍能看到选中的 key 读数:掩码、全值 base url(不是「https.../v1」)、余额、能点的测试按钮
await screen.findByText('已配置 sk-ful...1234')
expect((await screen.findAllByText('https://sub.example.com/v1')).length).toBeGreaterThan(0)
await screen.findByText('$5.25')
expect(fm2.keyBalance).toHaveBeenCalledWith({ baseUrl: 'https://sub.example.com/v1', apiKey: 'sk-full-value-1234' })
expect((screen.getByRole('button', { name: /测试所选 Key/ }) as HTMLButtonElement).disabled).toBe(false)
})
it('tests the saved key without a session', async () => {
fm2.status.mockResolvedValue({ configured: false })
getEnvVars.mockResolvedValue({
FREEMODEL2API_API_KEY: { is_set: true, redacted_value: 'sk-dyw...6a3f' },
FREEMODEL2API_BASE_URL: { is_set: true, redacted_value: 'http...0/v1' }
})
revealEnvVar.mockImplementation((key: any) =>
key === 'FREEMODEL2API_BASE_URL'
? Promise.resolve({ key, value: 'http://124.232.163.75:3020/v1' })
: Promise.resolve({ key, value: 'sk-saved-9999' }))
validateCustomEndpoint.mockResolvedValue({
ok: true,
reachable: true,
message: '',
models: ['gpt-5.4'],
pricing: { 'gpt-5.4': { input: '$0.30', output: '$1.20', free: false } },
rate_multiplier: 2
})
fm2.keyBalance.mockResolvedValue({ balance: 9181.34 })
render(<FreeModel2ApiSettings />)
await screen.findByText('已配置 sk-dyw...6a3f')
// 不登录也能看余额:凭 env 里那把 key 直查(sub2api /v1/sub2api/balance),不走会话
await waitFor(() => expect(fm2.keyBalance).toHaveBeenCalledWith({
baseUrl: 'http://124.232.163.75:3020/v1',
apiKey: 'sk-saved-9999'
}))
expect(await screen.findByText('$9181.34')).toBeTruthy()
const rechargeHrefs = screen.getAllByText('充值').map(e => e.closest('a')?.getAttribute('href'))
expect(rechargeHrefs).toContain('http://124.232.163.75:3020')
// 删除是未登录唯一不许做的事:按钮只长在登录后的绑定卡片里
expect(screen.queryByRole('button', { name: /删除当前 Key/ })).toBeNull()
// 断开后测试照样能打:用的 key 是 env 里保存的那把(reveal 回来的全值)
fireEvent.click(screen.getByRole('button', { name: /测试所选 Key/ }))
await waitFor(() => expect(validateCustomEndpoint).toHaveBeenCalled())
expect(validateCustomEndpoint.mock.calls.at(-1)?.[0]).toMatchObject({
api_key: 'sk-saved-9999',
base_url: 'http://124.232.163.75:3020/v1',
with_pricing: true
})
await waitFor(() => expect(testBox().textContent).toContain('×2'))
expect(testBox().textContent).toContain('$0.30 / $1.20')
})
it('refreshes balance and keys when clicking refresh', async () => {
@@ -110,13 +408,13 @@ describe('FreeModel2ApiSettings', () => {
fm2.keys.mockResolvedValue({ keys: [KEY] })
render(<FreeModel2ApiSettings />)
await screen.findByText(/\$3\.00/)
await screen.findAllByText(/\$3\.00/)
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 12.5 })
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
fireEvent.click(screen.getByRole('button', { name: /refresh/i }))
await screen.findByText(/\$12\.50/)
await screen.findAllByText(/\$12\.50/)
await waitFor(() => expect(screen.getByRole('option', { name: /backup/ })).toBeTruthy())
// 已选中的 key 仍然存在时保持选中
expect((screen.getByRole('combobox', { name: /api key/i }) as HTMLSelectElement).value).toBe('7')
@@ -136,41 +434,238 @@ describe('FreeModel2ApiSettings', () => {
await waitFor(() => expect((screen.getByRole('combobox', { name: /api key/i }) as HTMLSelectElement).value).toBe('8'))
})
it('prefills the form from remembered login', async () => {
it('prefills url and email from remembered login, keeping the password off the page', async () => {
fm2.status.mockResolvedValue({ configured: false })
fm2.rememberedLoad.mockResolvedValue({ baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123' })
render(<FreeModel2ApiSettings />)
await waitFor(() => expect((screen.getByLabelText(/base url/i) as HTMLInputElement).value).toBe('https://sub.example.com'))
expect((screen.getByLabelText(/email/i) as HTMLInputElement).value).toBe('u@example.com')
expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw-123')
expect((screen.getByLabelText(/remember/i) as HTMLInputElement).checked).toBe(true)
await waitFor(() => expect((screen.getByLabelText(/FreeModelAPI URL/i) as HTMLInputElement).value).toBe('https://sub.example.com'))
expect((screen.getByLabelText(/用户\(邮箱\)/) as HTMLInputElement).value).toBe('u@example.com')
// 密码不回显明文:输入框空着,只提示有一份存着的
const pwField = screen.getByLabelText(/FreeModelAPI 密码/i) as HTMLInputElement
expect(pwField.value).toBe('')
expect(pwField.placeholder).toBe('已保存,留空即不修改')
expect(screen.getByRole('checkbox', { name: /记住用户名密码/ })).toBeTruthy()
})
it('saves remembered login on connect when checked, clears when unchecked', async () => {
it('prefills url and email from remembered login while connected too', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.rememberedLoad.mockResolvedValue({ baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123' })
fm2.keys.mockResolvedValue({ keys: [] })
render(<FreeModel2ApiSettings />)
// 已登录时登录框也不能空着 —— 那会让用户以为「记住用户名密码」没生效
await waitFor(() => expect((screen.getByLabelText(/FreeModelAPI URL/i) as HTMLInputElement).value).toBe('https://sub.example.com'))
expect((screen.getByLabelText(/用户\(邮箱\)/) as HTMLInputElement).value).toBe('u@example.com')
expect((screen.getByLabelText(/FreeModelAPI 密码/i) as HTMLInputElement).placeholder).toBe('已保存,留空即不修改')
})
it('logs in with the remembered password when the field is left blank', async () => {
fm2.status.mockResolvedValue({ configured: false })
fm2.rememberedLoad.mockResolvedValue({ baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123' })
fm2.login.mockResolvedValue({ ok: true, email: 'u@example.com', balance: 1 })
fm2.keys.mockResolvedValue({ keys: [] })
render(<FreeModel2ApiSettings />)
await screen.findByLabelText(/FreeModelAPI URL/i)
// 密码留空直接点登录:用 Gateway 记住的那份
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
await waitFor(() => expect(fm2.login).toHaveBeenCalledWith({
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123'
}))
})
it('remembers the login only while the checkbox is on', async () => {
fm2.status.mockResolvedValue({ configured: false })
fm2.login.mockResolvedValue({ ok: true, email: 'u@example.com', balance: 1 })
fm2.keys.mockResolvedValue({ keys: [] })
const { unmount } = render(<FreeModel2ApiSettings />)
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com' } })
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
// 勾着(默认):登录后落盘
const first = render(<FreeModel2ApiSettings />)
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://sub.example.com' } })
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
await waitFor(() => expect(fm2.rememberedSave).toHaveBeenCalledWith({
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw'
}))
unmount()
first.unmount()
// 取消勾选后连接 → 清除记住的登录信息
// 取消勾选:这一次登录不落盘(没 Disconnect,只能换个新渲染从没登录态开始)
fm2.rememberedSave.mockClear()
fm2.login.mockClear()
render(<FreeModel2ApiSettings />)
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com' } })
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByLabelText(/remember/i))
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
await waitFor(() => expect(fm2.rememberedSave).toHaveBeenCalledWith(null))
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://other.example.com' } })
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
fireEvent.click(screen.getByRole('checkbox', { name: /记住用户名密码/ }))
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
await waitFor(() => expect(fm2.login).toHaveBeenCalledTimes(1))
expect(fm2.rememberedSave).not.toHaveBeenCalled()
})
it('toggles the login password between masked and plain', async () => {
fm2.status.mockResolvedValue({ configured: false })
render(<FreeModel2ApiSettings />)
const field = (await screen.findByLabelText(/FreeModelAPI 密码/i)) as HTMLInputElement
expect(field.type).toBe('password')
fireEvent.click(screen.getByRole('button', { name: /show password/i }))
expect((screen.getByLabelText(/FreeModelAPI 密码/i) as HTMLInputElement).type).toBe('text')
fireEvent.click(screen.getByRole('button', { name: /hide password/i }))
expect((screen.getByLabelText(/FreeModelAPI 密码/i) as HTMLInputElement).type).toBe('password')
})
it('shows account, balance, frozen balance and api address', async () => {
fm2.status.mockResolvedValue({
configured: true,
baseUrl: 'http://124.232.163.75:3020/v1',
email: '312820574@qq.com',
balance: 9181.342,
username: 'yb',
frozenBalance: 12.5
})
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
render(<FreeModel2ApiSettings />)
// 账号优先用 username,没有才退回邮箱
await screen.findByText('yb')
expect((await screen.findAllByText('$9181.34')).length).toBeGreaterThan(0)
expect(screen.getByText('$12.50')).toBeTruthy()
// 账户格的 API 地址和卡片归一后的 Base URL 同串(gatewayUrl 会剥掉旧 /v1 再补),各一枚
expect((await screen.findAllByText('http://124.232.163.75:3020/v1')).length).toBeGreaterThan(0)
// 充值入口指向控制台地址(剥掉 /v1),不是模型接口;账户区和卡片各一枚
const rechargeHrefs = screen.getAllByText('充值').map(e => e.closest('a')?.getAttribute('href'))
expect(rechargeHrefs).toContain('http://124.232.163.75:3020')
})
it('falls back to the email as the account label when upstream has no username', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [] })
render(<FreeModel2ApiSettings />)
await screen.findByText('u@example.com')
})
it('reopens with the api key that was saved, not the first one', async () => {
const KEY2 = { ...KEY, id: 8, name: 'backup', key: 'sk-other-9999' }
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
getHermesConfig.mockResolvedValue({ providers: { freemodel2api: { key_id: 8 } } })
render(<FreeModel2ApiSettings />)
await waitFor(() => expect((screen.getByRole('combobox', { name: /api key/i }) as HTMLSelectElement).value).toBe('8'))
// 探测也必须用回显的那把 key,否则面板显示 B 却在按 A 拉模型
await waitFor(() => expect(validateCustomEndpoint.mock.calls[0][0]).toMatchObject({ api_key: 'sk-other-9999' }))
})
it('falls back to the first api key when the saved one is gone', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
// 上次存的是 8,sub2api 那边已经删了
getHermesConfig.mockResolvedValue({ providers: { freemodel2api: { key_id: 8 } } })
render(<FreeModel2ApiSettings />)
await waitFor(() => expect((screen.getByRole('combobox', { name: /api key/i }) as HTMLSelectElement).value).toBe('7'))
})
it('shows the saved key, its masked secret and the base url in the current-key card', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
// 还没保存:卡片只说「还没保存」,不编一把「当前 Key」出来
expect(card().textContent).toContain('还没有保存 key')
fireEvent.click(screen.getByRole('button', { name: /保存并设为默认/ }))
await waitFor(() => expect(card().textContent).toContain('main'))
expect(card().textContent).toContain('已配置')
expect(card().textContent).toContain('创建于 2026-01-01')
// 卡片里也带余额和充值入口(账户区和卡片各一枚)
expect(card().textContent).toContain('$9.50')
const rechargeHrefs = screen.getAllByText('充值').map(e => e.closest('a')?.getAttribute('href'))
expect(rechargeHrefs).toContain('https://sub.example.com')
// 掩码,不露完整 key
expect(card().textContent).toContain('sk-ful')
expect(card().textContent).not.toContain('sk-full-value-1234')
expect(card().textContent).toContain('https://sub.example.com/v1')
expect(screen.getByRole('button', { name: /删除当前 Key/ })).toBeTruthy()
})
it('reopens the card from config, and browsing another key does not rewrite it', async () => {
const KEY2 = { ...KEY, id: 8, name: 'backup', key: 'sk-other-9999' }
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
getHermesConfig.mockResolvedValue({ providers: { freemodel2api: { key_id: 8 } } })
render(<FreeModel2ApiSettings />)
await waitFor(() => expect(card().textContent).toContain('backup'))
// 前面的 key 下拉只是「看」:换成 A,卡片(仍是被保存的 B)不许跟着变
fireEvent.change(screen.getByRole('combobox', { name: /api key/i }), { target: { value: '7' } })
await screen.findAllByText('gpt-5.4')
expect(card().textContent).toContain('backup')
expect(card().textContent).not.toContain('sk-ful')
expect(saveHermesConfig).not.toHaveBeenCalled()
})
it('deletes the saved key after confirming, and keeps it when cancelled', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
getHermesConfig.mockResolvedValue({ providers: { freemodel2api: { key_id: 7 } } })
render(<FreeModel2ApiSettings onConfigSaved={onConfigSaved} />)
await waitFor(() => expect(card().textContent).toContain('main'))
// 点了确认框里说「不」:什么都不动
confirm.mockResolvedValueOnce(false)
fireEvent.click(screen.getByRole('button', { name: /删除当前 Key/ }))
await waitFor(() => expect(confirm).toHaveBeenCalled())
expect(deleteEnvVar).not.toHaveBeenCalled()
expect(card().textContent).toContain('main')
fireEvent.click(screen.getByRole('button', { name: /删除当前 Key/ }))
await waitFor(() => expect(deleteEnvVar).toHaveBeenCalledTimes(2))
expect(deleteEnvVar.mock.calls[0]).toEqual(['FREEMODEL2API_API_KEY', undefined])
expect(deleteEnvVar.mock.calls[1]).toEqual(['FREEMODEL2API_BASE_URL', undefined])
await waitFor(() => expect(card().textContent).toContain('还没有保存 key'))
expect(onConfigSaved).toHaveBeenCalled()
})
it('reveals the selected api key on demand and masks it again', async () => {
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
fm2.keys.mockResolvedValue({ keys: [KEY] })
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4'] })
render(<FreeModel2ApiSettings />)
await screen.findAllByText('gpt-5.4')
// 默认掩码:面板上不留明文
expect(screen.queryByText('sk-full-value-1234')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /reveal api key/i }))
expect(screen.getByText('sk-full-value-1234')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /hide api key/i }))
expect(screen.queryByText('sk-full-value-1234')).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -22,6 +22,7 @@ import {
Settings2,
ShieldLock,
Upload,
Users,
Wrench,
Zap
} from '@/lib/icons'
@@ -101,7 +102,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
}, [activeView, setActiveView])
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'custom-endpoints')
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'freemodel2api')
const [keysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
// Jump to a section + its sub-view in one navigate. Two sequential setters
@@ -125,7 +126,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
)
const openProviderView = useCallback(
(view: ProviderView) => openSubView('providers', 'pview', view, 'custom-endpoints'),
(view: ProviderView) => openSubView('providers', 'pview', view, 'freemodel2api'),
[openSubView]
)
@@ -216,6 +217,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
{
active: activeView === 'providers',
children: [
{
active: activeView === 'providers' && providerView === 'accounts',
icon: Users,
id: 'pview:accounts',
label: t.settings.nav.providerAccounts,
onSelect: () => openProviderView('accounts')
},
{
active: activeView === 'providers' && providerView === 'custom-endpoints',
icon: Globe,
@@ -34,10 +34,11 @@ vi.mock('@/hermes', () => ({
getApiRequestProfile: () => 'default',
getMoaModels: (profile?: null | string) => getMoaModels(profile),
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setModelAssignment: (body: unknown, profile?: null | string) =>
profile == null ? setModelAssignment(body) : setModelAssignment(body, profile),
getRecommendedDefaultModel: (slug: string, profile?: null | string) => getRecommendedDefaultModel(slug, profile),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
setEnvVar: (key: string, value: string, profile?: null | string) => setEnvVar(key, value, profile),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
setApiRequestProfile: () => {}
@@ -199,6 +200,69 @@ describe('ModelSettings', () => {
expect(startManualOnboarding).not.toHaveBeenCalled()
})
it('activates FreeModel2API with its endpoint and makes its live model the main default', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'freemodel2api', model: '' })
getGlobalModelOptions
.mockResolvedValueOnce({
providers: [
{
name: 'FreeModel2API',
slug: 'freemodel2api',
models: [],
authenticated: false,
auth_type: 'api_key',
key_env: 'FREEMODEL2API_API_KEY',
base_url_env: 'FREEMODEL2API_BASE_URL'
}
]
})
.mockResolvedValueOnce({
providers: [
{
name: 'FreeModel2API',
slug: 'freemodel2api',
models: ['gpt-5.4'],
authenticated: true,
auth_type: 'api_key',
key_env: 'FREEMODEL2API_API_KEY',
base_url_env: 'FREEMODEL2API_BASE_URL'
}
]
})
getRecommendedDefaultModel.mockResolvedValueOnce({
provider: 'freemodel2api',
model: 'gpt-5.4',
free_tier: null
})
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'freemodel2api',
model: 'gpt-5.4',
gateway_tools: []
})
await renderModelSettings('research')
fireEvent.change(await screen.findByPlaceholderText('Paste FREEMODEL2API_API_KEY'), {
target: { value: 'sk-fm2' }
})
fireEvent.change(screen.getByPlaceholderText('Enter FREEMODEL2API_BASE_URL'), {
target: { value: 'http://fm2.example/v1' }
})
fireEvent.click(screen.getByRole('button', { name: 'Activate' }))
await waitFor(() => {
expect(setEnvVar).toHaveBeenNthCalledWith(1, 'FREEMODEL2API_API_KEY', 'sk-fm2', 'research')
expect(setEnvVar).toHaveBeenNthCalledWith(2, 'FREEMODEL2API_BASE_URL', 'http://fm2.example/v1', 'research')
})
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith(
{ provider: 'freemodel2api', model: 'gpt-5.4', scope: 'main' },
'research'
)
)
})
it('replaces the selected provider and model when the active profile changes', async () => {
getGlobalModelInfo
.mockResolvedValueOnce({ provider: 'custom', model: 'local-a' })
@@ -261,6 +261,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
// Inline API-key entry for picking an unconfigured `api_key` provider in
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
const [apiKeyDraft, setApiKeyDraft] = useState('')
const [apiBaseUrlDraft, setApiBaseUrlDraft] = useState('')
const [activating, setActivating] = useState(false)
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
@@ -381,6 +382,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
setSelectedProvider('')
setSelectedModel('')
setApiKeyDraft('')
setApiBaseUrlDraft('')
void refresh({ replaceSelection: true })
})
@@ -417,9 +419,13 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
const needsSetup = optionsLoaded && !!selectedProvider && !isProviderReady(selectedProviderRow)
const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env
const setupBaseUrlEnv =
typeof selectedProviderRow?.base_url_env === 'string' ? selectedProviderRow.base_url_env.trim() : ''
// Clear any half-typed key when switching provider so it can't leak across.
useEffect(() => {
setApiKeyDraft('')
setApiBaseUrlDraft('')
}, [selectedProvider])
const auxDraftProviderModels = useMemo(
@@ -634,7 +640,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
const keyEnv = selectedProviderRow?.key_env
const slug = selectedProviderRow?.slug
if (!keyEnv || !slug || !apiKeyDraft.trim()) {
const baseUrlEnv =
typeof selectedProviderRow?.base_url_env === 'string' ? selectedProviderRow.base_url_env.trim() : ''
if (!keyEnv || !slug || !apiKeyDraft.trim() || (baseUrlEnv && !apiBaseUrlDraft.trim())) {
return
}
@@ -644,7 +653,13 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
try {
await setEnvVar(keyEnv, apiKeyDraft.trim(), scopeProfile)
if (baseUrlEnv) {
await setEnvVar(baseUrlEnv, apiBaseUrlDraft.trim(), scopeProfile)
}
setApiKeyDraft('')
setApiBaseUrlDraft('')
// Pick a sensible default for the freshly-activated provider (mirrors
// `hermes model` curation). Best-effort — fall through to the refreshed
@@ -658,7 +673,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
nextModel = ''
}
const options = await getGlobalModelOptions(undefined, scopeProfile)
const options = await getGlobalModelOptions({ refresh: true }, scopeProfile)
if (profileEpoch.current !== epoch) {
return
@@ -667,13 +682,50 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
setProviders(options.providers || [])
const refreshedRow = options.providers?.find(p => p.slug === slug)
const fallbackModel = refreshedRow?.models?.[0] ?? ''
setSelectedModel(nextModel || fallbackModel)
const selectedDefault = nextModel || fallbackModel
setSelectedModel(selectedDefault)
// FreeModel2API is the product default route: once its profile-scoped endpoint/key are
// available, persist the provider and a concrete live-catalog model in one setup action.
if (slug === 'freemodel2api' && selectedDefault) {
const result = await setMainModelAssignment(
{
provider: slug,
model: selectedDefault,
...(refreshedRow?.api_url ? { base_url: refreshedRow.api_url } : {})
},
scopeProfile
)
if (profileEpoch.current !== epoch) {
return
}
const provider = result.provider || slug
const model = result.model || selectedDefault
setMainModel({ provider, model })
setSwitchStaleAux(result.stale_aux ?? [])
if (scopeProfile == null) {
onMainModelChanged?.(provider, model)
}
}
} catch (err) {
setCaughtError(err, m.loadFailed)
} finally {
setActivating(false)
}
}, [apiKeyDraft, m.loadFailed, scopeProfile, selectedProviderRow, setCaughtError])
}, [
apiBaseUrlDraft,
apiKeyDraft,
m.loadFailed,
onMainModelChanged,
scopeProfile,
selectedProviderRow,
setCaughtError
])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in. The
@@ -922,8 +974,23 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
type="password"
value={apiKeyDraft}
/>
{setupBaseUrlEnv ? (
<Input
autoComplete="off"
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
onChange={event => setApiBaseUrlDraft(event.target.value)}
onKeyDown={event => {
if (isSubmitEnter(event)) {
void activateApiKeyProvider()
}
}}
placeholder={`Enter ${setupBaseUrlEnv}`}
type="url"
value={apiBaseUrlDraft}
/>
) : null}
<Button
disabled={!apiKeyDraft.trim() || activating}
disabled={!apiKeyDraft.trim() || (setupBaseUrlEnv ? !apiBaseUrlDraft.trim() : false) || activating}
onClick={() => void activateApiKeyProvider()}
size="sm"
>
@@ -30,8 +30,8 @@ import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { CustomEndpointsSettings } from './custom-endpoints-settings'
import { FreeModel2ApiSettings } from './freemodel2api-settings'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { FreeModel2ApiSettings } from './freemodel2api-settings'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LocalModelsSettings } from './local-models-settings'
import { SettingsContent, SettingsSkeleton } from './primitives'
@@ -356,7 +356,7 @@ export function ProvidersSettings({
}: ProvidersSettingsProps) {
const { t } = useI18n()
const scopeProfile = useStore($settingsRequestProfile)
const { rowProps, vars } = useEnvCredentials(scopeProfile)
const { refresh: refreshEnvVars, rowProps, vars } = useEnvCredentials(scopeProfile)
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(null)
@@ -525,7 +525,18 @@ export function ProvidersSettings({
}
if (view === 'freemodel2api') {
return <FreeModel2ApiSettings onConfigSaved={onConfigSaved} />
// That panel writes its two env slots itself, so `vars` here would keep
// showing them as unset until the scope changed — re-read on its signal.
return (
<FreeModel2ApiSettings
onConfigSaved={() => {
onConfigSaved?.()
void refreshEnvVars()
}}
onMainModelChanged={onMainModelChanged}
scopeProfile={scopeProfile}
/>
)
}
if (view === 'local') {
@@ -0,0 +1,69 @@
import { cleanup, renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { $desktopVersion, $updateStatus } from '@/store/updates'
import { useStatusbarItems } from './use-statusbar-items'
vi.mock('./use-context-breakdown', () => ({ useContextBreakdown: () => ({ breakdown: null, loading: false }) }))
vi.mock('../system-resources-statusbar', () => ({
useSystemResourcesStatusbarItem: () => ({ id: 'system-resources', variant: 'text', label: 'Resources' })
}))
afterEach(() => {
cleanup()
$connection.set(null)
$desktopVersion.set(null)
$updateStatus.set(null)
})
it('keeps statusbar tools but never duplicates the sidebar update entry, including remote mode', () => {
$desktopVersion.set({
appVersion: '0.77.3',
electronVersion: '0',
nodeVersion: '0',
platform: 'test',
hermesRoot: '/tmp'
})
$updateStatus.set({ supported: true, updateAvailable: true, currentSha: 'build-sha' })
$connection.set({
mode: 'remote',
connectionId: 'remote',
baseUrl: 'https://gateway.example',
wsUrl: 'wss://gateway.example/ws',
isFullscreen: false,
nativeOverlayWidth: 0,
token: '',
logs: [],
windowButtonPosition: null
})
const { result } = renderHook(
() =>
useStatusbarItems({
agentsOpen: false,
changeWorkspace: vi.fn(),
chatOpen: true,
commandCenterOpen: false,
extraLeftItems: [],
extraRightItems: [],
gatewayState: 'open',
inferenceStatus: null,
openAgents: vi.fn(),
openCommandCenterSection: vi.fn(),
freshDraftReady: true,
requestGateway: vi.fn(),
statusSnapshot: null,
toggleCommandCenter: vi.fn()
}),
{ wrapper: MemoryRouter }
)
const ids = result.current.statusbarItems.map(item => item.id)
expect(ids).not.toContain('version-client')
expect(ids).not.toContain('version-backend')
expect(ids).toContain('system-resources')
expect(ids).toContain('terminal')
})
@@ -3,7 +3,6 @@ import { useMemo } from 'react'
import { useNavigate } from 'react-router'
import { ConnectionSwitcher } from '@/app/chat/sidebar/connection-switcher'
import { ProfileSwitcher } from '@/app/chat/sidebar/profile-dropdown-switcher'
import type { CommandCenterSection } from '@/app/command-center'
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
@@ -22,7 +21,6 @@ import {
Command,
FolderOpen,
Globe,
Hash,
Layers3,
Loader2,
Terminal,
@@ -32,18 +30,15 @@ import { runtimeReadinessDisplay, type RuntimeReadinessResult } from '@/lib/runt
import { cacheHitLabel, contextBarLabel, LiveDuration, tokensPerSecondLabel, usageContextLabel } from '@/lib/statusbar'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { resolveVersionStatus } from '@/lib/version-status'
import { copyFilePath, revealFile } from '@/store/file-actions'
import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier'
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { revealFileInTree } from '@/store/layout'
import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate'
import { $profileRailVisible } from '@/store/profile-rail-prefs'
import { $projectTree, projectNameForCwd } from '@/store/projects'
import {
$activeSessionId,
$busy,
$connection,
$currentCwd,
$currentUsage,
$selectedStoredSessionId,
@@ -57,14 +52,6 @@ import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from
import { $statusbarHiddenIds } from '@/store/statusbar-prefs'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
import { $gatewayRestarting } from '@/store/system-actions'
import {
$backendUpdateApply,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
import type { StatusResponse, UsageStats } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes'
@@ -115,7 +102,6 @@ export function useStatusbarItems({
// minimized zone, which lit the button for a pane the user couldn't see.
const terminalShowing = useStore($paneVisible('terminal'))
const sessionsShowing = useStore($paneVisible('sessions'))
const profileRailVisible = useStore($profileRailVisible)
const botsShowing = useStore($paneVisible('hermes-bots:pane'))
const primaryBusy = useStore($busy)
// Draft / primary composer atom — used only while the focused surface is the
@@ -149,12 +135,6 @@ export function useStatusbarItems({
// subscription is what makes the check reactive.
useStore($onboardingGate)
const guideOwnsSignIn = guidedOnboardingActive()
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
const backendUpdateApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
// The FOCUSED session (interacted tile, else the primary — the same
// derivation the titlebar title follows): every session-scoped readout
@@ -341,93 +321,6 @@ export function useStatusbarItems({
? 'text-amber-600 hover:text-amber-600'
: 'text-destructive hover:text-destructive'
const clientVersionItem = useMemo<StatusbarItem>(() => {
const applying = updateApply.applying || updateApply.stage === 'restart'
const status = resolveVersionStatus({
applying,
applyMessage: updateApply.message,
behind: updateStatus?.behind ?? 0,
branch: updateStatus?.branch,
copy,
remote: connection?.mode === 'remote',
restarting: updateApply.stage === 'restart',
sha: updateStatus?.currentSha?.slice(0, 7) ?? null,
target: 'client',
updateAvailable: updateStatus?.updateAvailable,
version: desktopVersion?.appVersion
})
return {
className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
detail: status.detail,
hidden: status.unknown,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-client',
label: status.label,
// Update state is not a preference: hiding it is how a user misses that
// their client is behind. Listed in the menu, but locked on.
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('client'),
title: status.tooltip,
toggleLabel: copy.toggleVersion,
variant: 'action'
}
}, [
desktopVersion?.appVersion,
connection?.mode,
copy,
updateApply.applying,
updateApply.message,
updateApply.stage,
updateStatus?.behind,
updateStatus?.branch,
updateStatus?.currentSha,
updateStatus?.updateAvailable
])
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
const status = resolveVersionStatus({
applying,
applyMessage: backendUpdateApply.message,
behind: backendUpdateStatus?.behind ?? 0,
copy,
remote: true,
restarting: backendUpdateApply.stage === 'restart',
target: 'backend',
updateAvailable: backendUpdateStatus?.updateAvailable,
version: statusSnapshot?.version
})
return {
className: status.hasUpdate ? 'text-primary hover:text-primary' : undefined,
hidden: status.unknown,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-backend',
label: status.label,
lockedVisible: true,
onSelect: () => openUpdateOverlayFor('backend'),
title: status.tooltip,
toggleLabel: copy.toggleBackendVersion,
variant: 'action'
}
}, [
connection?.mode,
statusSnapshot?.version,
backendUpdateStatus?.behind,
backendUpdateStatus?.updateAvailable,
backendUpdateApply.applying,
backendUpdateApply.message,
backendUpdateApply.stage,
copy
])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
@@ -448,14 +341,6 @@ export function useStatusbarItems({
lockedVisible: true,
render: () => <StatusbarGatewaySwitcher />
},
{
// The rail's stand-in: the profile picker moves down here while the
// colored strip is hidden, so switching profiles always has a door.
hidden: !sessionsShowing || profileRailVisible,
id: 'profile-switcher',
lockedVisible: true,
render: () => <ProfileSwitcher compact />
},
{
className: gatewayRestarting ? undefined : gatewayClassName,
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
@@ -609,7 +494,6 @@ export function useStatusbarItems({
inferenceReady,
inferenceStatus?.reason,
openAgents,
profileRailVisible,
projectName,
sessionsShowing,
subagentsFailed,
@@ -682,16 +566,12 @@ export function useStatusbarItems({
title: terminalShowing ? copy.hideTerminal : copy.showTerminal,
toggleLabel: copy.toggleTerminal,
variant: 'action'
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
}
],
[
backendVersionItem,
busy,
cacheHit,
chatOpen,
clientVersionItem,
contextBar,
contextBreakdown,
contextBreakdownLoading,
@@ -17,7 +17,7 @@ function renderControls(pathname: string, props?: { leftTools?: TitlebarTool[];
return render(
<MemoryRouter initialEntries={[pathname]}>
<I18nProvider configClient={null} initialLocale="en">
<TitlebarControls leftTools={props?.leftTools} onOpenSettings={() => {}} tools={props?.tools} />
<TitlebarControls leftTools={props?.leftTools} tools={props?.tools} />
</I18nProvider>
</MemoryRouter>
)
@@ -140,32 +140,36 @@ describe('titlebar app-action cluster', () => {
cleanup()
})
it('defaults settings, layout, and HUD to the right so the left titlebar stays free for tabs', () => {
it('keeps the sidebar and swap controls on the left and omits settings', () => {
renderControls('/')
const left = screen.getByLabelText('Window controls')
const right = screen.getByLabelText('App controls')
expect(within(right).getByLabelText('Open settings')).toBeTruthy()
expect(within(right).getByLabelText('Layout editor')).toBeTruthy()
expect(within(right).getByLabelText('HUD mode')).toBeTruthy()
expect(within(right).queryByLabelText('Open settings')).toBeNull()
expect(within(left).queryByLabelText('Open settings')).toBeNull()
expect(within(left).queryByLabelText('Layout editor')).toBeNull()
expect(within(left).queryByLabelText('HUD mode')).toBeNull()
expect(within(left).getByLabelText(/Hide sidebar|Show sidebar/)).toBeTruthy()
expect(within(left).getByLabelText(/Collapse sidebar|Expand sidebar/)).toBeTruthy()
expect(within(left).getByLabelText('Swap sidebar sides')).toBeTruthy()
expect(within(right).queryByLabelText('Swap sidebar sides')).toBeNull()
})
it('moves settings, layout, and HUD to the left when the appearance setting says left', () => {
it('moves layout and HUD to the left when the appearance setting says left', () => {
setTitlebarAppActionsSide('left')
renderControls('/')
const left = screen.getByLabelText('Window controls')
const right = screen.getByLabelText('App controls')
expect(within(left).getByLabelText('Open settings')).toBeTruthy()
expect(within(left).getByLabelText('Layout editor')).toBeTruthy()
expect(within(left).getByLabelText('HUD mode')).toBeTruthy()
expect(within(left).getByLabelText('Swap sidebar sides')).toBeTruthy()
expect(within(left).queryByLabelText('Open settings')).toBeNull()
expect(within(right).queryByLabelText('Open settings')).toBeNull()
expect(within(right).queryByLabelText('Swap sidebar sides')).toBeNull()
})
})
@@ -64,7 +64,6 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
/**
@@ -131,7 +130,7 @@ function useModifierHeld(): boolean {
return held
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
export function TitlebarControls({ leftTools = [], tools = [] }: TitlebarControlsProps) {
const { t } = useI18n()
const navigate = useNavigate()
const location = useLocation()
@@ -152,14 +151,14 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const titleBarRight = useContributions('titleBar.right')
const pageOwnsTitlebar = titleBarLeft.length + titleBarCenter.length + titleBarRight.length > 0
// POSITIONAL toggles: each button shows/hides everything on its physical
// POSITIONAL toggles: each edge button shows/hides everything on its physical
// side of the main zone (the layout tree collapses the whole side), so they
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
// show/hide affordances.
// $fileBrowserOpen ≙ right side. The pane-swap action is fixed in the left
// titlebar cluster and is handled separately below.
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar
const leftLabel = leftEdge.open ? t.titlebar.collapseSidebar : t.titlebar.expandSidebar
const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar
const sidebarTool: TitlebarTool = {
@@ -198,19 +197,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
tour: 'right-pane-toggle'
}
// Static system tools — always pinned to the screen's right edge so the
// left titlebar stays free for tabs (#107351).
// Static system tools follow the configured app-action side. The pane-swap
// control remains left-fixed so the titlebar keeps one stable navigation
// cluster regardless of that preference.
const systemTools: TitlebarTool[] = [
{
actionId: 'nav.settings',
icon: <TitlebarIcon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
},
{
className: 'group/tool',
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
@@ -289,7 +279,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
const visibleLeftTools = (
appActionsSide === 'left' ? [sidebarTool, ...systemTools, ...leftTools] : [sidebarTool, ...leftTools]
appActionsSide === 'left'
? [sidebarTool, flipTool, ...systemTools, ...leftTools]
: [sidebarTool, flipTool, ...leftTools]
).filter(tool => !tool.hidden)
const visibleSystemTools = appActionsSide === 'right' ? systemTools.filter(tool => !tool.hidden) : []
@@ -327,7 +319,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<TitlebarToolButton navigate={navigate} tool={flipTool} />
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
<Slot area="titleBar.right" />
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ export function Backdrop() {
}
return (
<div aria-hidden className="pointer-events-none absolute inset-0 z-2 opacity-[0.025] mix-blend-difference">
<div aria-hidden className="pointer-events-none absolute inset-0 z-2 opacity-[0.01] mix-blend-difference">
<img
alt=""
className="h-[160dvh] w-auto min-w-dvw object-cover object-left-top [filter:invert(var(--backdrop-invert-mul,1))]"
@@ -202,7 +202,6 @@ function ListingGallery({ address, images }: { address: string; images: string[]
<ImageLightbox
alt={address}
copy={t.desktop}
onClick={download}
onOpenChange={open => setOpenAt(open ? position : null)}
open={openAt !== null}
@@ -4,6 +4,7 @@ import { type FC, useEffect, useState } from 'react'
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
import { Tip } from '@/components/ui/tooltip'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
@@ -133,10 +134,11 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
</div>
)}
{src && (
<Tip label={copy.openImage}>
<button
aria-label={copy.openImage}
className="absolute inset-0 block size-full cursor-zoom-in"
onClick={() => setLightboxOpen(true)}
title={copy.openImage}
type="button"
>
<img
@@ -159,6 +161,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
src={src}
/>
</button>
</Tip>
)}
{loaded && src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
@@ -167,7 +170,6 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
{src && (
<ImageLightbox
alt="Generated image"
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
@@ -0,0 +1,170 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'
import { LocalFilePreview } from '@/app/chat/right-rail/preview-file'
import { $connection } from '@/store/session'
import { stubMenuDomApis } from '@/test/jsdom'
import { GeneratedImage } from './generated-image-result'
vi.mock('./image-generation-placeholder', () => ({ DiffusionCanvas: () => null }))
let deliverResize: ResizeObserverCallback
const source = 'data:image/png;base64,fixture'
const save = vi.fn(async () => false)
const read = vi.fn(async () => source)
const api = vi.fn(async () => ({ dataUrl: source }))
beforeAll(() => {
stubMenuDomApis()
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: ResizeObserverCallback) {
deliverResize = callback
}
observe() {}
unobserve() {}
disconnect() {}
}
)
})
beforeEach(() => {
$connection.set(null)
vi.stubGlobal('hermesDesktop', { readFileDataUrl: read, api, saveImageFromUrl: save })
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
$connection.set(null)
Reflect.deleteProperty(window, 'hermesDesktop')
})
function resize(stage: HTMLElement, width: number, height: number) {
act(() =>
deliverResize(
[{ target: stage, contentRect: { width, height } } as unknown as ResizeObserverEntry],
{} as ResizeObserver
)
)
}
function loadImage(image: HTMLElement) {
Object.defineProperties(image, {
naturalWidth: { configurable: true, value: 2000 },
naturalHeight: { configurable: true, value: 1000 }
})
fireEvent.load(image)
}
function scale(image: HTMLElement) {
return Number(image.style.transform.match(/scale\(([^)]+)\)/)?.[1])
}
it('gives generated images fit/native sizing, cancellable wheel zoom, drag and download without dismissing the viewer', async () => {
render(<GeneratedImage result={{ success: true, image: source }} />)
fireEvent.click(screen.getByRole('button', { name: 'Open image' }))
const dialog = screen.getByRole('dialog')
const image = within(dialog).getByAltText('Generated image')
const stage = dialog.querySelector('[data-slot="image-stage"]') as HTMLElement
loadImage(image)
resize(stage, 400, 300)
const fitScale = scale(image)
expect(fitScale).toBeGreaterThan(0)
expect(fitScale).toBeLessThan(1)
fireEvent.click(within(dialog).getByRole('button', { name: 'Zoom in' }))
expect(scale(image)).toBeGreaterThan(fitScale)
fireEvent.click(within(dialog).getByRole('button', { name: 'Zoom out' }))
expect(scale(image)).toBeCloseTo(fitScale)
const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY: -1 })
act(() => stage.dispatchEvent(wheel))
expect(wheel.defaultPrevented).toBe(true)
expect(scale(image)).toBeGreaterThan(fitScale)
fireEvent.click(within(dialog).getByRole('button', { name: 'Actual size' }))
expect(scale(image)).toBe(1)
resize(stage, 700, 400)
expect(scale(image)).toBe(1)
fireEvent.pointerDown(stage, { button: 0, pointerId: 1, clientX: 10, clientY: 20 })
fireEvent.pointerMove(stage, { pointerId: 1, clientX: 60, clientY: 50 })
expect(image.style.transform).toContain('translate(50px, 30px)')
fireEvent.pointerCancel(stage, { pointerId: 1 })
const position = image.style.transform
fireEvent.pointerMove(stage, { pointerId: 1, clientX: 90, clientY: 90 })
expect(image.style.transform).toBe(position)
fireEvent.click(image)
expect(screen.getByRole('dialog')).toBe(dialog)
fireEvent.click(within(dialog).getByRole('button', { name: 'Fit to window' }))
expect(scale(image)).toBeLessThan(1)
expect(image.style.transform).toContain('translate(0px, 0px)')
resize(stage, 800, 400)
expect(scale(image)).toBeGreaterThan(fitScale)
fireEvent.click(within(dialog).getByRole('button', { name: 'Download image' }))
await waitFor(() => expect(save).toHaveBeenCalledWith(source))
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
})
it('keeps local/remote file reads on their real adapters and exposes the same viewer inside the pane and expanded', async () => {
const path = '/work/generated.png'
const target = {
kind: 'file' as const,
label: 'generated.png',
previewKind: 'image' as const,
source: path,
path,
url: `file://${path}`
}
for (const remote of [false, true]) {
if (remote) {
$connection.set({
mode: 'remote',
profile: 'image-profile',
connectionId: 'remote',
baseUrl: 'https://gateway.example',
wsUrl: 'wss://gateway.example/ws',
token: '',
logs: [],
isFullscreen: false,
nativeOverlayWidth: 0,
windowButtonPosition: null
})
}
const { container, unmount } = render(<LocalFilePreview reloadKey={0} target={target} />)
const image = await screen.findByAltText(target.label)
expect(image.getAttribute('src')).toBe(source)
if (remote) {
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
path: `/api/fs/read-data-url?path=${encodeURIComponent(path)}`,
profile: 'image-profile'
})
)
} else {
expect(read).toHaveBeenCalledWith(path)
expect(api).not.toHaveBeenCalled()
}
const stage = container.querySelector('[data-slot="image-stage"]') as HTMLElement
loadImage(image)
resize(stage, 352, 450)
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
expect(scale(image)).toBeGreaterThan(352 / 2000)
fireEvent.click(screen.getByRole('button', { name: 'Open image' }))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByAltText(target.label).getAttribute('src')).toBe(source)
expect(within(dialog).getByRole('button', { name: 'Fit to window' })).toBeTruthy()
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(container.querySelector('[data-slot="image-viewer"]')).toBeTruthy()
unmount()
}
})
@@ -0,0 +1,210 @@
import { type ComponentProps, useCallback, useEffect, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { Tip } from '@/components/ui/tooltip'
import { useZoomPan } from '@/components/ui/use-zoom-pan'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { Download, Maximize, RefreshCw, Square, X, ZoomIn, ZoomOut } from '@/lib/icons'
import { isSmartZoomWheel } from '@/lib/trackpad-gestures'
import { cn } from '@/lib/utils'
interface ImageSize {
width: number
height: number
}
export interface ImageViewerProps {
alt?: string
src: string
onDownload: () => void
saving?: boolean
onExpand?: () => void
onClose?: () => void
}
/** Shared image surface for file previews and transcript lightboxes. */
export function ImageViewer({ alt, src, onDownload, saving = false, onExpand, onClose }: ImageViewerProps) {
const { t } = useI18n()
const copy = t.desktop
const stageRef = useRef<HTMLDivElement>(null)
const [imageSize, setImageSize] = useState<ImageSize>({ width: 0, height: 0 })
const [stageSize, setStageSize] = useState<ImageSize>({ width: 0, height: 0 })
const [fit, setFit] = useState(true)
const [failed, setFailed] = useState(false)
const ready = imageSize.width > 0 && stageSize.width > 0 && stageSize.height > 0
const fitScale = ready ? Math.min(1, stageSize.width / imageSize.width, stageSize.height / imageSize.height) : 1
const { panning, reset, scale, setScale, stageProps, style, zoomIn, zoomOut } = useZoomPan({
minScale: Math.min(0.05, fitScale),
resetScale: fitScale
})
const { onWheel, ...pointerProps } = stageProps
const measureStage = useCallback((entries: readonly ResizeObserverEntry[]) => {
const rect = entries[0]?.contentRect ?? stageRef.current?.getBoundingClientRect()
if (rect) {
setStageSize(prev =>
prev.width === rect.width && prev.height === rect.height ? prev : { width: rect.width, height: rect.height }
)
}
}, [])
useResizeObserver(measureStage, stageRef)
useEffect(() => {
if (fit && ready) {
reset()
}
}, [fit, ready, reset])
// React wheel listeners are passive; image zoom must not scroll or zoom the app.
useEffect(() => {
const stage = stageRef.current!
const handleWheel = (event: WheelEvent) => {
if (!ready || failed) {
return
}
setFit(isSmartZoomWheel(event))
onWheel(event)
}
stage.addEventListener('wheel', handleWheel, { passive: false })
return () => stage.removeEventListener('wheel', handleWheel)
}, [failed, onWheel, ready])
const fitImage = () => {
setFit(true)
reset()
}
const actualSize = () => {
setFit(false)
setScale(1)
}
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-col" data-slot="image-viewer">
<div
aria-label={copy.openImage}
className="flex h-11 shrink-0 items-center justify-between gap-1 border-b border-(--ui-stroke-tertiary) px-2"
role="toolbar"
>
<div className="flex items-center gap-1">
<ImageToolButton
disabled={!ready || failed || scale <= Math.min(0.05, fitScale)}
label={copy.imageZoomOut}
onClick={() => {
setFit(false)
zoomOut()
}}
>
<ZoomOut />
</ImageToolButton>
<span
className="w-12 shrink-0 text-center text-[0.6875rem] tabular-nums text-(--ui-text-secondary)"
data-slot="image-scale"
>
{Math.round(scale * 100)}%
</span>
<ImageToolButton
disabled={!ready || failed || scale >= 8}
label={copy.imageZoomIn}
onClick={() => {
setFit(false)
zoomIn()
}}
>
<ZoomIn />
</ImageToolButton>
<ImageToolButton aria-pressed={fit} disabled={!ready || failed} label={copy.imageFit} onClick={fitImage}>
<RefreshCw />
</ImageToolButton>
<ImageToolButton
aria-pressed={!fit && scale === 1}
disabled={!ready || failed}
label={copy.imageActualSize}
onClick={actualSize}
>
<Square />
</ImageToolButton>
</div>
<div className="flex items-center gap-1">
<ImageToolButton
disabled={saving}
label={saving ? copy.savingImage : copy.downloadImage}
onClick={onDownload}
>
<Download />
</ImageToolButton>
{onExpand && (
<ImageToolButton label={copy.openImage} onClick={onExpand}>
<Maximize />
</ImageToolButton>
)}
{onClose && (
<Button aria-label={t.common.close} onClick={onClose} size="icon-sm" variant="ghost">
<X />
</Button>
)}
</div>
</div>
<div
{...pointerProps}
className={cn(
'relative min-h-0 flex-1 touch-none overflow-hidden',
panning ? 'cursor-grabbing' : 'cursor-grab'
)}
data-slot="image-stage"
onDoubleClick={() => (scale === 1 ? fitImage() : actualSize())}
ref={stageRef}
>
{!ready && !failed && <PageLoader className="absolute inset-0" label={t.preview.loading} />}
{failed && (
<div className="absolute inset-0 grid place-items-center p-4">
<ErrorState title={copy.imagePreviewFailed} />
</div>
)}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<img
alt={alt ?? ''}
className="block max-h-none max-w-none shrink-0 select-none"
draggable={false}
onError={() => setFailed(true)}
onLoad={event => {
const { naturalWidth, naturalHeight } = event.currentTarget
setImageSize({ width: naturalWidth, height: naturalHeight })
setFailed(false)
}}
src={src}
style={{
...style,
width: imageSize.width || undefined,
height: imageSize.height || undefined,
visibility: ready && !failed ? 'visible' : 'hidden'
}}
/>
</div>
</div>
</div>
)
}
interface ImageToolButtonProps extends ComponentProps<typeof Button> {
label: string
}
function ImageToolButton({ label, ...props }: ImageToolButtonProps) {
return (
<Tip label={label}>
<Button aria-label={label} size="icon-sm" variant="ghost" {...props} />
</Tip>
)
}
@@ -68,7 +68,7 @@
{"personality":"hype","headline":"New session, infinite W's","body":"Bring the gnarliest bug you've got. I'll read, patch, test, commit like my life depends on it. LET'S GO."}
{"personality":"hype","headline":"ABSOLUTELY DIALED IN","body":"Describe the task. I'll blitz through files, crush failing tests, and leave a commit that SLAPS. Go go go."}
{"personality":"hype","headline":"Ready. So ready. Too ready.","body":"Tiny typo or huge refactor - doesn't matter. I'm shipping clean code today. Name the task and let's WORK."}
{"personality":"none","headline":"Hermes Agent is ready.","body":"Ask a question, paste an error, or point me at a repo. I can read code, run tools, and help you ship."}
{"personality":"none","headline":"Digital Assistant is ready.","body":"Ask a question, paste an error, or point me at a repo. I can read code, run tools, and help you ship."}
{"personality":"none","headline":"What are we building today?","body":"Describe the task in your own words. I'll pick the right tools, explain my plan, and check in before risky steps."}
{"personality":"none","headline":"Start anywhere.","body":"Drop a file path, a traceback, or a rough idea. I'll investigate, suggest next steps, and keep things reversible."}
{"personality":"none","headline":"Your workspace, one prompt away.","body":"Search the repo, edit files, run tests, open PRs. Tell me the goal and I'll handle the mechanical parts."}
+1 -1
View File
@@ -145,7 +145,7 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
}
const WORDMARK = 'HERMES AGENT'
const WORDMARK = 'Digital Assistant'
function resolveCopy(personality?: string, seed?: number): IntroCopy {
const personalityKey = normalizeKey(personality)
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
/**
* The oversized display lettering of an empty chat — the Collapse face that
* writes "HERMES AGENT" across a fresh draft, and a bot's name across its own
* writes the welcome name across a fresh draft, and a bot's name across its own
* empty chat.
*
* The doubled children are load-bearing, not a typo: `.fit-text` sizes the
@@ -2,12 +2,15 @@
import { type ComponentProps, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Tip } from '@/components/ui/tooltip'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { Download } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { ImageViewer } from './image-viewer'
export interface ZoomableImageProps extends ComponentProps<'img'> {
containerClassName?: string
slot?: string
@@ -31,15 +34,17 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
data-slot={slot ?? 'aui_zoomable-image'}
>
<Tip label={canOpen ? copy.openImage : undefined}>
<button
aria-label={copy.openImage}
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? copy.openImage : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
</button>
</Tip>
{src && (
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
)}
@@ -47,7 +52,6 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
{src && (
<ImageLightbox
alt={alt}
copy={copy}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
@@ -59,44 +63,35 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
)
}
export function ImageLightbox({
alt,
copy,
onClick,
onOpenChange,
open,
saving,
src
}: {
export interface ImageLightboxProps {
alt?: string
copy: ImageActionCopy
onClick: () => void
onOpenChange: (open: boolean) => void
open: boolean
saving: boolean
src: string
}) {
}
export function ImageLightbox({ alt, onClick, onOpenChange, open, saving, src }: ImageLightboxProps) {
const { t } = useI18n()
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
bodyClassName="block overflow-visible p-0"
className="w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] border-0 bg-transparent shadow-none"
aria-describedby={undefined}
bodyClassName="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
className="h-[90dvh] w-[96vw] max-h-[90dvh] max-w-[96vw]"
showCloseButton={false}
>
<div className="group/lightbox relative inline-block">
<img
alt={alt ?? ''}
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => onOpenChange(false)}
<DialogTitle className="sr-only">{alt || t.desktop.openImage}</DialogTitle>
<ImageViewer
alt={alt}
key={src}
onClose={() => onOpenChange(false)}
onDownload={onClick}
saving={saving}
src={src}
/>
<ImageActionButton
className="group-hover/lightbox:opacity-100"
copy={copy}
onClick={onClick}
saving={saving}
/>
</div>
</DialogContent>
</Dialog>
)
@@ -114,6 +109,7 @@ export function ImageActionButton({
saving: boolean
}) {
return (
<Tip label={saving ? copy.savingImage : copy.downloadImage}>
<button
aria-label={saving ? copy.savingImage : copy.downloadImage}
className={cn(
@@ -125,10 +121,10 @@ export function ImageActionButton({
event.stopPropagation()
void onClick()
}}
title={saving ? copy.savingImage : copy.downloadImage}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</button>
</Tip>
)
}
@@ -265,7 +265,7 @@ export async function buildFirstTaskSeedMessages(
/** The hidden note sent to the welcome chat once the build session is live. The check-ins after it come from the
* build's own progress, in first-build.ts. */
export function buildHandoffCompleteNote(task: string): string {
return `[setup] handoff complete — "${task.trim()}" is now building in its own session on the default profile, and the user is watching it there. The app is showing them a short tour of the profile rail and the sessions list right now, so do not describe either. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.`
return `[setup] handoff complete — "${task.trim()}" is now building in its own session on the default profile, and the user is watching it there. The app is showing them a short tour of the account menu's profiles and the sessions list right now, so do not describe either. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.`
}
/** Creates the guide profile. The catch treats an already-existing profile as success, so kickoff can call this on
@@ -13,10 +13,10 @@ import { translateNow } from '@/i18n'
/** Tour handles (`data-tour`). A targets scan returns these same selectors, so a curated step and a
* model-driven step point at the same node. */
const RAIL = '[data-tour="profile-rail"]'
const PROFILE_MENU = '[data-tour="profile-menu"]'
const SESSIONS = '[data-tour="sessions-sidebar"]'
/** Waits for a visible node. The profile rail mounts a render or two after the handoff switches profiles, and
/** Waits for a visible node. Sidebar chrome can mount after the handoff switches profiles, and
* the tour engine returns a no-match for a selector that is not in the DOM yet. Returns false on timeout. */
async function waitFor(selector: string, timeoutMs = 6000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
@@ -40,7 +40,7 @@ async function waitFor(selector: string, timeoutMs = 6000): Promise<boolean> {
/** The caller does not await this, so the tour does not delay the handoff. */
export async function showHandoffTour(): Promise<void> {
if (!(await waitFor(RAIL))) {
if (!(await waitFor(PROFILE_MENU))) {
return
}
@@ -51,10 +51,10 @@ export async function showHandoffTour(): Promise<void> {
const { startTour } = await import('@/lib/tour')
await startTour([
{ accent: true, selector: RAIL, side: 'right', text: copy('profileText'), title: copy('profileTitle') },
{ accent: true, selector: PROFILE_MENU, side: 'right', text: copy('profileText'), title: copy('profileTitle') },
...(sessionsVisible
? [{ selector: SESSIONS, side: 'right' as const, text: copy('sessionsText'), title: copy('sessionsTitle') }]
: []),
{ accent: true, selector: RAIL, side: 'right', text: copy('stayText'), title: copy('stayTitle') }
{ accent: true, selector: PROFILE_MENU, side: 'right', text: copy('stayText'), title: copy('stayTitle') }
])
}
@@ -1,11 +1,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import { makeOAuthProvider } from '@/test/oauth-provider'
import type { OAuthProvider } from '@/types/hermes'
import { Picker } from '.'
import { ApiKeyForm, Picker } from '.'
function setProviders(providers: OAuthProvider[]) {
$desktopOnboarding.set({
@@ -121,3 +121,52 @@ describe('onboarding Picker', () => {
expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull()
})
})
describe('ApiKeyForm per-install base URL slot', () => {
const perInstall = {
id: 'freemodel2api',
name: 'FreeModel2API',
envKey: 'FREEMODEL2API_API_KEY',
baseUrlEnvKey: 'FREEMODEL2API_BASE_URL',
docsUrl: ''
}
it('requires the base URL before connecting and reports both env writes', async () => {
const onSave = vi.fn().mockResolvedValue({ ok: true })
render(<ApiKeyForm canGoBack={false} onBack={() => {}} onSave={onSave} options={[perInstall]} />)
const key = screen.getByPlaceholderText('Paste API key')
const url = screen.getByPlaceholderText('Base URL, e.g. http://127.0.0.1:8000/v1')
const connect = screen.getByRole('button', { name: 'Connect' })
fireEvent.change(key, { target: { value: 'sk-test' } })
expect(connect.hasAttribute('disabled')).toBe(true)
fireEvent.change(url, { target: { value: 'http://h:3020/v1' } })
expect(connect.hasAttribute('disabled')).toBe(false)
fireEvent.click(connect)
await vi.waitFor(() =>
expect(onSave).toHaveBeenCalledWith(
'FREEMODEL2API_API_KEY',
'sk-test',
'FreeModel2API',
undefined,
{ envKey: 'FREEMODEL2API_BASE_URL', value: 'http://h:3020/v1' }
)
)
})
it('renders no second field for plain API-key options', () => {
render(
<ApiKeyForm
canGoBack={false}
onBack={() => {}}
onSave={vi.fn().mockResolvedValue({ ok: true })}
options={[{ id: 'fireworks', name: 'Fireworks AI', envKey: 'FIREWORKS_API_KEY', docsUrl: '' }]}
/>
)
expect(screen.queryByPlaceholderText('Base URL, e.g. http://127.0.0.1:8000/v1')).toBeNull()
})
})
@@ -73,6 +73,9 @@ interface DesktopOnboardingOverlayProps {
}
export interface ApiKeyOption {
/** Second, required env slot for per-install gateways (freemodel2api): the
* provider ships no default base URL, so the form asks for it explicitly. */
baseUrlEnvKey?: string
description?: string
docsUrl: string
envKey: string
@@ -82,9 +85,16 @@ export interface ApiKeyOption {
short?: string
}
// Curated order mirrors CANONICAL_PROVIDERS: Fireworks sits #2 overall (after
// Nous Portal OAuth), ahead of OpenRouter and the rest of the key catalog.
// FreeModel2API is the default inference route. Keep it first so the direct-key setup path lands
// on the product default even before the dynamic provider catalog finishes loading.
const API_KEY_OPTIONS: ApiKeyOption[] = [
{
id: 'freemodel2api',
name: 'FreeModel2API',
envKey: 'FREEMODEL2API_API_KEY',
baseUrlEnvKey: 'FREEMODEL2API_BASE_URL',
docsUrl: ''
},
{
id: 'fireworks',
name: 'Fireworks AI',
@@ -179,6 +189,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
id: row.slug,
name: row.name,
envKey,
baseUrlEnvKey: (row.base_url_env as string | undefined) || undefined,
description: `Direct API access to ${row.name}.`,
docsUrl: ''
})
@@ -357,7 +368,10 @@ export function DesktopOnboardingOverlay({
// do we know whether to dismiss (true) or surface the picker (false).
// EXCEPTION: manual mode (user opened the selector from a working app to
// add/switch a provider) shows the overlay regardless of configured state.
if (onboarding.configured === true && !onboarding.manual && !onboarding.freeTierReady) {
// An UNCONFIGURED install no longer gets the blocking first-run wizard — the
// composer status stack shows a setup hint instead, and the overlay opens
// only on demand (startManualOnboarding) or for the free-tier intro.
if (onboarding.configured !== null && !onboarding.manual && !onboarding.freeTierReady) {
return null
}
@@ -596,7 +610,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
const [showAll, setShowAll] = useState(readShowAll)
// Which key-form option to preselect when we flip to 'apikey' mode. The
// OpenRouter row selects its key; the generic link lands on the first option.
// OpenRouter row selects its key; the generic link lands on FreeModel2API, the first option.
const [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined)
const openKeyForm = (envKey?: string) => {
@@ -619,7 +633,9 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
canGoBack={hasOauth && !localEndpoint}
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
onSave={(envKey, value, name, apiKey, extraEnv) =>
saveOnboardingApiKey(envKey, value, name, ctx, apiKey, extraEnv)
}
options={apiKeyOptions}
/>
{manual ? null : (
@@ -740,7 +756,13 @@ export function ApiKeyForm({
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string, apiKey?: string) => Promise<{ message?: string; ok: boolean }>
onSave: (
envKey: string,
value: string,
name: string,
apiKey?: string,
extraEnv?: { envKey: string; value: string }
) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
@@ -752,6 +774,9 @@ export function ApiKeyForm({
// Optional endpoint API key, only used by the local / custom endpoint option
// (whose `value` is the base URL). Cleared whenever the option changes.
const [localKey, setLocalKey] = useState('')
// Required base URL for per-install gateway options (freemodel2api) — the
// provider has no default endpoint, without it the live catalog cannot probe.
const [baseUrl, setBaseUrl] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
@@ -762,6 +787,7 @@ export function ApiKeyForm({
setOption(options[0])
setValue('')
setLocalKey('')
setBaseUrl('')
setError(null)
}
}, [option.envKey, options])
@@ -774,6 +800,7 @@ export function ApiKeyForm({
setOption(o)
setValue('')
setLocalKey('')
setBaseUrl('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -782,13 +809,16 @@ export function ApiKeyForm({
}
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const baseUrlEnvKey = option.baseUrlEnvKey
const alreadySet = isSet?.(option.envKey) ?? false
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
// placeholder so users can eyeball that the right key is in place.
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
// or unusual key can't block the user from continuing. EXCEPTION: a
// per-install gateway option also needs its base URL; without one there is
// no endpoint to probe and the provider would sit silently broken.
const canSave = value.trim().length >= 1 && (!baseUrlEnvKey || baseUrl.trim().length >= 1)
const optionCopy = t.onboarding.apiKeyOptions[option.id]
const optionDescription = optionCopy?.description ?? option.description
@@ -799,11 +829,19 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
const result = await onSave(
option.envKey,
value,
option.name,
isLocal ? localKey : undefined,
baseUrlEnvKey ? { envKey: baseUrlEnvKey, value: baseUrl } : undefined
)
if (result.ok) {
setValue('')
setLocalKey('')
setBaseUrl('')
} else {
setError(result.message ?? t.onboarding.couldNotSave)
}
@@ -871,6 +909,17 @@ export function ApiKeyForm({
value={localKey}
/>
) : null}
{baseUrlEnvKey ? (
<Input
autoComplete="off"
className="font-mono"
onChange={e => setBaseUrl(e.target.value)}
onKeyDown={e => isSubmitEnter(e) && void submit()}
placeholder={t.onboarding.baseApiUrlPlaceholder}
type="text"
value={baseUrl}
/>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>

Some files were not shown because too many files have changed in this diff Show More