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
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:
@@ -230,6 +230,17 @@ existing traffic-light and Window Controls Overlay measurements.
|
|||||||
|
|
||||||
The left cluster shows sidebar, settings, layout editor, and HUD controls. Flip
|
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.
|
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
|
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
|
status dots after 400ms, without changing tab widths. Hints follow the same
|
||||||
binding and hovered/focused-zone resolver as the number shortcuts.
|
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.
|
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
|
- A tool result may expose an inline action that opens a preview. It must not
|
||||||
open the rail automatically.
|
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
|
- Tool rows reserve destructive red for explicit failures. Missing read paths and
|
||||||
ambiguous exit-1 results use neutral notices, with details still available.
|
ambiguous exit-1 results use neutral notices, with details still available.
|
||||||
Errors described inside returned data are not tool failures. Expanded failures
|
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 = {
|
const LOGIN_OK = {
|
||||||
code: 0,
|
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 () => {
|
test('login persists credentials and returns balance; status reuses in-memory session', async () => {
|
||||||
const { handlers, ipcMain } = fakeIpcMain()
|
const { handlers, ipcMain } = fakeIpcMain()
|
||||||
const { io } = fakeIo()
|
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 })
|
registerFreeModel2ApiIpc({ ipcMain, io, fetcher })
|
||||||
|
|
||||||
const login = await handlers.get('hermes:freemodel2api:login')!(null, { baseUrl: 'https://sub.example.com/', email: 'u@example.com', password: 'pw' })
|
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)), {
|
assert.deepEqual(JSON.parse(io.decrypt(JSON.parse(io.readStoreText()).freemodel2api)), {
|
||||||
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw'
|
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw'
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = await handlers.get('hermes:freemodel2api:status')!(null)
|
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 请求
|
// status 用内存里的 session,没有第二次 login 请求
|
||||||
assert.equal(calls.filter(c => c.url.endsWith('/auth/login')).length, 1)
|
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 })
|
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 () => {
|
test('keys re-login once on 401 then succeed', async () => {
|
||||||
const { handlers, ipcMain } = fakeIpcMain()
|
const { handlers, ipcMain } = fakeIpcMain()
|
||||||
const { io } = fakeIo()
|
const { io } = fakeIo()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
persistRememberedLogin,
|
persistRememberedLogin,
|
||||||
sub2ApiFetchKeys,
|
sub2ApiFetchKeys,
|
||||||
sub2ApiFetchProfile,
|
sub2ApiFetchProfile,
|
||||||
|
sub2ApiKeyBalance,
|
||||||
sub2ApiLogin,
|
sub2ApiLogin,
|
||||||
type FetchLike,
|
type FetchLike,
|
||||||
type FreeModel2ApiStoreIo
|
type FreeModel2ApiStoreIo
|
||||||
@@ -78,7 +79,13 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void {
|
|||||||
}
|
}
|
||||||
const result = await freshLogin(baseUrl, email, password)
|
const result = await freshLogin(baseUrl, email, password)
|
||||||
persistFreeModel2ApiCredentials({ baseUrl, email, password }, io)
|
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 () => {
|
ipcMain.handle('hermes:freemodel2api:status', async () => {
|
||||||
@@ -87,7 +94,14 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void {
|
|||||||
return { configured: false }
|
return { configured: false }
|
||||||
}
|
}
|
||||||
const profile = await withSession(s => sub2ApiFetchProfile(s.baseUrl, s.accessToken, fetcher))
|
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 () => {
|
ipcMain.handle('hermes:freemodel2api:keys', async () => {
|
||||||
@@ -105,6 +119,20 @@ export function registerFreeModel2ApiIpc(deps: FreeModel2ApiIpcDeps): void {
|
|||||||
return loadRememberedLogin(io)
|
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) => {
|
ipcMain.handle('hermes:freemodel2api:remembered:save', async (_event, payload) => {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
persistRememberedLogin(null, io)
|
persistRememberedLogin(null, io)
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface Sub2ApiProfile {
|
|||||||
email: string
|
email: string
|
||||||
username: string
|
username: string
|
||||||
balance: number
|
balance: number
|
||||||
|
/** 上游没这个字段时按 0 处理 —— 面板宁可显示 $0.00,也不为它单独加一次请求 */
|
||||||
|
frozenBalance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Sub2ApiLoginResult {
|
export interface Sub2ApiLoginResult {
|
||||||
@@ -101,6 +103,8 @@ export interface Sub2ApiLoginResult {
|
|||||||
expiresAt: number
|
expiresAt: number
|
||||||
email: string
|
email: string
|
||||||
balance: number
|
balance: number
|
||||||
|
username: string
|
||||||
|
frozenBalance: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FetchLike = (
|
export type FetchLike = (
|
||||||
@@ -152,7 +156,9 @@ export async function sub2ApiLogin(baseUrl: string, email: string, password: str
|
|||||||
// 提前 60s 视为过期,边界请求不踩死线
|
// 提前 60s 视为过期,边界请求不踩死线
|
||||||
expiresAt: Date.now() + expiresIn * 1000 - 60_000,
|
expiresAt: Date.now() + expiresIn * 1000 - 60_000,
|
||||||
email: String(data?.user?.email ?? email),
|
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 {
|
return {
|
||||||
email: String(data?.email ?? ''),
|
email: String(data?.email ?? ''),
|
||||||
username: String(data?.username ?? ''),
|
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[]> {
|
export async function sub2ApiFetchKeys(baseUrl: string, token: string, fetcher: FetchLike): Promise<Sub2ApiKey[]> {
|
||||||
const data = await callSub2Api(baseUrl, '/api/v1/keys', { token }, fetcher)
|
const data = await callSub2Api(baseUrl, '/api/v1/keys', { token }, fetcher)
|
||||||
const items = Array.isArray(data?.items) ? data.items : []
|
const items = Array.isArray(data?.items) ? data.items : []
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
keys: () => ipcRenderer.invoke('hermes:freemodel2api:keys'),
|
keys: () => ipcRenderer.invoke('hermes:freemodel2api:keys'),
|
||||||
logout: () => ipcRenderer.invoke('hermes:freemodel2api:logout'),
|
logout: () => ipcRenderer.invoke('hermes:freemodel2api:logout'),
|
||||||
rememberedLoad: () => ipcRenderer.invoke('hermes:freemodel2api:remembered:load'),
|
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/状态。
|
// Hermes 用户账号(§21):token 留在 main 进程,renderer 只见 profile/状态。
|
||||||
userAccount: {
|
userAccount: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { test } from 'vitest'
|
|||||||
|
|
||||||
import type { FetchLike } from './relay-account'
|
import type { FetchLike } from './relay-account'
|
||||||
import { registerUserAccountIpc } from './user-account-ipc'
|
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() {
|
function fakeIo() {
|
||||||
let fileText: string | null = null
|
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'])
|
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 { handlers, ipcMain } = fakeIpcMain()
|
||||||
const { io } = fakeIo()
|
const { io } = fakeIo()
|
||||||
let meCalls = 0
|
let meCalls = 0
|
||||||
@@ -167,8 +167,159 @@ test('refresh 也被拒 = 硬会话终点:本地清零并上报 sessionExpired
|
|||||||
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
|
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:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
|
||||||
const status = await handlers.get('hermes:account:status')!(null)
|
const status = await handlers.get('hermes:account:status')!(null)
|
||||||
|
// 在线行为不变(renderer 照旧落登录页)……
|
||||||
assert.deepEqual(status, { loggedIn: false, sessionExpired: true })
|
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)
|
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 () => {
|
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' })
|
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 () => {
|
test('register-start 失败码透传(weak_password / registration_rate_limited)', async () => {
|
||||||
const { handlers, ipcMain } = fakeIpcMain()
|
const { handlers, ipcMain } = fakeIpcMain()
|
||||||
const { io } = fakeIo()
|
const { io } = fakeIo()
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
|
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
|
||||||
*
|
*
|
||||||
* 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试;
|
* 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试;
|
||||||
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 唯一的
|
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 服务端判定的
|
||||||
* 硬会话终点(U-3)→ 本地清零并上报 sessionExpired,由 renderer 落登录页。
|
* 硬终点(U-3):**不清本地会话**,只上报 sessionExpired——在线时 renderer 照旧
|
||||||
|
* 落登录页,离线时本地会话仍放行(离线优先,2026-09-24 用户裁定)。真正的清零
|
||||||
|
* 只发生在 logout / unbind。
|
||||||
|
*
|
||||||
|
* 离线登录(同一裁定):断网时 login 回落到本地校验记住的凭据,把已在盘上的
|
||||||
|
* 会话解锁回登录态。它只是"别砖在门口"的临时通路,不是第二套运行模式——
|
||||||
|
* 不新建会话,也不放行登录以外的任何操作。
|
||||||
*
|
*
|
||||||
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
|
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
|
||||||
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
|
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
|
||||||
@@ -111,7 +117,11 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
|||||||
persistUserAccount(account, io)
|
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> {
|
async function withSession<T>(fn: (session: RelayAccountSession) => Promise<T>): Promise<T> {
|
||||||
const account = current()
|
const account = current()
|
||||||
|
|
||||||
@@ -129,7 +139,6 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
|||||||
refreshed = await relayAccountRefresh(account.session, fetcher)
|
refreshed = await relayAccountRefresh(account.session, fetcher)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RelayAccountError && error.status === 401) {
|
if (error instanceof RelayAccountError && error.status === 401) {
|
||||||
store(null)
|
|
||||||
throw new RelayAccountError('session_expired', 401)
|
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 }
|
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) => {
|
ipcMain.handle('hermes:account:login', async (_event, payload) => {
|
||||||
let site: string
|
let site: string
|
||||||
|
|
||||||
@@ -209,6 +262,10 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
|||||||
|
|
||||||
return { ok: true, site, profile }
|
return { ok: true, site, profile }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof RelayAccountError && error.code === 'network') {
|
||||||
|
return offlineLogin(site, email, password)
|
||||||
|
}
|
||||||
|
|
||||||
return failure(error)
|
return failure(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -509,13 +566,20 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
|||||||
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
|
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
|
||||||
try {
|
try {
|
||||||
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
|
||||||
|
const email = String(payload?.email ?? '').trim()
|
||||||
await relayAccountResetConfirm(
|
await relayAccountResetConfirm(
|
||||||
site,
|
site,
|
||||||
String(payload?.email ?? '').trim(),
|
email,
|
||||||
String(payload?.code ?? '').trim(),
|
String(payload?.code ?? '').trim(),
|
||||||
String(payload?.password ?? ''),
|
String(payload?.password ?? ''),
|
||||||
fetcher
|
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 }
|
return { ok: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -525,7 +589,7 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。
|
* 登录表单预填:返回最近一次成功登录记住的 {site, email, password}。
|
||||||
* 写入只发生在 login 成功(自动)与 unbind 全清(抹除)——renderer 无写口。
|
* login 成功写入;reset 成功及 unbind 清除旧凭据——renderer 无写口。
|
||||||
*/
|
*/
|
||||||
ipcMain.handle('hermes:account:remembered:load', async () => {
|
ipcMain.handle('hermes:account:remembered:load', async () => {
|
||||||
return loadRememberedLogin(io)
|
return loadRememberedLogin(io)
|
||||||
|
|||||||
@@ -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: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: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: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 .",
|
"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 electron --inspect=9229 .",
|
"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 electron .",
|
"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 && electron .",
|
"start": "npm run build && node scripts/launch-dev-electron.mjs .",
|
||||||
"prebuild": "node scripts/assert-root-install.mjs && npm run clean",
|
"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",
|
"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",
|
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"@lezer/highlight": "1.2.3",
|
"@lezer/highlight": "1.2.3",
|
||||||
"@nanostores/react": "1.1.0",
|
"@nanostores/react": "1.1.0",
|
||||||
"@nous-research/ui": "0.18.2",
|
"@nous-research/ui": "0.18.2",
|
||||||
|
"@silurus/ooxml": "0.88.0",
|
||||||
"@streamdown/code": "1.1.1",
|
"@streamdown/code": "1.1.1",
|
||||||
"@streamdown/math": "1.0.2",
|
"@streamdown/math": "1.0.2",
|
||||||
"@tabler/icons-react": "3.44.0",
|
"@tabler/icons-react": "3.44.0",
|
||||||
@@ -236,7 +237,7 @@
|
|||||||
"entitlements": "electron/entitlements.mac.plist",
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"CFBundleDisplayName": "Digital Assistant",
|
"CFBundleDisplayName": "DeepWork",
|
||||||
"CFBundleExecutable": "Hermes",
|
"CFBundleExecutable": "Hermes",
|
||||||
"CFBundleName": "Hermes",
|
"CFBundleName": "Hermes",
|
||||||
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
|
"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
|
// macOS reads the Dock label from the launched .app bundle. Patching the stock
|
||||||
// CFBundleDisplayName is "Electron" — so hovering the Dock icon says "Electron"
|
// Electron.app plist alone still leaves the running tile named "Electron", so
|
||||||
// no matter what app.setName() does (macOS reads the bundle, not the runtime
|
// launch a branded copy while keeping node_modules/electron's original intact.
|
||||||
// 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.
|
|
||||||
import { execFileSync } from 'node:child_process'
|
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 path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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') {
|
function plistValue(plist, key) {
|
||||||
const desktopRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
return execFileSync(BUDDY, ['-c', `Print :${key}`, plist], { encoding: 'utf8' }).trim()
|
||||||
const plist = path.join(desktopRoot, 'node_modules/electron/dist/Electron.app/Contents/Info.plist')
|
}
|
||||||
|
|
||||||
if (existsSync(plist)) {
|
function setPlistValue(plist, key, value) {
|
||||||
const buddy = '/usr/libexec/PlistBuddy'
|
execFileSync(BUDDY, ['-c', `Set :${key} ${value}`, plist], { stdio: 'ignore' })
|
||||||
const read = key => {
|
}
|
||||||
try {
|
|
||||||
return execFileSync(buddy, ['-c', `Print :${key}`, plist], { encoding: 'utf8' }).trim()
|
export function prepareBrandedElectron({
|
||||||
} catch {
|
electronDist = path.join(DESKTOP_ROOT, 'node_modules/electron/dist'),
|
||||||
return ''
|
cacheRoot = path.join(DESKTOP_ROOT, 'node_modules/.cache/hermes-desktop')
|
||||||
}
|
} = {}) {
|
||||||
}
|
const source = path.join(electronDist, 'Electron.app')
|
||||||
const write = (key, value) =>
|
const sourcePlist = path.join(source, 'Contents/Info.plist')
|
||||||
execFileSync(buddy, ['-c', `Set :${key} ${value}`, plist], { stdio: 'ignore' })
|
const version = plistValue(sourcePlist, 'CFBundleShortVersionString')
|
||||||
|
const versionRoot = path.join(cacheRoot, version)
|
||||||
for (const key of ['CFBundleName', 'CFBundleDisplayName']) {
|
const branded = path.join(versionRoot, `${DISPLAY_NAME}.app`)
|
||||||
if (read(key) !== DISPLAY_NAME) {
|
const binary = path.join(branded, 'Contents/MacOS/Electron')
|
||||||
write(key, DISPLAY_NAME)
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@ export interface FreeModel2ApiStatus {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
email?: string
|
email?: string
|
||||||
balance?: number
|
balance?: number
|
||||||
|
username?: string
|
||||||
|
frozenBalance?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreeModel2ApiKey {
|
export interface FreeModel2ApiKey {
|
||||||
@@ -45,3 +47,8 @@ export function fm2RememberedLoad(): Promise<FreeModel2ApiRememberedLogin | null
|
|||||||
export function fm2RememberedSave(payload: FreeModel2ApiRememberedLogin | null) {
|
export function fm2RememberedSave(payload: FreeModel2ApiRememberedLogin | null) {
|
||||||
return window.hermesDesktop.freemodel2api.rememberedSave(payload)
|
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)
|
expect(lightboxImage?.getAttribute('src')).toBe(DATA_URL)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(lightboxImage!)
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
{lightboxSrc && (
|
{lightboxSrc && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
alt={attachment.label}
|
alt={attachment.label}
|
||||||
copy={t.desktop}
|
|
||||||
onClick={download}
|
onClick={download}
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
setLightboxOpen(open)
|
setLightboxOpen(open)
|
||||||
|
|||||||
@@ -10,22 +10,18 @@ import type { TriggerState } from './text-utils'
|
|||||||
|
|
||||||
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
export const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||||
|
|
||||||
// Above the stack breakpoint but still cramped: the model pill sheds its label
|
// Above the tight-control breakpoint but still cramped: the model pill sheds
|
||||||
// for its chevron icon so the controls stop crowding the input before the whole
|
// its label for its chevron icon so the controls keep room for the input rail.
|
||||||
// row has to stack. Progressive collapse: full pill → icon pill → stacked.
|
// Progressive collapse: full pill → icon pill → compact controls.
|
||||||
//
|
//
|
||||||
// Sized off what the controls actually cost, because guessing put the two
|
// 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
|
// 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
|
// (pill 111 + the icon cluster), so the label drops at 560px and the ~110px the
|
||||||
// — barely over its 128px minimum. A few words wrapped, wrapping is what
|
// chevron frees is available to the input rail.
|
||||||
// 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.
|
|
||||||
export const COMPOSER_COMPACT_PILL_PX = 560
|
export const COMPOSER_COMPACT_PILL_PX = 560
|
||||||
|
|
||||||
// The ladder keeps going below the stack breakpoint — a pane can be far
|
// The ladder keeps going below the tight-control breakpoint — a pane can be far
|
||||||
// narrower than even the stacked controls row. Both rungs are budgeted
|
// narrower than even the controls row. Both rungs are budgeted
|
||||||
// against that row's real cost: menu ~24 + surface padding 16 + the cluster
|
// against that row's real cost: menu ~24 + surface padding 16 + the cluster
|
||||||
// (~190; ~218 mid-turn with the queue button).
|
// (~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).
|
// spare at any width the layout tree allows (~74 all-in).
|
||||||
export const COMPOSER_MINIMAL_PX = 180
|
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 =
|
export const COMPOSER_FADE_BACKGROUND =
|
||||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
'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'
|
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
|
// The shared ResizeObserver delivers once per observed element after
|
||||||
// observe(); replay that by hand so the hook's initial measurement runs where
|
// observe(); replay that by hand so the hook's initial measurement runs where
|
||||||
// it would in Chromium.
|
// it would in Chromium.
|
||||||
@@ -60,16 +56,15 @@ function Harness({ dockHeight, surfaceHeight }: { dockHeight: number; surfaceHei
|
|||||||
const composerDockRef = useRef<HTMLDivElement | null>(null)
|
const composerDockRef = useRef<HTMLDivElement | null>(null)
|
||||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||||
const composerSurfaceRef = useRef<HTMLDivElement | 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 (
|
return (
|
||||||
<div data-chat-surface="">
|
<div data-chat-surface="">
|
||||||
<div ref={sized(composerDockRef, dockHeight)}>
|
<div ref={sized(composerDockRef, dockHeight)}>
|
||||||
<form ref={composerRef}>
|
<form ref={composerRef}>
|
||||||
<div ref={sized(composerSurfaceRef, surfaceHeight)}>
|
<div ref={sized(composerSurfaceRef, surfaceHeight)}>
|
||||||
<div ref={editorRef} />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useAuiState } from '@assistant-ui/react'
|
|
||||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
COMPOSER_COMPACT_PILL_PX,
|
COMPOSER_COMPACT_PILL_PX,
|
||||||
COMPOSER_FOLD_VOICE_PX,
|
COMPOSER_FOLD_VOICE_PX,
|
||||||
COMPOSER_MINIMAL_PX,
|
COMPOSER_MINIMAL_PX,
|
||||||
COMPOSER_SINGLE_LINE_MAX_PX,
|
|
||||||
COMPOSER_STACK_BREAKPOINT_PX
|
COMPOSER_STACK_BREAKPOINT_PX
|
||||||
} from '../composer-utils'
|
} from '../composer-utils'
|
||||||
|
|
||||||
@@ -22,7 +20,6 @@ interface UseComposerMetricsArgs {
|
|||||||
composerDockRef: RefObject<HTMLDivElement | null>
|
composerDockRef: RefObject<HTMLDivElement | null>
|
||||||
composerRef: RefObject<HTMLFormElement | null>
|
composerRef: RefObject<HTMLFormElement | null>
|
||||||
composerSurfaceRef: RefObject<HTMLDivElement | null>
|
composerSurfaceRef: RefObject<HTMLDivElement | null>
|
||||||
editorRef: RefObject<HTMLDivElement | null>
|
|
||||||
poppedOut: boolean
|
poppedOut: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,59 +43,25 @@ const fitForWidth = (width: number): ComposerFit => ({
|
|||||||
const sameFit = (a: ComposerFit, b: ComposerFit) =>
|
const sameFit = (a: ComposerFit, b: ComposerFit) =>
|
||||||
a.compactPill === b.compactPill && a.foldVoice === b.foldVoice && a.minimal === b.minimal && a.tight === b.tight
|
a.compactPill === b.compactPill && a.foldVoice === b.foldVoice && a.minimal === b.minimal && a.tight === b.tight
|
||||||
|
|
||||||
interface UseComposerMetricsResult extends ComposerFit {
|
type UseComposerMetricsResult = ComposerFit
|
||||||
stacked: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the composer's *sizing* engine: the stacked-vs-inline layout decision
|
* Owns the composer's sizing engine: width-driven control collapse and the
|
||||||
* and the measured-height CSS vars the thread reads for bottom clearance. All
|
* measured-height CSS vars the thread reads for bottom clearance. The input
|
||||||
* work is edge-gated — the ResizeObserver only fires on real size changes, the
|
* and controls are always separate rows, so this hook only resolves control
|
||||||
* height vars are 8px-bucketed so per-keystroke growth never invalidates the
|
* density. All work is edge-gated — the ResizeObserver only fires on real size
|
||||||
* tree's computed style, and the fit only re-renders when it crosses a stage.
|
* 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({
|
export function useComposerMetrics({
|
||||||
composerDockRef,
|
composerDockRef,
|
||||||
composerRef,
|
composerRef,
|
||||||
composerSurfaceRef,
|
composerSurfaceRef,
|
||||||
editorRef,
|
|
||||||
poppedOut
|
poppedOut
|
||||||
}: UseComposerMetricsArgs): UseComposerMetricsResult {
|
}: UseComposerMetricsArgs): UseComposerMetricsResult {
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [fit, setFit] = useState<ComposerFit>(ROOMY)
|
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
|
// Bucket measured heights so we only invalidate the global CSS var when
|
||||||
// the size crosses a meaningful threshold. Without bucketing, the editor
|
// the size crosses a meaningful threshold. Without bucketing, the editor
|
||||||
// grows ~1px per character → setProperty fires every keystroke → entire
|
// 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) {
|
if (height > 0) {
|
||||||
const bucket = Math.round(height / 8) * 8
|
const bucket = Math.round(height / 8) * 8
|
||||||
|
|
||||||
@@ -181,9 +132,9 @@ export function useComposerMetrics({
|
|||||||
setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`)
|
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.
|
// Toggling pop-out changes whether the composer reserves thread clearance.
|
||||||
// The ResizeObserver may not fire (the box can keep the same box size), so
|
// The ResizeObserver may not fire (the box can keep the same box size), so
|
||||||
@@ -217,23 +168,12 @@ export function useComposerMetrics({
|
|||||||
}, [composerRef])
|
}, [composerRef])
|
||||||
|
|
||||||
// Every decision comes from the composer's OWN measured width, never the
|
// 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
|
// viewport's. The input/control split is fixed; the ladder only reduces
|
||||||
// well, and it quietly outranked everything: any window under 480px stacked
|
// control density as the available width shrinks.
|
||||||
// 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.
|
|
||||||
return {
|
return {
|
||||||
compactPill: fit.compactPill || fit.tight,
|
compactPill: fit.compactPill || fit.tight,
|
||||||
foldVoice: fit.foldVoice || fit.minimal,
|
foldVoice: fit.foldVoice || fit.minimal,
|
||||||
minimal: fit.minimal,
|
minimal: fit.minimal,
|
||||||
stacked: expanded || fit.tight,
|
|
||||||
tight: fit.tight
|
tight: fit.tight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,11 +360,10 @@ export function ChatBar({
|
|||||||
return onCancel()
|
return onCancel()
|
||||||
}, [activeQueueSessionKeyRef, onCancel])
|
}, [activeQueueSessionKeyRef, onCancel])
|
||||||
|
|
||||||
const { compactPill, foldVoice, minimal, stacked } = useComposerMetrics({
|
const { compactPill, foldVoice, minimal } = useComposerMetrics({
|
||||||
composerDockRef,
|
composerDockRef,
|
||||||
composerRef,
|
composerRef,
|
||||||
composerSurfaceRef,
|
composerSurfaceRef,
|
||||||
editorRef,
|
|
||||||
poppedOut
|
poppedOut
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1108,17 +1107,16 @@ export function ChatBar({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const input = (
|
const input = (
|
||||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
<div className="relative w-full">
|
||||||
<div
|
<div
|
||||||
aria-disabled={inputDisabled ? true : undefined}
|
aria-disabled={inputDisabled ? true : undefined}
|
||||||
aria-label={t.composer.message}
|
aria-label={t.composer.message}
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
className={cn(
|
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',
|
'**:data-ref-text:cursor-default',
|
||||||
stacked && 'pl-3',
|
'w-full pl-3',
|
||||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1',
|
|
||||||
// Inside the native Wayland HUD drag region: a drag region swallows
|
// 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
|
// the page's mouse input whole, so the input must opt back out or it
|
||||||
// becomes unclickable. Buttons use the global no-drag rule.
|
// becomes unclickable. Buttons use the global no-drag rule.
|
||||||
@@ -1450,12 +1448,7 @@ export function ChatBar({
|
|||||||
)}
|
)}
|
||||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className='grid w-full grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
||||||
'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"]'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
<div className="flex translate-y-[3px] items-start gap-(--composer-control-gap) self-start [grid-area:menu]">
|
||||||
{contextMenu}
|
{contextMenu}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/u
|
|||||||
import { BillingBanner } from '@/components/billing-banner'
|
import { BillingBanner } from '@/components/billing-banner'
|
||||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||||
import { StatusSection } from '@/components/chat/status-section'
|
import { StatusSection } from '@/components/chat/status-section'
|
||||||
|
import { StatusRow } from '@/components/chat/status-row'
|
||||||
import { FreeTierNoticeStrip, useFreeTierNoticeOwner } from '@/components/free-tier/notice-strip'
|
import { FreeTierNoticeStrip, useFreeTierNoticeOwner } from '@/components/free-tier/notice-strip'
|
||||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
stopBackgroundProcess
|
stopBackgroundProcess
|
||||||
} from '@/store/composer-status'
|
} from '@/store/composer-status'
|
||||||
import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
|
import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
|
||||||
|
import { $desktopOnboarding, startManualOnboarding } from '@/store/onboarding'
|
||||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||||
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
|
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
|
||||||
import { $threadScrolledUpBySession } from '@/store/thread-scroll'
|
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.
|
// notice once — and a non-owning stack adds no empty row to its card.
|
||||||
const ownsFreeTierNotice = useFreeTierNoticeOwner()
|
const ownsFreeTierNotice = useFreeTierNoticeOwner()
|
||||||
const freeTierNotice = ownsFreeTierNotice && freeTierStripPending(freeTierStatus, freeTierRoute)
|
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'
|
const isStructuredSupported = controlEntry?.capability === 'supported'
|
||||||
|
|
||||||
@@ -205,6 +213,10 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
|
|||||||
sections.push({ key: 'free-tier', node: <FreeTierNoticeStrip /> })
|
sections.push({ key: 'free-tier', node: <FreeTierNoticeStrip /> })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setupPending) {
|
||||||
|
sections.push({ key: 'setup-pending', node: <SetupPendingStrip /> })
|
||||||
|
}
|
||||||
|
|
||||||
const hasControlContent = Boolean(
|
const hasControlContent = Boolean(
|
||||||
controlEntry &&
|
controlEntry &&
|
||||||
(controlEntry.error ||
|
(controlEntry.error ||
|
||||||
@@ -336,3 +348,29 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -739,7 +739,6 @@ const ChatViewContent = memo(function ChatViewContent({
|
|||||||
data-composer-target={composerScope.target}
|
data-composer-target={composerScope.target}
|
||||||
data-session-anchor={sessionAnchor}
|
data-session-anchor={sessionAnchor}
|
||||||
>
|
>
|
||||||
<Backdrop />
|
|
||||||
{/* Tiles get their chrome from the layout zone (chip strip); the modal
|
{/* Tiles get their chrome from the layout zone (chip strip); the modal
|
||||||
prompt overlays stay active-session-scoped in the primary surface. */}
|
prompt overlays stay active-session-scoped in the primary surface. */}
|
||||||
{isPrimary && (
|
{isPrimary && (
|
||||||
@@ -751,7 +750,7 @@ const ChatViewContent = memo(function ChatViewContent({
|
|||||||
selectedSessionId={selectedSessionId}
|
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
|
{/* Mounted for the primary AND every tile, each scoped to its own session
|
||||||
so a tiled/background session's blocking prompt surfaces instead of
|
so a tiled/background session's blocking prompt surfaces instead of
|
||||||
@@ -771,6 +770,9 @@ const ChatViewContent = memo(function ChatViewContent({
|
|||||||
data-slot="composer-bounds"
|
data-slot="composer-bounds"
|
||||||
{...dropHandlers}
|
{...dropHandlers}
|
||||||
>
|
>
|
||||||
|
{/* Keep the optional image texture inside the conversation body. The
|
||||||
|
title/workspace chrome should remain a clean, continuous band. */}
|
||||||
|
<Backdrop />
|
||||||
<Thread
|
<Thread
|
||||||
clampToComposer={showChatBar}
|
clampToComposer={showChatBar}
|
||||||
cwd={currentCwd}
|
cwd={currentCwd}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export interface PaneMirror<T> {
|
|||||||
dir?: (tile: T) => TileDock | undefined
|
dir?: (tile: T) => TileDock | undefined
|
||||||
/** Pane to dock against (default `workspace`) — a drop's target zone. */
|
/** Pane to dock against (default `workspace`) — a drop's target zone. */
|
||||||
anchor?: (tile: T) => string | undefined
|
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). */
|
/** Center docks: the strip slot (stack before this pane id). */
|
||||||
before?: (tile: T) => null | string | undefined
|
before?: (tile: T) => null | string | undefined
|
||||||
minWidth: string
|
minWidth: string
|
||||||
@@ -89,6 +93,8 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
|
|||||||
},
|
},
|
||||||
minWidth: cfg.minWidth,
|
minWidth: cfg.minWidth,
|
||||||
newTab: cfg.newTab?.(key),
|
newTab: cfg.newTab?.(key),
|
||||||
|
headerSurface: cfg.headerSurface,
|
||||||
|
titlebar: cfg.titlebar,
|
||||||
// Every mirrored tile is a full workspace surface docked beside main —
|
// 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
|
// and closeable, which is what keeps its tab when it lands in a zone of
|
||||||
// its own (see strip-visibility.ts).
|
// its own (see strip-visibility.ts).
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ vi.mock('./right-rail/preview-console-store', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { registry } from '@/contrib/registry'
|
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 { $previewTabs, closeRightRail, noteBrowserPage, openPreview } from '@/store/preview'
|
||||||
|
|
||||||
import { browserTabExternalUrl, browserTabLabel, watchPreviewTiles } from './preview-tile'
|
import { browserTabExternalUrl, browserTabLabel, watchPreviewTiles } from './preview-tile'
|
||||||
@@ -19,6 +21,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
closeRightRail()
|
closeRightRail()
|
||||||
|
$layoutTree.set(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('browserTabLabel', () => {
|
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
|
({ kind: 'file', label: path.split('/').at(-1) ?? path, path, source: path, url: path }) as const
|
||||||
|
|
||||||
describe('preview tiles stack, not split (#93610)', () => {
|
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', () => {
|
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')
|
openPreview(fileTarget('/tmp/a.ts'), 'file-browser')
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,20 @@
|
|||||||
|
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
|
||||||
import { findGroup } from '@/components/pane-shell/tree/model'
|
import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||||
import { $activeTreeGroup, $layoutTree, revealTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
|
import {
|
||||||
|
$activeTreeGroup,
|
||||||
|
$layoutTree,
|
||||||
|
moveTreePaneBeside,
|
||||||
|
revealTreePane,
|
||||||
|
treePanesWithPrefix
|
||||||
|
} from '@/components/pane-shell/tree/store'
|
||||||
import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||||
import { FileTypeIcon } from '@/components/ui/file-type-icon'
|
import { FileTypeIcon } from '@/components/ui/file-type-icon'
|
||||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||||
import { translateNow } from '@/i18n'
|
import { translateNow } from '@/i18n'
|
||||||
import { openExternalLink } from '@/lib/external-link'
|
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 {
|
import {
|
||||||
$browserPages,
|
$browserPages,
|
||||||
$dockedPreviewTabs,
|
$dockedPreviewTabs,
|
||||||
@@ -64,31 +70,48 @@ export function browserTabExternalUrl(tabId: string): null | string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function browserTabMenuPrefix(tabId: string) {
|
function browserTabMenuPrefix(tabId: string) {
|
||||||
if (targetFor(tabId)?.kind !== 'url') {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (kit: MenuKit) => (
|
return (kit: MenuKit) => (
|
||||||
<>
|
<>
|
||||||
{canOpenBrowserWindow()
|
|
||||||
? renderActionItem(kit, {
|
|
||||||
icon: 'empty-window',
|
|
||||||
key: 'pop-out',
|
|
||||||
label: translateNow('preview.popOut'),
|
|
||||||
onSelect: () => popOutBrowserTab(tabId)
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
{renderActionItem(kit, {
|
{renderActionItem(kit, {
|
||||||
disabled: !browserTabExternalUrl(tabId),
|
icon: 'layout-sidebar-right',
|
||||||
icon: 'link-external',
|
key: 'move-to-workspace',
|
||||||
key: 'open-external',
|
label: translateNow('preview.moveToWorkspace'),
|
||||||
label: translateNow('preview.openInExternal'),
|
onSelect: () => movePreviewToWorkspace(tabId)
|
||||||
onSelect: () => openExternalLink(browserTabExternalUrl(tabId) ?? '')
|
|
||||||
})}
|
})}
|
||||||
|
{targetFor(tabId)?.kind === 'url' ? <kit.Separator /> : null}
|
||||||
|
{targetFor(tabId)?.kind === 'url' ? (
|
||||||
|
<>
|
||||||
|
{canOpenBrowserWindow()
|
||||||
|
? renderActionItem(kit, {
|
||||||
|
icon: 'empty-window',
|
||||||
|
key: 'pop-out',
|
||||||
|
label: translateNow('preview.popOut'),
|
||||||
|
onSelect: () => popOutBrowserTab(tabId)
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
{renderActionItem(kit, {
|
||||||
|
disabled: !browserTabExternalUrl(tabId),
|
||||||
|
icon: 'link-external',
|
||||||
|
key: 'open-external',
|
||||||
|
label: translateNow('preview.openInExternal'),
|
||||||
|
onSelect: () => openExternalLink(browserTabExternalUrl(tabId) ?? '')
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a preview tab into a split beside the main Workspace pane. */
|
||||||
|
export function movePreviewToWorkspace(tabId: string) {
|
||||||
|
if (!targetFor(tabId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moveTreePaneBeside(previewPaneId(tabId), 'workspace')
|
||||||
|
selectRightRailTab(tabId as RightRailTabId)
|
||||||
|
}
|
||||||
|
|
||||||
/** Tab title. A URL tab is titled by the CONTRIBUTION as the surface — see
|
/** 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
|
* `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
|
* 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}`
|
const previewPaneId = (tabId: string) => `${PREVIEW_TILE_PREFIX}:${tabId}`
|
||||||
|
|
||||||
/** The pane a NEW preview tile should stack into: another preview tile already
|
/** The pane a NEW preview tile should stack into. When the working-directory
|
||||||
* in the tree, else another open tab adopted earlier in the same pass (a
|
* pane is present, it owns the shared Workspace-tools tab group: the tree and
|
||||||
* reload restores every tab at once, before any of them is in the tree).
|
* every preview are tabs in one place. Layouts without a files pane retain
|
||||||
* `undefined` means this is the first preview — it opens its own zone. */
|
* the old preview-only zone as a fallback. */
|
||||||
function existingPreviewAnchor(tabId: string): string | undefined {
|
function existingPreviewAnchor(tabId: string): string | undefined {
|
||||||
const own = previewPaneId(tabId)
|
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)
|
const inTree = treePanesWithPrefix(`${PREVIEW_TILE_PREFIX}:`).find(id => id !== own)
|
||||||
|
|
||||||
if (inTree) {
|
if (inTree) {
|
||||||
@@ -251,13 +281,13 @@ const watchPreviewTileMirror = paneMirror<{ id: string }>({
|
|||||||
// `openPreview` ran and the click looked like a no-op.
|
// `openPreview` ran and the click looked like a no-op.
|
||||||
key: tab => tab.id,
|
key: tab => tab.id,
|
||||||
prefix: PREVIEW_TILE_PREFIX,
|
prefix: PREVIEW_TILE_PREFIX,
|
||||||
// The FIRST preview still opens its own zone docked beside main (identical
|
// With a working-directory pane, the FIRST preview joins its tab group and
|
||||||
// to route tiles — NOT anchored to the file tree, so ⌘J can't take it
|
// every later preview stacks there too. Layouts without `files` retain the
|
||||||
// along). Every SUBSEQUENT preview stacks into that zone as a center tab:
|
// preview-only fallback zone; without an anchor each opened file would split
|
||||||
// without the anchor each opened file split a new zone off the right edge
|
// a new zone off the right edge (#93610).
|
||||||
// (#93610), turning three file opens into three ever-narrower columns.
|
|
||||||
dir: tab => (existingPreviewAnchor(tab.id) ? 'center' : 'right'),
|
dir: tab => (existingPreviewAnchor(tab.id) ? 'center' : 'right'),
|
||||||
anchor: tab => existingPreviewAnchor(tab.id),
|
anchor: tab => existingPreviewAnchor(tab.id),
|
||||||
|
titlebar: 'content',
|
||||||
minWidth: '22rem',
|
minWidth: '22rem',
|
||||||
title: previewTitle,
|
title: previewTitle,
|
||||||
tabLead: tabId => <PreviewTabLead tabId={tabId} />,
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type * as OfficeFiles from '@/lib/office-files'
|
||||||
|
|
||||||
const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() }))
|
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'
|
import { LocalFilePreview, MarkdownPreview } from './preview-file'
|
||||||
|
|
||||||
@@ -60,13 +86,15 @@ const binaryTarget = {
|
|||||||
binary: true,
|
binary: true,
|
||||||
byteSize: 8_000,
|
byteSize: 8_000,
|
||||||
kind: 'file' as const,
|
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',
|
language: 'text',
|
||||||
large: false,
|
large: false,
|
||||||
path: '/w/report.docx',
|
path: '/w/firmware.bin',
|
||||||
previewKind: 'binary' as const,
|
previewKind: 'binary' as const,
|
||||||
source: '/w/report.docx',
|
source: '/w/firmware.bin',
|
||||||
url: 'file:///w/report.docx'
|
url: 'file:///w/firmware.bin'
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LocalFilePreview binary refusal', () => {
|
describe('LocalFilePreview binary refusal', () => {
|
||||||
@@ -87,7 +115,7 @@ describe('LocalFilePreview binary refusal', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull()
|
expect(screen.getByRole('button', { name: 'Preview anyway' })).not.toBeNull()
|
||||||
|
|
||||||
fireEvent.click(primary)
|
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', () => {
|
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()
|
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,
|
MouseEvent as ReactMouseEvent,
|
||||||
ReactNode
|
ReactNode
|
||||||
} from 'react'
|
} 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 { Streamdown } from 'streamdown'
|
||||||
|
|
||||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
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 { isComposerChord } from '@/lib/keybinds/chords'
|
||||||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||||||
import { normalizeFilePreviewMath } from '@/lib/markdown-preprocess'
|
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 { cn } from '@/lib/utils'
|
||||||
import type { PreviewTarget } from '@/store/preview'
|
import type { PreviewTarget } from '@/store/preview'
|
||||||
import { setPreviewDirty } from '@/store/preview-edit'
|
import { setPreviewDirty } from '@/store/preview-edit'
|
||||||
import { $connection, $currentCwd } from '@/store/session'
|
import { $connection, $currentCwd } from '@/store/session'
|
||||||
import { notifyWorkspaceChanged } from '@/store/workspace-events'
|
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 SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||||
const SOURCE_CHUNK_LINES = 200
|
const SOURCE_CHUNK_LINES = 200
|
||||||
@@ -159,6 +162,8 @@ interface LocalPreviewState {
|
|||||||
error?: string
|
error?: string
|
||||||
language?: string
|
language?: string
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
/** Raw bytes for the inline Word/PowerPoint viewer (see office-preview.tsx). */
|
||||||
|
officeBytes?: ArrayBuffer
|
||||||
text?: string
|
text?: string
|
||||||
truncated?: boolean
|
truncated?: boolean
|
||||||
}
|
}
|
||||||
@@ -668,6 +673,12 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
const [forcePreview, setForcePreview] = useState(false)
|
const [forcePreview, setForcePreview] = useState(false)
|
||||||
const [pdfError, setPdfError] = useState<string>()
|
const [pdfError, setPdfError] = useState<string>()
|
||||||
const [pdfUrl, setPdfUrl] = 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,
|
// User-picked view; null = auto (diff when changed, else rendered markdown,
|
||||||
// else source). Reset when the previewed file changes.
|
// else source). Reset when the previewed file changes.
|
||||||
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
|
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
|
||||||
@@ -694,6 +705,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
const filePath = filePathForTarget(target)
|
const filePath = filePathForTarget(target)
|
||||||
const isImage = target.previewKind === 'image'
|
const isImage = target.previewKind === 'image'
|
||||||
const isPdf = target.previewKind === 'pdf'
|
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)
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -703,6 +717,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
setSaving(false)
|
setSaving(false)
|
||||||
setSaveError(null)
|
setSaveError(null)
|
||||||
setConflict(false)
|
setConflict(false)
|
||||||
|
setOfficeFailed(false)
|
||||||
draftRef.current = ''
|
draftRef.current = ''
|
||||||
baselineRef.current = ''
|
baselineRef.current = ''
|
||||||
}, [filePath, reloadKey])
|
}, [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.
|
// when the file is forcibly previewed past the binary refusal screen.
|
||||||
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -724,7 +739,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isImage && !isPdf && !isText) {
|
if (!isImage && !isPdf && !isText && !officeFormat) {
|
||||||
setState({ loading: false })
|
setState({ loading: false })
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -733,13 +748,18 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
setState({ loading: true })
|
setState({ loading: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isImage || isPdf) {
|
if (isImage || isPdf || officeFormat) {
|
||||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||||
|
|
||||||
if (active) {
|
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
|
return
|
||||||
@@ -777,10 +797,17 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (active) {
|
if (active) {
|
||||||
setState({
|
// An unreadable Office file is not worth a raw error string: it lands
|
||||||
error: error instanceof Error ? error.message : String(error),
|
// on the viewer's own state, which offers the system app instead.
|
||||||
loading: false
|
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,
|
isImage,
|
||||||
isPdf,
|
isPdf,
|
||||||
isText,
|
isText,
|
||||||
|
officeFormat,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
selfReload,
|
selfReload,
|
||||||
target.dataUrl,
|
target.dataUrl,
|
||||||
@@ -1027,6 +1055,29 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
return <PreviewEmptyState body={pdfError} title={t.preview.unavailable} />
|
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 (
|
if (
|
||||||
!isImage &&
|
!isImage &&
|
||||||
!isPdf &&
|
!isPdf &&
|
||||||
@@ -1055,16 +1106,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isImage && state.dataUrl) {
|
if (isImage && state.dataUrl) {
|
||||||
return (
|
return <ImageFilePreview alt={target.label} src={state.dataUrl} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPdf && state.dataUrl && pdfUrl) {
|
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,
|
dir: t => t.dir,
|
||||||
anchor: t => t.anchor,
|
anchor: t => t.anchor,
|
||||||
before: t => t.before,
|
before: t => t.before,
|
||||||
|
headerSurface: 'chat',
|
||||||
minWidth: '20rem',
|
minWidth: '20rem',
|
||||||
title: tileCaption,
|
title: tileCaption,
|
||||||
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
|
// 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 { registry } from '@/contrib/registry'
|
||||||
import { $selectedStoredSessionId, $sessions } from '@/store/session'
|
import { $selectedStoredSessionId, $sessions } from '@/store/session'
|
||||||
import { $removedSessionIds } from '@/store/session-removal'
|
import { $removedSessionIds } from '@/store/session-removal'
|
||||||
|
import { $desktopVersion } from '@/store/updates'
|
||||||
import { makeSessionInfo } from '@/test/session-info'
|
import { makeSessionInfo } from '@/test/session-info'
|
||||||
|
|
||||||
import { type AppView, ROUTES_AREA, SIDEBAR_NAV_AREA } from '../../routes'
|
import { type AppView, ROUTES_AREA, SIDEBAR_NAV_AREA } from '../../routes'
|
||||||
|
|
||||||
|
import { SidebarZoneLead } from './zone-tabs'
|
||||||
|
|
||||||
import { ChatSidebar } from './index'
|
import { ChatSidebar } from './index'
|
||||||
|
|
||||||
const noop = () => {}
|
const noop = () => {}
|
||||||
@@ -28,6 +31,8 @@ const renderSidebar = (pathname: string, currentView: AppView) =>
|
|||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[pathname]}>
|
<MemoryRouter initialEntries={[pathname]}>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
{/* The zone's lead, which the layout tree renders above this pane. */}
|
||||||
|
<SidebarZoneLead />
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onArchiveSession={noop}
|
onArchiveSession={noop}
|
||||||
@@ -162,3 +167,38 @@ describe('ChatSidebar navigation activity', () => {
|
|||||||
expectOnlySelectedSession(null)
|
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,
|
requestProfileCreate,
|
||||||
toggleShowAllProfiles
|
toggleShowAllProfiles
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { $profileRailVisible, toggleProfileRailVisible } from '@/store/profile-rail-prefs'
|
|
||||||
import { runImportProfileFlow } from '@/store/profile-share'
|
import { runImportProfileFlow } from '@/store/profile-share'
|
||||||
import { $projectTree } from '@/store/projects'
|
import { $projectTree } from '@/store/projects'
|
||||||
import type { PullRequestBucket } from '@/store/pull-requests'
|
import type { PullRequestBucket } from '@/store/pull-requests'
|
||||||
@@ -158,7 +157,6 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
const ordering = useStore($sidebarOrdering)
|
const ordering = useStore($sidebarOrdering)
|
||||||
const rowMeta = useStore($sidebarRowMeta)
|
const rowMeta = useStore($sidebarRowMeta)
|
||||||
const cardRows = useStore($sidebarCardRows)
|
const cardRows = useStore($sidebarCardRows)
|
||||||
const profileRailVisible = useStore($profileRailVisible)
|
|
||||||
const showAllSessions = useStore($sidebarShowAllSessions)
|
const showAllSessions = useStore($sidebarShowAllSessions)
|
||||||
const statusFilter = useStore($sidebarStatusFilter)
|
const statusFilter = useStore($sidebarStatusFilter)
|
||||||
const projectFilter = useStore($sidebarProjectFilter)
|
const projectFilter = useStore($sidebarProjectFilter)
|
||||||
@@ -308,15 +306,6 @@ export function SidebarFilterMenu({ className }: { className?: string }) {
|
|||||||
onCheck={() => setSidebarCardRows(!cardRows)}
|
onCheck={() => setSidebarCardRows(!cardRows)}
|
||||||
option={{ icon: 'inbox', id: 'card-rows', label: 'Inbox style' }}
|
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>
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type * as React from 'react'
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router'
|
import { useLocation } from 'react-router'
|
||||||
|
|
||||||
import { UserAccountCard } from '@/app/chat/sidebar/user-account-card'
|
|
||||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
@@ -75,6 +74,7 @@ import {
|
|||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
|
$activeGatewayProfile,
|
||||||
$newChatProfile,
|
$newChatProfile,
|
||||||
$profiles,
|
$profiles,
|
||||||
$profileScope,
|
$profileScope,
|
||||||
@@ -83,7 +83,7 @@ import {
|
|||||||
normalizeProfileKey,
|
normalizeProfileKey,
|
||||||
sidebarProfileForScope
|
sidebarProfileForScope
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { $profileRailVisible } from '@/store/profile-rail-prefs'
|
import { $pinnedProjectIdsByProfile, reorderPinnedProjects } from '@/store/project-pins'
|
||||||
import {
|
import {
|
||||||
$activeProjectId,
|
$activeProjectId,
|
||||||
$newProjectDropPlacement,
|
$newProjectDropPlacement,
|
||||||
@@ -153,7 +153,6 @@ import { useGatewaySessionGroups } from './gateway-group-model'
|
|||||||
import { SidebarLoadMoreRow } from './load-more-row'
|
import { SidebarLoadMoreRow } from './load-more-row'
|
||||||
import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order'
|
import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order'
|
||||||
import { filterSessionsByProfileScope } from './profile-scope'
|
import { filterSessionsByProfileScope } from './profile-scope'
|
||||||
import { ProfileRail } from './profile-switcher'
|
|
||||||
import { ProjectDialog } from './project-dialog'
|
import { ProjectDialog } from './project-dialog'
|
||||||
import { resolveLiveProjectFilter } from './project-filter'
|
import { resolveLiveProjectFilter } from './project-filter'
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +160,7 @@ import {
|
|||||||
orderProjectsByIds,
|
orderProjectsByIds,
|
||||||
overlayLiveLanes,
|
overlayLiveLanes,
|
||||||
overlayLivePreviews,
|
overlayLivePreviews,
|
||||||
|
prioritizePinnedProjects,
|
||||||
PROJECT_PREVIEW_COUNT,
|
PROJECT_PREVIEW_COUNT,
|
||||||
ProjectBackRow,
|
ProjectBackRow,
|
||||||
ProjectMenu,
|
ProjectMenu,
|
||||||
@@ -186,6 +186,8 @@ import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section
|
|||||||
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu'
|
||||||
import { useEnteredProjectSessions } from './use-entered-project-sessions'
|
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
|
// 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
|
// front, reveal more in larger steps on demand. Keeps a busy platform from
|
||||||
// dominating the sidebar before the user asks to see it.
|
// dominating the sidebar before the user asks to see it.
|
||||||
@@ -436,6 +438,9 @@ export function ChatSidebar({
|
|||||||
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
|
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
|
||||||
const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds)
|
const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds)
|
||||||
const projectOrderIds = useStore($sidebarProjectOrderIds)
|
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 projects = useStore($projects)
|
||||||
const projectTree = useStore($projectTree)
|
const projectTree = useStore($projectTree)
|
||||||
|
|
||||||
@@ -499,7 +504,6 @@ export function ChatSidebar({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||||
const profileRailVisible = useStore($profileRailVisible)
|
|
||||||
|
|
||||||
const dndSensors = useSensors(
|
const dndSensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
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
|
// 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
|
// (default) returns `sorted` untouched; projects the user hasn't ordered yet
|
||||||
// keep their sorted position rather than jumping the hand-picked list.
|
// keep their sorted position rather than jumping the hand-picked list.
|
||||||
return orderProjectsByIds(sorted, projectOrderIds)
|
return prioritizePinnedProjects(orderProjectsByIds(sorted, projectOrderIds), pinnedProjectIds)
|
||||||
}, [
|
}, [
|
||||||
projectTree,
|
projectTree,
|
||||||
dismissedAutoProjects,
|
dismissedAutoProjects,
|
||||||
@@ -974,6 +978,7 @@ export function ChatSidebar({
|
|||||||
activeProjectId,
|
activeProjectId,
|
||||||
projectFilter,
|
projectFilter,
|
||||||
projectOrderIds,
|
projectOrderIds,
|
||||||
|
pinnedProjectIds,
|
||||||
isHiddenFromProjects,
|
isHiddenFromProjects,
|
||||||
s
|
s
|
||||||
])
|
])
|
||||||
@@ -1446,7 +1451,10 @@ export function ChatSidebar({
|
|||||||
|
|
||||||
// Persist the new project overview order (drag-to-reorder); orderByIds applies
|
// 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.
|
// 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
|
// Sortable rows carry live session ids; the pinned store is keyed by durable
|
||||||
// (lineage-root) ids, so translate before persisting the new order.
|
// (lineage-root) ids, so translate before persisting the new order.
|
||||||
@@ -1474,7 +1482,10 @@ export function ChatSidebar({
|
|||||||
data-tour="sessions-sidebar"
|
data-tour="sessions-sidebar"
|
||||||
>
|
>
|
||||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
|
<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>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu className="gap-px">
|
<SidebarMenu className="gap-px">
|
||||||
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
|
{[...SIDEBAR_NAV, ...contributedNav].map(item => {
|
||||||
@@ -1853,6 +1864,7 @@ export function ChatSidebar({
|
|||||||
onToggleUnread={toggleUnread}
|
onToggleUnread={toggleUnread}
|
||||||
open={agentsOpen}
|
open={agentsOpen}
|
||||||
pinned={false}
|
pinned={false}
|
||||||
|
pinnedProjectIds={pinnedProjectIds}
|
||||||
projectBackRow={
|
projectBackRow={
|
||||||
inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined
|
inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined
|
||||||
}
|
}
|
||||||
@@ -1932,19 +1944,6 @@ export function ChatSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
{!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>
|
</SidebarContent>
|
||||||
<ProjectDialog />
|
<ProjectDialog />
|
||||||
{/* One mount for the whole app. The header of WorktreeDialog tells why. */}
|
{/* 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, waitFor } from '@testing-library/react'
|
||||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { MemoryRouter, useLocation } from 'react-router'
|
||||||
import { atom } from 'nanostores'
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
import { afterEach, 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
|
vi.mock('@/store/profile', async importOriginal => ({
|
||||||
// door to switching profiles — it must list every profile of the active
|
...(await importOriginal<typeof ProfileStore>()),
|
||||||
// gateway and select through the same store action the rail uses.
|
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()
|
beforeEach(() => {
|
||||||
const setShowAllProfiles = vi.fn()
|
stubMenuDomApis()
|
||||||
|
$userAccount.set({
|
||||||
vi.mock('react-router', () => ({ useNavigate: () => vi.fn() }))
|
status: 'signed_in',
|
||||||
|
site: 'https://account.example',
|
||||||
vi.mock('@/i18n', () => ({
|
offline: false,
|
||||||
useI18n: () => ({
|
profileAsOf: null,
|
||||||
t: {
|
profile: {
|
||||||
profiles: {
|
email: 'owner@example.com',
|
||||||
allProfiles: 'All profiles',
|
username: '',
|
||||||
fleet: { onGateway: (name: string, gateway: string) => `${name} · ${gateway}` },
|
nickname: 'Owner',
|
||||||
importProfile: 'Import profile…',
|
phone: '',
|
||||||
manageProfiles: 'Manage profiles…',
|
planId: '',
|
||||||
newProfile: 'New profile',
|
periodEnd: '',
|
||||||
switchConnectionFailed: (name: string) => `Could not connect to ${name}`,
|
dataAsOf: ''
|
||||||
title: 'Profiles'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}))
|
$activeGatewayProfile.set('default')
|
||||||
|
$showAllProfiles.set(false)
|
||||||
vi.mock('@/store/profile', () => ({
|
$profileOrder.set(['work', 'clippy'])
|
||||||
$activeGatewayProfile: atom('default'),
|
$profiles.set(
|
||||||
$profileColors: atom({}),
|
['default', 'clippy', 'work'].map(name => ({
|
||||||
$profileCreateRequest: atom(0),
|
name,
|
||||||
$profileOrder: atom([]),
|
is_default: name === 'default',
|
||||||
$profiles: atom([
|
path: `/tmp/${name}`,
|
||||||
{ is_default: true, name: 'default' },
|
has_env: false,
|
||||||
{ is_default: false, name: 'clippy' }
|
model: null,
|
||||||
]),
|
provider: null,
|
||||||
$showAllProfiles: atom(false),
|
skill_count: 0
|
||||||
ALL_PROFILES: '__all__',
|
}))
|
||||||
normalizeProfileKey: (name: string) => name,
|
)
|
||||||
profileLabel: (profile: { name: string }) => profile.name,
|
$connectionsRegistry.set(null)
|
||||||
refreshActiveProfile: vi.fn().mockResolvedValue(undefined),
|
$connection.set(null)
|
||||||
selectProfile: (name: string) => selectProfile(name),
|
$fleetRoster.set(null)
|
||||||
setShowAllProfiles: (value: boolean) => setShowAllProfiles(value),
|
$profileCreateRequest.set(0)
|
||||||
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 }))
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
$profileRailVisible.set(true)
|
$userAccount.set({ status: 'unknown' })
|
||||||
|
$profiles.set([])
|
||||||
|
$profileOrder.set([])
|
||||||
|
$connectionsRegistry.set(null)
|
||||||
|
$connection.set(null)
|
||||||
|
$fleetRoster.set(null)
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches profiles from the statusbar dropdown while the rail is hidden', async () => {
|
function Account({ compact = false }: { compact?: boolean }) {
|
||||||
act(() => $profileRailVisible.set(false))
|
const route = useLocation()
|
||||||
render(<ProfileSwitcher compact />)
|
|
||||||
|
|
||||||
const trigger = screen.getByRole('button', { name: 'Profiles: default' })
|
return (
|
||||||
await act(async () => {
|
<>
|
||||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
<UserAccountCard compact={compact} />
|
||||||
await Promise.resolve()
|
<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
|
||||||
|
})
|
||||||
|
$connectionsRegistry.set({
|
||||||
|
version: 2,
|
||||||
|
primary: 'local',
|
||||||
|
lastUsed: 'local',
|
||||||
|
launchMode: 'primary',
|
||||||
|
secureTokenStorage: true,
|
||||||
|
connections: [
|
||||||
|
{ id: 'local', kind: 'local', label: 'This device', tokenSet: false, tokenPreview: '' },
|
||||||
|
{
|
||||||
|
id: 'remote',
|
||||||
|
kind: 'remote',
|
||||||
|
label: 'Remote gateway',
|
||||||
|
url: 'https://remote.example',
|
||||||
|
tokenSet: false,
|
||||||
|
tokenPreview: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
$fleetRoster.set({
|
||||||
|
sources: [{ connectionId: 'remote', kind: 'remote', label: 'Remote gateway', reachable: false }],
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
connectionId: 'remote',
|
||||||
|
connectionKind: 'remote',
|
||||||
|
connectionLabel: 'Remote gateway',
|
||||||
|
profile: 'builder',
|
||||||
|
handle: 'builder-remote'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
view.rerender(
|
||||||
const clippy = await screen.findByRole('menuitemradio', { name: /clippy/ })
|
<MemoryRouter>
|
||||||
await act(async () => {
|
<Account compact />
|
||||||
fireEvent.click(clippy)
|
</MemoryRouter>
|
||||||
await Promise.resolve()
|
)
|
||||||
})
|
await openProfiles()
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: /builder.*Remote gateway/ }))
|
||||||
expect(selectProfile).toHaveBeenCalledWith('clippy')
|
await waitFor(() => expect(selectConnection).toHaveBeenCalledWith('remote', { profile: 'builder' }))
|
||||||
expect(setShowAllProfiles).not.toHaveBeenCalled()
|
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 { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
dropdownMenuSectionLabel,
|
dropdownMenuSectionLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { ProfileGlyph } from '@/components/ui/profile-glyph'
|
import { ProfileGlyph } from '@/components/ui/profile-glyph'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
@@ -21,44 +20,42 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||||||
import { Loader2 } from '@/lib/icons'
|
import { Loader2 } from '@/lib/icons'
|
||||||
import { resolveProfileColor } from '@/lib/profile-color'
|
import { resolveProfileColor } from '@/lib/profile-color'
|
||||||
import { cn } from '@/lib/utils'
|
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 { $fleetRoster } from '@/store/fleet-roster'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
$activeGatewayProfile,
|
$activeGatewayProfile,
|
||||||
$profileColors,
|
$profileColors,
|
||||||
$profileCreateRequest,
|
|
||||||
$profileOrder,
|
$profileOrder,
|
||||||
$profiles,
|
$profiles,
|
||||||
$showAllProfiles,
|
$showAllProfiles,
|
||||||
ALL_PROFILES,
|
ALL_PROFILES,
|
||||||
normalizeProfileKey,
|
normalizeProfileKey,
|
||||||
profileLabel,
|
profileLabel,
|
||||||
refreshActiveProfile,
|
requestProfileCreate,
|
||||||
selectProfile,
|
selectProfile,
|
||||||
setShowAllProfiles,
|
setShowAllProfiles,
|
||||||
sortByProfileOrder
|
sortByProfileOrder
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { runImportProfileFlow } from '@/store/profile-share'
|
import { runImportProfileFlow } from '@/store/profile-share'
|
||||||
|
|
||||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
import { PROFILES_ROUTE, SETTINGS_ROUTE } from '../../routes'
|
||||||
import { PROFILES_ROUTE } from '../../routes'
|
|
||||||
|
|
||||||
import { ConnectionGlyph } from './connection-glyph'
|
import { ConnectionGlyph } from './connection-glyph'
|
||||||
import { buildRestGroups, type FleetAgent, fleetRouteKey } from './fleet-rail'
|
import { buildRestGroups, type FleetAgent, fleetRouteKey } from './fleet-rail'
|
||||||
import { useFleetRoster } from './use-fleet-roster'
|
|
||||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The profile picker that sits beside the gateway switcher in the statusbar
|
* Account-menu profile picker. Dialogs and refresh listeners belong to the
|
||||||
* while the colored rail is hidden — the same choices the rail offers (this
|
* standing shell host, so selecting New can close this menu without losing
|
||||||
* gateway's profiles, every other gateway's agents in fleet mode, new / import
|
* its dialog. Square gestures remain in Manage Profiles.
|
||||||
* / 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.
|
|
||||||
*/
|
*/
|
||||||
export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
|
export function ProfileSubmenu() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const p = t.profiles
|
const p = t.profiles
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -71,26 +68,8 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
const registry = useStore($connectionsRegistry)
|
const registry = useStore($connectionsRegistry)
|
||||||
const activeConnectionId = useStore($activeConnectionId)
|
const activeConnectionId = useStore($activeConnectionId)
|
||||||
const roster = useStore($fleetRoster)
|
const roster = useStore($fleetRoster)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
|
||||||
const [pendingRoute, setPendingRoute] = useState<null | string>(null)
|
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 connections = registry?.connections
|
||||||
|
|
||||||
const restGroups = useMemo(
|
const restGroups = useMemo(
|
||||||
@@ -134,120 +113,93 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
const triggerLabel = showAll ? p.allProfiles : active ? profileLabel(active) : p.title
|
const triggerLabel = showAll ? p.allProfiles : active ? profileLabel(active) : p.title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DropdownMenuSub>
|
||||||
aria-busy={pendingRoute !== null}
|
<DropdownMenuSubTrigger
|
||||||
aria-label={p.title}
|
aria-busy={pendingRoute !== null}
|
||||||
className={cn('min-w-16 shrink overflow-hidden', compact ? 'h-full max-w-40' : 'max-w-72')}
|
aria-label={showAll || active ? `${p.title}: ${triggerLabel}` : p.title}
|
||||||
data-slot="profile-switcher"
|
className="min-w-0"
|
||||||
role="group"
|
data-slot="account-profiles"
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
{pendingRoute !== null ? (
|
||||||
<DropdownMenuTrigger asChild>
|
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
|
||||||
<Button
|
) : (
|
||||||
aria-label={`${p.title}: ${triggerLabel}`}
|
<Codicon aria-hidden="true" name="organization" size="0.875rem" />
|
||||||
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',
|
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||||
compact && 'h-full min-h-0 rounded-none px-1.5 text-[0.6875rem] font-normal'
|
{(showAll || active) && <span className="max-w-24 truncate text-(--ui-text-tertiary)">{triggerLabel}</span>}
|
||||||
)}
|
</DropdownMenuSubTrigger>
|
||||||
size="xs"
|
<DropdownMenuSubContent
|
||||||
type="button"
|
className="w-56 min-w-0 max-w-(--radix-popper-available-width) max-h-[min(70vh,30rem)] overflow-y-auto"
|
||||||
variant="ghost"
|
collisionPadding={8}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
<DropdownMenuRadioGroup onValueChange={choose} value={value}>
|
||||||
{pendingRoute !== null ? (
|
{ordered.map(profile => (
|
||||||
<Loader2 aria-hidden="true" className="size-3 shrink-0 animate-spin" />
|
<ProfileItem
|
||||||
) : showAll || !active ? (
|
color={resolveProfileColor(profile.name, colors)}
|
||||||
<Codicon aria-hidden="true" className="shrink-0" name="layers" size="0.75rem" />
|
isDefault={profile.is_default}
|
||||||
) : (
|
key={profile.name}
|
||||||
<ProfileGlyph
|
label={profileLabel(profile)}
|
||||||
aria-hidden="true"
|
name={profile.name}
|
||||||
className="size-3 text-[0.4375rem]"
|
/>
|
||||||
color={resolveProfileColor(active.name, colors)}
|
|
||||||
isDefault={active.is_default}
|
|
||||||
name={active.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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">
|
|
||||||
<DropdownMenuRadioGroup onValueChange={choose} value={value}>
|
|
||||||
{ordered.map(profile => (
|
|
||||||
<ProfileItem
|
|
||||||
color={resolveProfileColor(profile.name, colors)}
|
|
||||||
isDefault={profile.is_default}
|
|
||||||
key={profile.name}
|
|
||||||
label={profileLabel(profile)}
|
|
||||||
name={profile.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Nothing to widen to with one profile: the ALL view only exists
|
|
||||||
once a second profile does (the rail hides its toggle then too). */}
|
|
||||||
{profiles.length > 1 && (
|
|
||||||
<DropdownMenuRadioItem className="min-w-0" value={ALL_PROFILES}>
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<Codicon aria-hidden="true" className="text-(--ui-text-tertiary)" name="layers" size="0.875rem" />
|
|
||||||
<span className="truncate">{p.allProfiles}</span>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
{restGroups.map(group => (
|
|
||||||
<div data-connection-id={group.connectionId} data-slot="profile-switcher-gateway" key={group.connectionId}>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuLabel className={cn(dropdownMenuSectionLabel, 'flex items-center gap-1.5')}>
|
|
||||||
<ConnectionGlyph connection={group} />
|
|
||||||
<span className="truncate">{group.label}</span>
|
|
||||||
{!group.reachable && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{[group.defaultAgent, ...group.named].map(agent => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
aria-label={p.fleet.onGateway(agent.profile, group.label)}
|
|
||||||
className="min-w-0"
|
|
||||||
key={agent.profile}
|
|
||||||
onSelect={() => switchToRest(agent)}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<ProfileGlyph
|
|
||||||
aria-hidden="true"
|
|
||||||
color={resolveProfileColor(agent.profile, colors)}
|
|
||||||
isDefault={agent.isDefault}
|
|
||||||
name={agent.profile}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{agent.profile}</span>
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
{/* Nothing to widen to with one profile: the ALL view only exists
|
||||||
<DropdownMenuItem onSelect={() => setCreateOpen(true)}>
|
once a second profile does (the rail hides its toggle then too). */}
|
||||||
<Codicon aria-hidden="true" name="add" size="0.875rem" />
|
{profiles.length > 1 && (
|
||||||
<span className="truncate">{p.newProfile}</span>
|
<DropdownMenuRadioItem className="min-w-0" value={ALL_PROFILES}>
|
||||||
</DropdownMenuItem>
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<DropdownMenuItem onSelect={() => void runImportProfileFlow()}>
|
<Codicon aria-hidden="true" className="text-(--ui-text-tertiary)" name="layers" size="0.875rem" />
|
||||||
<Codicon aria-hidden="true" name="cloud-download" size="0.875rem" />
|
<span className="truncate">{p.allProfiles}</span>
|
||||||
<span className="truncate">{p.importProfile}</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuItem onSelect={() => navigate(PROFILES_ROUTE)}>
|
)}
|
||||||
<Codicon aria-hidden="true" name="settings-gear" size="0.875rem" />
|
</DropdownMenuRadioGroup>
|
||||||
<span className="truncate">{p.manageProfiles}</span>
|
{restGroups.map(group => (
|
||||||
</DropdownMenuItem>
|
<div data-connection-id={group.connectionId} data-slot="profile-switcher-gateway" key={group.connectionId}>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenu>
|
<DropdownMenuLabel className={cn(dropdownMenuSectionLabel, 'flex items-center gap-1.5')}>
|
||||||
|
<ConnectionGlyph connection={group} />
|
||||||
<CreateProfileDialog
|
<span className="truncate">{group.label}</span>
|
||||||
onClose={() => setCreateOpen(false)}
|
{!group.reachable && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />}
|
||||||
onCreated={async name => {
|
</DropdownMenuLabel>
|
||||||
await refreshActiveProfile()
|
{[group.defaultAgent, ...group.named].map(agent => (
|
||||||
selectProfile(name)
|
<DropdownMenuItem
|
||||||
}}
|
aria-label={p.fleet.onGateway(agent.profile, group.label)}
|
||||||
open={createOpen}
|
className="min-w-0"
|
||||||
profiles={profiles}
|
key={agent.profile}
|
||||||
/>
|
onSelect={() => switchToRest(agent)}
|
||||||
</div>
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<ProfileGlyph
|
||||||
|
aria-hidden="true"
|
||||||
|
color={resolveProfileColor(agent.profile, colors)}
|
||||||
|
isDefault={agent.isDefault}
|
||||||
|
name={agent.profile}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{agent.profile}</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={requestProfileCreate}>
|
||||||
|
<Codicon aria-hidden="true" name="add" size="0.875rem" />
|
||||||
|
<span className="truncate">{p.newProfile}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => void runImportProfileFlow()}>
|
||||||
|
<Codicon aria-hidden="true" name="cloud-download" size="0.875rem" />
|
||||||
|
<span className="truncate">{p.importProfile}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => navigate(PROFILES_ROUTE)}>
|
||||||
|
<Codicon aria-hidden="true" name="settings-gear" size="0.875rem" />
|
||||||
|
<span className="truncate">{p.manageProfiles}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<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)
|
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
|
||||||
|
|
||||||
return (
|
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">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
<ProfileGlyph aria-hidden="true" color={color} isDefault={isDefault} name={name} />
|
<ProfileGlyph aria-hidden="true" color={color} isDefault={isDefault} name={name} />
|
||||||
<span className="truncate">{label}</span>
|
<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 { atom } from 'nanostores'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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).
|
// gap reported against the multi-connection registry launch).
|
||||||
|
|
||||||
const navigate = vi.fn()
|
const navigate = vi.fn()
|
||||||
|
const { createDialog } = vi.hoisted(() => ({ createDialog: vi.fn(() => null) }))
|
||||||
|
|
||||||
vi.mock('react-router', () => ({
|
vi.mock('react-router', () => ({
|
||||||
useNavigate: () => navigate
|
useNavigate: () => navigate
|
||||||
@@ -83,19 +84,22 @@ vi.mock('@/hermes', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/chat/code-editor', () => ({ CodeEditor: () => null }))
|
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/delete-profile-dialog', () => ({ DeleteProfileDialog: () => null }))
|
||||||
vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () => null }))
|
vi.mock('../../profiles/rename-profile-dialog', () => ({ RenameProfileDialog: () => null }))
|
||||||
|
|
||||||
const { $hasMultipleConnections } = await import('@/store/connections')
|
const { $hasMultipleConnections } = await import('@/store/connections')
|
||||||
const hasMultipleConnections = $hasMultipleConnections as ReturnType<typeof atom<boolean>>
|
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 }>>>
|
const profiles = $profiles as ReturnType<typeof atom<Array<{ is_default: boolean; name: string }>>>
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
hasMultipleConnections.set(false)
|
hasMultipleConnections.set(false)
|
||||||
profiles.set([{ is_default: true, name: 'default' }])
|
profiles.set([{ is_default: true, name: 'default' }])
|
||||||
|
createRequest.set(0)
|
||||||
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ProfileRail multi-gateway entry point', () => {
|
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('group', { name: 'Profiles' }).className).toContain('min-w-0')
|
||||||
expect(screen.getByRole('button', { name: 'Profiles' })).toBeTruthy()
|
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
|
// (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
|
// 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.
|
// 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 { t } = useI18n()
|
||||||
const p = t.profiles
|
const p = t.profiles
|
||||||
const profiles = useStore($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)
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (createRequest === lastCreateRef.current) {
|
if (management || createRequest === lastCreateRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastCreateRef.current = createRequest
|
lastCreateRef.current = createRequest
|
||||||
setCreateOpen(true)
|
setCreateOpen(true)
|
||||||
}, [createRequest])
|
}, [createRequest, management])
|
||||||
|
|
||||||
// The sortable strip of the active gateway's named profiles (unchanged
|
// The sortable strip of the active gateway's named profiles (unchanged
|
||||||
// from the single-gateway rail; fleet mode only decides where it sits).
|
// 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"
|
className="flex min-w-0 items-center gap-0.5"
|
||||||
data-slot="profile-rail"
|
data-slot="profile-rail"
|
||||||
data-tip-region=""
|
data-tip-region=""
|
||||||
data-tour="profile-rail"
|
data-tour={management ? undefined : 'profile-rail'}
|
||||||
role="group"
|
role="group"
|
||||||
>
|
>
|
||||||
{/* Fleet: every gateway carries its own home square inside its group, so
|
{/* 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
|
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
|
single-profile user must be able to edit the default's persona
|
||||||
without first creating a throwaway second profile. */}
|
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
|
{/* Multi-gateway discoverability: before a second source exists, a plug
|
||||||
pinned beside Manage deep-links to the unified Gateways page. Once
|
pinned beside Manage deep-links to the unified Gateways page. Once
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SidebarRowStack } from '../chrome'
|
|||||||
import { useWorkspaceNodeOpen } from './model'
|
import { useWorkspaceNodeOpen } from './model'
|
||||||
import { SidebarWorkspaceGroup } from './workspace-group'
|
import { SidebarWorkspaceGroup } from './workspace-group'
|
||||||
import {
|
import {
|
||||||
|
groupContainsSession,
|
||||||
mergeRepoWorktreeGroups,
|
mergeRepoWorktreeGroups,
|
||||||
overlayRepoLanes,
|
overlayRepoLanes,
|
||||||
type SidebarProjectTree,
|
type SidebarProjectTree,
|
||||||
@@ -32,6 +33,7 @@ import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
|
|||||||
// headers so the folders stay distinguishable.
|
// headers so the folders stay distinguishable.
|
||||||
export function EnteredProjectContent({
|
export function EnteredProjectContent({
|
||||||
project,
|
project,
|
||||||
|
activeSessionId,
|
||||||
renderRows,
|
renderRows,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewSessionSplit,
|
onNewSessionSplit,
|
||||||
@@ -40,6 +42,7 @@ export function EnteredProjectContent({
|
|||||||
removedSessionIds
|
removedSessionIds
|
||||||
}: {
|
}: {
|
||||||
project: SidebarProjectTree
|
project: SidebarProjectTree
|
||||||
|
activeSessionId?: null | string
|
||||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||||
onNewSession?: (path: null | string) => void
|
onNewSession?: (path: null | string) => void
|
||||||
onNewSessionSplit?: NewSessionSplitHandler
|
onNewSessionSplit?: NewSessionSplitHandler
|
||||||
@@ -63,6 +66,7 @@ export function EnteredProjectContent({
|
|||||||
<>
|
<>
|
||||||
{project.repos.map(repo => (
|
{project.repos.map(repo => (
|
||||||
<RepoFlatSection
|
<RepoFlatSection
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
|
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
|
||||||
key={repo.id}
|
key={repo.id}
|
||||||
liveSessions={liveSessions}
|
liveSessions={liveSessions}
|
||||||
@@ -80,6 +84,7 @@ export function EnteredProjectContent({
|
|||||||
|
|
||||||
function RepoFlatSection({
|
function RepoFlatSection({
|
||||||
repo,
|
repo,
|
||||||
|
activeSessionId,
|
||||||
showHeader,
|
showHeader,
|
||||||
renderRows,
|
renderRows,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
@@ -89,6 +94,7 @@ function RepoFlatSection({
|
|||||||
removedSessionIds
|
removedSessionIds
|
||||||
}: {
|
}: {
|
||||||
repo: SidebarWorkspaceTree
|
repo: SidebarWorkspaceTree
|
||||||
|
activeSessionId?: null | string
|
||||||
showHeader: boolean
|
showHeader: boolean
|
||||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||||
onNewSession?: (path: null | string) => void
|
onNewSession?: (path: null | string) => void
|
||||||
@@ -139,6 +145,7 @@ function RepoFlatSection({
|
|||||||
!dismissedWorktrees.includes(group.id) ||
|
!dismissedWorktrees.includes(group.id) ||
|
||||||
(removedWorktrees.includes(group.id) && group.path && discoveredWorktreePaths.has(group.path))
|
(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
|
// 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
|
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
|
||||||
@@ -169,6 +176,7 @@ function RepoFlatSection({
|
|||||||
<>
|
<>
|
||||||
{ordered.map(group => (
|
{ordered.map(group => (
|
||||||
<SidebarWorkspaceGroup
|
<SidebarWorkspaceGroup
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
group={group}
|
group={group}
|
||||||
key={group.id}
|
key={group.id}
|
||||||
// The kanban bucket is read-only: it aggregates many task worktrees, so
|
// The kanban bucket is read-only: it aggregates many task worktrees, so
|
||||||
@@ -275,6 +283,7 @@ function RepoFlatSection({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
active={activeRepo}
|
||||||
emphasis
|
emphasis
|
||||||
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
|
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
|
||||||
label={repo.label}
|
label={repo.label}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export { EnteredProjectContent } from './entered-content'
|
export { EnteredProjectContent } from './entered-content'
|
||||||
export {
|
export {
|
||||||
orderProjectsByIds,
|
orderProjectsByIds,
|
||||||
|
prioritizePinnedProjects,
|
||||||
PROJECT_PREVIEW_COUNT,
|
PROJECT_PREVIEW_COUNT,
|
||||||
projectTreeCwd,
|
projectTreeCwd,
|
||||||
sortProjectsForOverview,
|
sortProjectsForOverview,
|
||||||
@@ -12,9 +13,11 @@ export { ProjectMenu } from './project-menu'
|
|||||||
export { SidebarWorkspaceGroup } from './workspace-group'
|
export { SidebarWorkspaceGroup } from './workspace-group'
|
||||||
export {
|
export {
|
||||||
excludeProjectSessions,
|
excludeProjectSessions,
|
||||||
|
groupContainsSession,
|
||||||
liveSessionProjectId,
|
liveSessionProjectId,
|
||||||
overlayLiveLanes,
|
overlayLiveLanes,
|
||||||
overlayLivePreviews,
|
overlayLivePreviews,
|
||||||
|
projectContainsSession,
|
||||||
reconcileEnteredProjectSessions,
|
reconcileEnteredProjectSessions,
|
||||||
sessionMatchesProjectFilter,
|
sessionMatchesProjectFilter,
|
||||||
sessionRecency,
|
sessionRecency,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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'
|
import { NO_PROJECT_ID, type SidebarProjectTree } from './workspace-groups'
|
||||||
|
|
||||||
function makeProject(id: string, sessionCount: number): SidebarProjectTree {
|
function makeProject(id: string, sessionCount: number): SidebarProjectTree {
|
||||||
@@ -76,3 +76,26 @@ describe('sortProjectsForOverview', () => {
|
|||||||
expect(ids(sortProjectsForOverview(projects, 'active'))).toEqual([NO_PROJECT_ID, 'active', 'scanned'])
|
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
|
// 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
|
// linked worktrees still appear even when their sessions aren't in the recents
|
||||||
// payload currently loaded in memory.
|
// payload currently loaded in memory.
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ describe('ProjectOverviewRow', () => {
|
|||||||
expect(container.querySelector('[data-sessions-project="p1"]')).toBeTruthy()
|
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', () => {
|
it('explicit projects keep the folder-library glyph and a plain accessible name', () => {
|
||||||
const explicit = { id: 'p1', label: 'Explicit' } as unknown as SidebarProjectTree
|
const explicit = { id: 'p1', label: 'Explicit' } as unknown as SidebarProjectTree
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/hermes'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $sidebarShowAllSessions } from '@/store/layout'
|
import { $sidebarShowAllSessions } from '@/store/layout'
|
||||||
|
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||||
|
import { $pinnedProjectIdsByProfile } from '@/store/project-pins'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SIDEBAR_LEAD_ICON_SIZE,
|
SIDEBAR_LEAD_ICON_SIZE,
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
|
|
||||||
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
|
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
|
||||||
import { ProjectContextMenu, ProjectMenu } from './project-menu'
|
import { ProjectContextMenu, ProjectMenu } from './project-menu'
|
||||||
import type { SidebarProjectTree } from './workspace-groups'
|
import { projectContainsSession, type SidebarProjectTree } from './workspace-groups'
|
||||||
import { WorkspaceAddButton } from './workspace-header'
|
import { WorkspaceAddButton } from './workspace-header'
|
||||||
|
|
||||||
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
|
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
|
||||||
@@ -79,6 +81,7 @@ interface ProjectOverviewRowProps {
|
|||||||
onNewSessionSplit?: NewSessionSplitHandler
|
onNewSessionSplit?: NewSessionSplitHandler
|
||||||
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
|
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
|
||||||
activeProjectId?: null | string
|
activeProjectId?: null | string
|
||||||
|
activeSessionId?: null | string
|
||||||
previewSessions?: SessionInfo[]
|
previewSessions?: SessionInfo[]
|
||||||
reorderable?: boolean
|
reorderable?: boolean
|
||||||
dragging?: boolean
|
dragging?: boolean
|
||||||
@@ -94,6 +97,7 @@ export function ProjectOverviewRow({
|
|||||||
onNewSessionSplit,
|
onNewSessionSplit,
|
||||||
renderRows,
|
renderRows,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
|
activeSessionId,
|
||||||
previewSessions,
|
previewSessions,
|
||||||
reorderable = false,
|
reorderable = false,
|
||||||
dragging = false,
|
dragging = false,
|
||||||
@@ -104,11 +108,19 @@ export function ProjectOverviewRow({
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const s = t.sidebar
|
const s = t.sidebar
|
||||||
const isActive = project.id === activeProjectId
|
const isActive = project.id === activeProjectId
|
||||||
|
const isCurrentSessionProject = projectContainsSession(
|
||||||
|
previewSessions ? { ...project, previewSessions } : project,
|
||||||
|
activeSessionId
|
||||||
|
)
|
||||||
|
const isCurrentProject = isActive || isCurrentSessionProject
|
||||||
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
|
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
|
||||||
// The appearance popover anchors here (the full row) so it opens flush with
|
// 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.
|
// the sidebar's content edge regardless of which side the sidebar is on.
|
||||||
const rowRef = useRef<HTMLDivElement>(null)
|
const rowRef = useRef<HTMLDivElement>(null)
|
||||||
const showAllSessions = useStore($sidebarShowAllSessions)
|
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 limit = showAllSessions ? Infinity : PROJECT_PREVIEW_COUNT
|
||||||
const fetched = (previewSessions ?? []).slice(0, limit)
|
const fetched = (previewSessions ?? []).slice(0, limit)
|
||||||
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, limit)) : []
|
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, limit)) : []
|
||||||
@@ -123,7 +135,7 @@ export function ProjectOverviewRow({
|
|||||||
{projectIcon(project)}
|
{projectIcon(project)}
|
||||||
</SidebarRowGrab>
|
</SidebarRowGrab>
|
||||||
) : (
|
) : (
|
||||||
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
|
<SidebarRowLead className={cn(isCurrentProject && 'text-(--ui-accent)')}>{projectIcon(project)}</SidebarRowLead>
|
||||||
)
|
)
|
||||||
|
|
||||||
const labelLink = (
|
const labelLink = (
|
||||||
@@ -135,7 +147,7 @@ export function ProjectOverviewRow({
|
|||||||
? `${s.projects.enter(project.label)} (${s.projects.autoDiscovered})`
|
? `${s.projects.enter(project.label)} (${s.projects.autoDiscovered})`
|
||||||
: s.projects.enter(project.label)
|
: 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)}
|
onClick={() => onEnter?.(project.id)}
|
||||||
>
|
>
|
||||||
{project.label}
|
{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}
|
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}
|
lead={lead}
|
||||||
// The label is grab surface too, not just the lead's grabber — same
|
// The label is grab surface too, not just the lead's grabber — same
|
||||||
// listeners, minus the controls that keep their own gestures. A project
|
// 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 { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { $pinnedProjectIdsByProfile, toggleProjectPin } from '@/store/project-pins'
|
||||||
|
|
||||||
import { ProjectMenu } from './project-menu'
|
import { ProjectMenu } from './project-menu'
|
||||||
import type { SidebarProjectTree } from './workspace-groups'
|
import type { SidebarProjectTree } from './workspace-groups'
|
||||||
|
|
||||||
@@ -35,6 +37,9 @@ vi.mock('@/i18n', () => ({
|
|||||||
menuDelete: 'Delete',
|
menuDelete: 'Delete',
|
||||||
menuRename: 'Rename',
|
menuRename: 'Rename',
|
||||||
menuSetActive: 'Set active',
|
menuSetActive: 'Set active',
|
||||||
|
menuPin: 'Pin to top',
|
||||||
|
menuUnpin: 'Unpin',
|
||||||
|
pinned: 'Pinned',
|
||||||
noColor: 'No color',
|
noColor: 'No color',
|
||||||
removeFromSidebar: 'Remove from sidebar',
|
removeFromSidebar: 'Remove from sidebar',
|
||||||
reveal: 'Reveal in file manager'
|
reveal: 'Reveal in file manager'
|
||||||
@@ -67,6 +72,18 @@ vi.mock('@/store/projects', () => ({
|
|||||||
setProjectAppearance: vi.fn().mockResolvedValue(false)
|
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 = {
|
const project = {
|
||||||
color: null,
|
color: null,
|
||||||
icon: null,
|
icon: null,
|
||||||
@@ -95,6 +112,20 @@ describe('ProjectMenu', () => {
|
|||||||
expect(tipTrigger(button)).toBeNull()
|
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
|
// When anchorRef is absent, PopoverAnchor wraps the dropdown trigger so the
|
||||||
// appearance popover positions against the kebab. asChild must still reach
|
// appearance popover positions against the kebab. asChild must still reach
|
||||||
// the real button (no non-forwarding wrappers inside the chain — #67500).
|
// 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 { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
|
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
|
||||||
|
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||||
|
import { $pinnedProjectIdsByProfile, toggleProjectPin } from '@/store/project-pins'
|
||||||
import {
|
import {
|
||||||
copyPath,
|
copyPath,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
@@ -56,6 +58,9 @@ function useProjectActions({
|
|||||||
const p = t.sidebar.projects
|
const p = t.sidebar.projects
|
||||||
const target = { id: project.id, name: project.label }
|
const target = { id: project.id, name: project.label }
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||||
|
const profile = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||||
|
const pinnedByProfile = useStore($pinnedProjectIdsByProfile)
|
||||||
|
const isPinned = (pinnedByProfile[profile] ?? []).includes(project.id)
|
||||||
|
|
||||||
const removeAuto = () => {
|
const removeAuto = () => {
|
||||||
dismissAutoProject(project.id)
|
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
|
const dangerItem: ActionItemSpec = project.isAuto
|
||||||
? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' }
|
? { 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
|
// 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).
|
// when the panes are flipped (sidebar on the right).
|
||||||
const panesFlipped = useStore($panesFlipped)
|
const panesFlipped = useStore($panesFlipped)
|
||||||
|
|
||||||
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
|
const { confirmDialog, dangerItem, identityItems, pathItems, pinItem } = useProjectActions({
|
||||||
isActive,
|
isActive,
|
||||||
onExitScope,
|
onExitScope,
|
||||||
project,
|
project,
|
||||||
@@ -248,6 +260,8 @@ export function ProjectMenu({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{renderActionItem(DROPDOWN_KIT, pinItem)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
{pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
|
{pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{renderActionItem(DROPDOWN_KIT, dangerItem)}
|
{renderActionItem(DROPDOWN_KIT, dangerItem)}
|
||||||
@@ -294,7 +308,7 @@ export function ProjectContextMenu({
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const p = t.sidebar.projects
|
const p = t.sidebar.projects
|
||||||
|
|
||||||
const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({
|
const { confirmDialog, dangerItem, identityItems, pathItems, pinItem } = useProjectActions({
|
||||||
isActive,
|
isActive,
|
||||||
onExitScope,
|
onExitScope,
|
||||||
project,
|
project,
|
||||||
@@ -327,7 +341,8 @@ export function ProjectContextMenu({
|
|||||||
</kit.SubContent>
|
</kit.SubContent>
|
||||||
</kit.Sub>
|
</kit.Sub>
|
||||||
)}
|
)}
|
||||||
{(identityItems.length > 0 || canTheme) && <kit.Separator />}
|
{renderActionItem(kit, pinItem)}
|
||||||
|
<kit.Separator />
|
||||||
{pathItems.map(item => renderActionItem(kit, item))}
|
{pathItems.map(item => renderActionItem(kit, item))}
|
||||||
<kit.Separator />
|
<kit.Separator />
|
||||||
{renderActionItem(kit, dangerItem)}
|
{renderActionItem(kit, dangerItem)}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import { notifyError } from '@/store/notifications'
|
|||||||
import { newSessionInProfile, pinNewChatProfile, selectProfile } from '@/store/profile'
|
import { newSessionInProfile, pinNewChatProfile, selectProfile } from '@/store/profile'
|
||||||
import { switchBranchInRepo } from '@/store/projects'
|
import { switchBranchInRepo } from '@/store/projects'
|
||||||
import { $sessionProfilesUsage } from '@/store/session'
|
import { $sessionProfilesUsage } from '@/store/session'
|
||||||
|
import { $sessionDotStateById, hasLiveTurn } from '@/store/session-dot-state'
|
||||||
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
|
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
|
||||||
|
|
||||||
import { SidebarGroupRow, SidebarRowLead, SidebarRowLink, SidebarRowStack } from '../chrome'
|
import { SidebarGroupRow, SidebarRowLead, SidebarRowLink, SidebarRowStack } from '../chrome'
|
||||||
import { rankSessions } from '../order'
|
import { rankSessions } from '../order'
|
||||||
|
|
||||||
import { PROJECT_PREVIEW_COUNT, SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
|
import { PROJECT_PREVIEW_COUNT, SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
|
||||||
import type { SidebarSessionGroup } from './workspace-groups'
|
import { groupContainsSession, type SidebarSessionGroup } from './workspace-groups'
|
||||||
import {
|
import {
|
||||||
WorkspaceAddButton,
|
WorkspaceAddButton,
|
||||||
WorkspaceContextMenu,
|
WorkspaceContextMenu,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
interface SidebarWorkspaceGroupProps {
|
interface SidebarWorkspaceGroupProps {
|
||||||
group: SidebarSessionGroup
|
group: SidebarSessionGroup
|
||||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||||
|
activeSessionId?: null | string
|
||||||
onNewSession?: (path: null | string) => void
|
onNewSession?: (path: null | string) => void
|
||||||
onNewSessionSplit?: NewSessionSplitHandler
|
onNewSessionSplit?: NewSessionSplitHandler
|
||||||
// When set (linked worktree rows), shows a remove affordance that runs a real
|
// When set (linked worktree rows), shows a remove affordance that runs a real
|
||||||
@@ -42,6 +44,7 @@ interface SidebarWorkspaceGroupProps {
|
|||||||
export function SidebarWorkspaceGroup({
|
export function SidebarWorkspaceGroup({
|
||||||
group,
|
group,
|
||||||
renderRows,
|
renderRows,
|
||||||
|
activeSessionId = null,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewSessionSplit,
|
onNewSessionSplit,
|
||||||
onRemove
|
onRemove
|
||||||
@@ -52,6 +55,10 @@ export function SidebarWorkspaceGroup({
|
|||||||
// Totals for the whole profile, not the loaded page — a selector so a refresh
|
// 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.
|
// that leaves this profile's spend unchanged doesn't repaint its header.
|
||||||
const usage = useStoreSelector($sessionProfilesUsage, all => all[group.id])
|
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)
|
const rankIds = useStore($sidebarSessionRankIds)
|
||||||
// The sidebar's "Show all sessions" preference lifts the lane's page too —
|
// 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
|
// the same switch that widens the project overview, read at the leaf like
|
||||||
@@ -212,11 +219,13 @@ export function SidebarWorkspaceGroup({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
active={active}
|
||||||
icon={leadingIcon}
|
icon={leadingIcon}
|
||||||
label={group.label}
|
label={group.label}
|
||||||
onToggle={toggleOpen}
|
onToggle={toggleOpen}
|
||||||
open={open}
|
open={open}
|
||||||
title={group.path ? displayPath(group.path) : undefined}
|
title={group.path ? displayPath(group.path) : undefined}
|
||||||
|
working={working}
|
||||||
/>
|
/>
|
||||||
</WorkspaceContextMenu>
|
</WorkspaceContextMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { HermesGitWorktree } from '@/global'
|
import type { HermesGitWorktree } from '@/global'
|
||||||
import type { ProjectInfo, SessionInfo } from '@/hermes'
|
import type { ProjectInfo, SessionInfo } from '@/hermes'
|
||||||
import { normalize } from '@/lib/text'
|
import { normalize } from '@/lib/text'
|
||||||
|
import { sessionMatchesStoredId } from '@/store/session'
|
||||||
|
|
||||||
import { rankSessions } from '../order'
|
import { rankSessions } from '../order'
|
||||||
|
|
||||||
@@ -71,6 +72,24 @@ export interface SidebarProjectTree {
|
|||||||
previewSessions?: SessionInfo[]
|
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. */
|
/** Path split into segments, ignoring trailing slashes and mixed separators. */
|
||||||
const segments = (path: string): string[] =>
|
const segments = (path: string): string[] =>
|
||||||
path
|
path
|
||||||
|
|||||||
@@ -194,16 +194,20 @@ export function StartWorkButton({ repoPath }: { repoPath: string }) {
|
|||||||
// button with a leading glyph, plus an optional trailing action (the +).
|
// button with a leading glyph, plus an optional trailing action (the +).
|
||||||
export function WorkspaceHeader({
|
export function WorkspaceHeader({
|
||||||
action,
|
action,
|
||||||
|
active = false,
|
||||||
emphasis = false,
|
emphasis = false,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
onToggle,
|
onToggle,
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
working = false,
|
||||||
ref,
|
ref,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
|
/** Marks the lane containing the focused conversation. */
|
||||||
|
active?: boolean
|
||||||
emphasis?: boolean
|
emphasis?: boolean
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
@@ -211,13 +215,19 @@ export function WorkspaceHeader({
|
|||||||
open: boolean
|
open: boolean
|
||||||
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
|
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
|
||||||
title?: string
|
title?: string
|
||||||
|
/** Adds a subtle pulse while any session in the lane is generating. */
|
||||||
|
working?: boolean
|
||||||
} & React.ComponentProps<'div'>) {
|
} & React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
|
'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)'
|
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}
|
ref={ref}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
@@ -229,8 +239,14 @@ export function WorkspaceHeader({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<SidebarRowLead>{icon}</SidebarRowLead>
|
<SidebarRowLead className={cn(active && 'text-(--ui-accent)')}>{icon}</SidebarRowLead>
|
||||||
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
|
<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
|
<DisclosureCaret
|
||||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -357,7 +357,8 @@ function SidebarSessionRowImpl({
|
|||||||
// metadata / preview lines below need the extra rows (#68119).
|
// metadata / preview lines below need the extra rows (#68119).
|
||||||
!card && density !== 'compact' && 'min-h-[2.75rem]',
|
!card && density !== 'compact' && 'min-h-[2.75rem]',
|
||||||
!card && density === 'detailed' && 'min-h-[3.875rem]',
|
!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
|
// Open in another pane: the SAME band, just weaker. Its own mixed
|
||||||
// token rather than row opacity — dimming the whole row would take
|
// token rather than row opacity — dimming the whole row would take
|
||||||
// the title and the status dot down with it.
|
// 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)',
|
dragging && 'z-10 cursor-grabbing bg-(--ui-sidebar-surface-background)',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
data-current-session={isSelected ? 'true' : undefined}
|
||||||
data-glass-opaque={dragging ? '' : undefined}
|
data-glass-opaque={dragging ? '' : undefined}
|
||||||
data-working={liveTurn ? 'true' : undefined}
|
data-working={liveTurn ? 'true' : undefined}
|
||||||
// The row runs BOTH drags off one press, and each declines outside its
|
// 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">
|
<span className="min-w-0 flex-1 self-center">
|
||||||
<OverflowTip label={title}>
|
<OverflowTip label={title}>
|
||||||
<SidebarRowLabel
|
<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}
|
onPointerEnter={armMarquee}
|
||||||
onPointerLeave={disarmMarquee}
|
onPointerLeave={disarmMarquee}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
listGroupNodeId,
|
listGroupNodeId,
|
||||||
toggleWorkspaceNodeCollapsed
|
toggleWorkspaceNodeCollapsed
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { sessionPinId } from '@/store/session'
|
import { sessionMatchesStoredId, sessionPinId } from '@/store/session'
|
||||||
import { $sessionDotStateById, hasLiveTurn } from '@/store/session-dot-state'
|
import { $sessionDotStateById, hasLiveTurn } from '@/store/session-dot-state'
|
||||||
|
|
||||||
import { SidebarDateDivider, SidebarSectionMeta } from './chrome'
|
import { SidebarDateDivider, SidebarSectionMeta } from './chrome'
|
||||||
@@ -132,6 +132,7 @@ interface SidebarSessionsSectionProps {
|
|||||||
// which then passes `projectContent` on the next render. Takes precedence
|
// which then passes `projectContent` on the next render. Takes precedence
|
||||||
// over `tree` / `groups`.
|
// over `tree` / `groups`.
|
||||||
projectOverview?: SidebarProjectTree[]
|
projectOverview?: SidebarProjectTree[]
|
||||||
|
pinnedProjectIds?: readonly string[]
|
||||||
// Per-project preview rows (from the backend tree), keyed by project id.
|
// Per-project preview rows (from the backend tree), keyed by project id.
|
||||||
projectOverviewPreviews?: Record<string, SessionInfo[]>
|
projectOverviewPreviews?: Record<string, SessionInfo[]>
|
||||||
// True while the backend project tree is loading (overview skeleton).
|
// True while the backend project tree is loading (overview skeleton).
|
||||||
@@ -206,6 +207,7 @@ export function SidebarSessionsSection({
|
|||||||
footer,
|
footer,
|
||||||
groups,
|
groups,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
|
pinnedProjectIds,
|
||||||
projectOverviewPreviews,
|
projectOverviewPreviews,
|
||||||
projectsLoading = false,
|
projectsLoading = false,
|
||||||
onEnterProject,
|
onEnterProject,
|
||||||
@@ -269,7 +271,7 @@ export function SidebarSessionsSection({
|
|||||||
branchStem,
|
branchStem,
|
||||||
card,
|
card,
|
||||||
isPinned: pinned,
|
isPinned: pinned,
|
||||||
isSelected: session.id === activeSessionId,
|
isSelected: sessionMatchesStoredId(session, activeSessionId ?? ''),
|
||||||
onArchive: () => onArchiveSession(session.id),
|
onArchive: () => onArchiveSession(session.id),
|
||||||
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
|
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
|
||||||
onDelete: () => onDeleteSession(session.id),
|
onDelete: () => onDeleteSession(session.id),
|
||||||
@@ -485,6 +487,7 @@ export function SidebarSessionsSection({
|
|||||||
{projectBackRow}
|
{projectBackRow}
|
||||||
{hasProjectContent ? (
|
{hasProjectContent ? (
|
||||||
<EnteredProjectContent
|
<EnteredProjectContent
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
liveSessions={liveSessions}
|
liveSessions={liveSessions}
|
||||||
onNewSession={onNewSessionInWorkspace}
|
onNewSession={onNewSessionInWorkspace}
|
||||||
onNewSessionSplit={onNewSessionSplit}
|
onNewSessionSplit={onNewSessionSplit}
|
||||||
@@ -507,12 +510,14 @@ export function SidebarSessionsSection({
|
|||||||
// wired — Home stays outside the sortable list, it's a fixture.
|
// wired — Home stays outside the sortable list, it's a fixture.
|
||||||
const home = projectOverview[0]?.isNoProject ? projectOverview[0] : undefined
|
const home = projectOverview[0]?.isNoProject ? projectOverview[0] : undefined
|
||||||
const sortableProjects = home ? projectOverview.slice(1) : projectOverview
|
const sortableProjects = home ? projectOverview.slice(1) : projectOverview
|
||||||
const projectsDraggable = sortableProjects.length > 1 && !!onReorderProjects
|
const pins = new Set(pinnedProjectIds ?? [])
|
||||||
const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow
|
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) => (
|
const projectRow = (project: SidebarProjectTree, Component: typeof ProjectOverviewRow) => (
|
||||||
<Component
|
<Component
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onEnter={onEnterProject}
|
onEnter={onEnterProject}
|
||||||
onNewSession={onNewSessionInWorkspace}
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggable = items.length > 1 && !!onReorderProjects
|
||||||
|
const Row = draggable ? SortableProjectOverviewRow : ProjectOverviewRow
|
||||||
|
const rows = items.map(project => projectRow(project, Row))
|
||||||
|
|
||||||
|
return draggable && onReorderProjects ? (
|
||||||
|
<ReorderableList
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
inner = (
|
inner = (
|
||||||
<>
|
<>
|
||||||
{home && projectRow(home, ProjectOverviewRow)}
|
{home && projectRow(home, ProjectOverviewRow)}
|
||||||
{projectsDraggable && onReorderProjects ? (
|
{projectList(pinnedProjects, otherProjects, true)}
|
||||||
<ReorderableList
|
{projectList(otherProjects, pinnedProjects, false)}
|
||||||
ids={sortableProjects.map(project => project.id)}
|
|
||||||
onReorder={onReorderProjects}
|
|
||||||
sensors={dndSensors}
|
|
||||||
>
|
|
||||||
{rows}
|
|
||||||
</ReorderableList>
|
|
||||||
) : (
|
|
||||||
rows
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else if (groups?.length && groups.every(group => group.mode === 'profile' && group.profile)) {
|
} else if (groups?.length && groups.every(group => group.mode === 'profile' && group.profile)) {
|
||||||
|
|||||||
@@ -1,61 +1,168 @@
|
|||||||
/**
|
/**
|
||||||
* 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)在名后标
|
* signed_in 时只显示 U-4 展示名;离线(U-3)在名后标 amber 圆点,
|
||||||
* amber 圆点,点击进 `/me` 资料页(缓存数据时间在那里注明)。
|
* 展开时点击展示名进 `/me`,尾部菜单与收缩时的用户图标提供账号操作。
|
||||||
* 未登录不渲染——那时登录门(DB-T2)本就盖着全屏。
|
* 未登录不渲染——那时登录门(DB-T2)本就盖着全屏。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { ME_ROUTE } from '@/app/me'
|
import { ME_ROUTE } from '@/app/me'
|
||||||
import { navigateToWorkspacePage } from '@/app/routes'
|
import { ChangePasswordDialog } from '@/app/me/change-password-dialog'
|
||||||
import { AvatarChip } from '@/components/ui/avatar-chip'
|
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 { 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() {
|
import { ProfileSubmenu } from './profile-dropdown-switcher'
|
||||||
const { t } = useI18n()
|
|
||||||
|
export interface UserAccountCardProps {
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAccountCard({ compact = false }: UserAccountCardProps) {
|
||||||
const account = useStore($userAccount)
|
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 binding = useStore($machineBinding)
|
||||||
const navigate = useNavigate()
|
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 || ''
|
const name = userAccountDisplayName(account.profile) || account.profile?.email || ''
|
||||||
// DB-T6:自动绑定失败或 pending 待确认 → 名后 amber 点,点卡片进 /me 处理。
|
// DB-T6:自动绑定失败或 pending 待确认 → 名后 amber 点,点卡片进 /me 处理。
|
||||||
const bindIssue = binding != null && !binding.bound && (binding.bindError != null || binding.pending != null)
|
const bindIssue = binding != null && !binding.bound && (binding.bindError != null || binding.pending != null)
|
||||||
|
|
||||||
return (
|
async function logout() {
|
||||||
<button
|
setLoggingOut(true)
|
||||||
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"
|
try {
|
||||||
onClick={() => navigateToWorkspacePage(navigate, ME_ROUTE)}
|
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"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<AvatarChip className="size-7" name={name} />
|
<Codicon aria-hidden="true" name="account" size="0.875rem" />
|
||||||
<span className="min-w-0 flex-1">
|
{!compact && <span className="min-w-0 flex-1 truncate">{name || t.userAccount.me.cardAria}</span>}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className={cn('flex shrink-0 items-center gap-1.5', compact && 'absolute right-0 top-0 gap-0.5')}>
|
||||||
<span className="truncate text-sm font-medium">{name}</span>
|
{account.offline && (
|
||||||
{account.offline && (
|
<span
|
||||||
<span
|
aria-label={t.userAccount.me.cardOffline}
|
||||||
aria-label={t.userAccount.me.cardOffline}
|
className="size-1.5 shrink-0 rounded-full bg-amber-500"
|
||||||
className="size-1.5 shrink-0 rounded-full bg-amber-500"
|
title={t.userAccount.me.cardOffline}
|
||||||
title={t.userAccount.me.cardOffline}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
{bindIssue && (
|
||||||
{bindIssue && (
|
<span
|
||||||
<span
|
aria-label={t.userAccount.me.cardBindIssue}
|
||||||
aria-label={t.userAccount.me.cardBindIssue}
|
className="size-1.5 shrink-0 rounded-full bg-amber-500"
|
||||||
className="size-1.5 shrink-0 rounded-full bg-amber-500"
|
title={t.userAccount.me.cardBindIssue}
|
||||||
title={t.userAccount.me.cardBindIssue}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{account.profile?.email && name !== account.profile.email && (
|
|
||||||
<span className="block truncate text-xs text-muted-foreground">{account.profile.email}</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
</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 { cleanup, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const pickAndChangeWorkspace = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('@/app/shell/workspace-menu', () => ({
|
|
||||||
pickAndChangeWorkspace: (...args: any[]) => pickAndChangeWorkspace(...args)
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { $projectTree } from '@/store/projects'
|
import { $projectTree } from '@/store/projects'
|
||||||
import { $currentBranch } from '@/store/session'
|
import { $currentBranch, $sessions } from '@/store/session'
|
||||||
|
|
||||||
import { WorkspaceBar } from './workspace-bar'
|
import { WorkspaceBar } from './workspace-bar'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
vi.clearAllMocks()
|
$sessions.set([])
|
||||||
$projectTree.set([])
|
|
||||||
$currentBranch.set('')
|
$currentBranch.set('')
|
||||||
|
$projectTree.set([])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('WorkspaceBar', () => {
|
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])
|
$projectTree.set([{ id: 'p_web', label: 'Website', path: '/repos/website', repos: [], sessionCount: 0 } as any])
|
||||||
$currentBranch.set('main')
|
$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(/Redesign slides/)).toBeTruthy()
|
||||||
expect(screen.getByText('· main')).toBeTruthy()
|
expect(screen.getByText('· main Website')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the cwd leaf when no project owns the path', () => {
|
it('resolves the title through the lineage id, like every other session lookup', () => {
|
||||||
render(<WorkspaceBar cwd="/somewhere/else" onChangeWorkspace={vi.fn()} />)
|
$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', () => {
|
it('falls back to the new-session placeholder while no row exists yet', () => {
|
||||||
render(<WorkspaceBar cwd="" onChangeWorkspace={vi.fn()} />)
|
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', () => {
|
// The path and the switcher moved out: the full path was the long half of the
|
||||||
const onChangeWorkspace = vi.fn()
|
// strip, and the statusbar's cwd item already owns switching workspaces.
|
||||||
render(<WorkspaceBar cwd="/repos/website" onChangeWorkspace={onChangeWorkspace} />)
|
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)
|
expect(screen.getByText(/Website/)).toBeTruthy()
|
||||||
})
|
expect(screen.queryByText(/repos/)).toBeNull()
|
||||||
|
expect(screen.queryByRole('button')).toBeNull()
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,49 +1,39 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
|
||||||
import { pickAndChangeWorkspace } from '@/app/shell/workspace-menu'
|
import { NEW_SESSION_TITLE, sessionTitle } from '@/lib/chat-runtime'
|
||||||
import { Button } from '@/components/ui/button'
|
import { pathLeaf } from '@/lib/display-path'
|
||||||
import { useI18n } from '@/i18n'
|
|
||||||
import { displayPath, pathLeaf } from '@/lib/display-path'
|
|
||||||
import { FolderOpen } from '@/lib/icons'
|
|
||||||
import { $projectTree, projectNameForCwd } from '@/store/projects'
|
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
|
/** Slim strip under the chat header: what this conversation is called, on which
|
||||||
* runs in, plus the switch action. Always rendered (even for a folder-less
|
* branch, in which project. Always rendered (even for a draft) so the strip
|
||||||
* draft) so the entry point never disappears — the draft case is exactly
|
* never disappears — an unsent chat is exactly where the name matters.
|
||||||
* where picking a workspace matters. */
|
*
|
||||||
export function WorkspaceBar({ cwd, onChangeWorkspace }: { cwd: string; onChangeWorkspace: (cwd: string) => Promise<void> }) {
|
* The name is the session's title — the same string the titlebar header would
|
||||||
const { t } = useI18n()
|
* carry, but that header is collapsed inside the pane tree, where the zone tab
|
||||||
const copy = t.shell.statusbar
|
* 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 branch = useStore($currentBranch)
|
||||||
const projectTree = useStore($projectTree)
|
const projectTree = useStore($projectTree)
|
||||||
|
const sessions = useStore($sessions)
|
||||||
const trimmed = cwd.trim()
|
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 (
|
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">
|
<div className="flex h-7 shrink-0 items-center bg-(--ui-chat-surface-background) px-3 text-xs text-muted-foreground">
|
||||||
<FolderOpen className="size-3.5 shrink-0" />
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{trimmed ? name : copy.chooseWorkspace}
|
{title}
|
||||||
{trimmed && branch ? <span className="text-muted-foreground/60"> · {branch}</span> : null}
|
{meta ? <span className="text-muted-foreground/60"> · {meta}</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)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { isRemoteGateway } from '@/lib/media'
|
|||||||
import { reachablePreviewUrl } from '@/lib/preview-reach'
|
import { reachablePreviewUrl } from '@/lib/preview-reach'
|
||||||
import { openCommandPalette } from '@/store/command-palette'
|
import { openCommandPalette } from '@/store/command-palette'
|
||||||
import { openPreview } from '@/store/preview'
|
import { openPreview } from '@/store/preview'
|
||||||
import { toggleProfileRailVisible } from '@/store/profile-rail-prefs'
|
|
||||||
import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
|
import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
|
||||||
import { requestActiveUpdate } from '@/store/updates'
|
import { requestActiveUpdate } from '@/store/updates'
|
||||||
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
|
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
|
||||||
@@ -574,12 +573,6 @@ function shellSections({ navigate, t }: ShellVerbs): ReactNode[][] {
|
|||||||
label={t.keybinds.actions['view.toggleStatusbar']}
|
label={t.keybinds.actions['view.toggleStatusbar']}
|
||||||
onSelect={toggleStatusbarVisible}
|
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
|
// 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
|
// shell reaches this menu from anywhere, including a zone that has no
|
||||||
// chrome left to right-click.
|
// chrome left to right-click.
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { registry } from '@/contrib/registry'
|
|||||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||||
import { translateNow } from '@/i18n'
|
import { translateNow } from '@/i18n'
|
||||||
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
|
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 { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||||
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
|
import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
|
||||||
@@ -63,7 +63,6 @@ import {
|
|||||||
SIDEBAR_DEFAULT_WIDTH,
|
SIDEBAR_DEFAULT_WIDTH,
|
||||||
SIDEBAR_MAX_WIDTH
|
SIDEBAR_MAX_WIDTH
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { $profileRailVisible } from '@/store/profile-rail-prefs'
|
|
||||||
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share'
|
||||||
import {
|
import {
|
||||||
$reviewOpen,
|
$reviewOpen,
|
||||||
@@ -73,7 +72,14 @@ import {
|
|||||||
openReview,
|
openReview,
|
||||||
REVIEW_PANE_ID
|
REVIEW_PANE_ID
|
||||||
} from '@/store/review'
|
} 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 { watchSessionPins } from '@/store/session-pin-sync'
|
||||||
import { $botChatScopes } from '@/store/session-states'
|
import { $botChatScopes } from '@/store/session-states'
|
||||||
import { watchUnreadWriteGuard } from '@/store/session-unread-remote'
|
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 { watchPreviewTiles } from '../chat/preview-tile'
|
||||||
import { watchRouteTiles } from '../chat/route-tile'
|
import { watchRouteTiles } from '../chat/route-tile'
|
||||||
import { startSessionDrag } from '../chat/session-drag'
|
import { startSessionDrag } from '../chat/session-drag'
|
||||||
|
import { SessionHistoryNav } from '../chat/session-history-nav'
|
||||||
import {
|
import {
|
||||||
SessionTileCloseConfirm,
|
SessionTileCloseConfirm,
|
||||||
stackSessionTilesIntoMain,
|
stackSessionTilesIntoMain,
|
||||||
@@ -92,6 +99,8 @@ import {
|
|||||||
watchSessionTiles,
|
watchSessionTiles,
|
||||||
WorkspaceTabMenu
|
WorkspaceTabMenu
|
||||||
} from '../chat/session-tile'
|
} 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 { AppContextMenu } from '../context-menu/app-context-menu'
|
||||||
import { HudShell } from '../hud/hud-shell'
|
import { HudShell } from '../hud/hud-shell'
|
||||||
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
|
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) : '' }
|
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
|
// 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
|
// chat, on a zone/edge to stack/split. Defers (`false`) to the generic pane
|
||||||
// move when there's no loaded session to carry.
|
// move when there's no loaded session to carry.
|
||||||
@@ -178,7 +201,12 @@ registry.registerMany([
|
|||||||
hideOnly: true,
|
hideOnly: true,
|
||||||
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
width: `${SIDEBAR_DEFAULT_WIDTH}px`,
|
||||||
minWidth: `${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" />
|
render: () => <WiredPane part="sidebar" />
|
||||||
},
|
},
|
||||||
@@ -188,8 +216,10 @@ registry.registerMany([
|
|||||||
// Live-retitled to the loaded session by syncWorkspaceTitle below.
|
// Live-retitled to the loaded session by syncWorkspaceTitle below.
|
||||||
title: NEW_SESSION_TITLE,
|
title: NEW_SESSION_TITLE,
|
||||||
data: {
|
data: {
|
||||||
|
headerSurface: 'chat',
|
||||||
placement: 'main',
|
placement: 'main',
|
||||||
minWidth: '22vw',
|
minWidth: '22vw',
|
||||||
|
stripLead: () => <SessionHistoryNav />,
|
||||||
tabDrag: workspaceTabDrag,
|
tabDrag: workspaceTabDrag,
|
||||||
tabWrap: wrapWorkspaceTab,
|
tabWrap: wrapWorkspaceTab,
|
||||||
uncloseable: true
|
uncloseable: true
|
||||||
@@ -230,7 +260,9 @@ registry.registerMany([
|
|||||||
revealAliases: ['file-browser'],
|
revealAliases: ['file-browser'],
|
||||||
width: FILE_BROWSER_DEFAULT_WIDTH,
|
width: FILE_BROWSER_DEFAULT_WIDTH,
|
||||||
minWidth: FILE_BROWSER_MIN_WIDTH,
|
minWidth: FILE_BROWSER_MIN_WIDTH,
|
||||||
maxWidth: FILE_BROWSER_MAX_WIDTH
|
maxWidth: FILE_BROWSER_MAX_WIDTH,
|
||||||
|
titlebar: 'content',
|
||||||
|
tabTitle: () => <FilesTabTitle />
|
||||||
},
|
},
|
||||||
render: () => idle(<FilesPane />)
|
render: () => idle(<FilesPane />)
|
||||||
},
|
},
|
||||||
@@ -364,15 +396,6 @@ registry.registerMany([
|
|||||||
get: () => $statusbarVisible.get(),
|
get: () => $statusbarVisible.get(),
|
||||||
set: enabled => $statusbarVisible.set(enabled)
|
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({
|
paletteToggle({
|
||||||
id: 'view.toggleTabStrip',
|
id: 'view.toggleTabStrip',
|
||||||
label: 'Toggle tabs',
|
label: 'Toggle tabs',
|
||||||
@@ -484,6 +507,7 @@ const syncWorkspaceTitle = () => {
|
|||||||
selected ? $botChatScopes.get()[selected] : undefined
|
selected ? $botChatScopes.get()[selected] : undefined
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
|
headerSurface: 'chat',
|
||||||
// The tab's status dot — the SAME primitive the sidebar row and session
|
// 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
|
// 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
|
// fresh draft has no session to key by, which IS its status: the dot
|
||||||
@@ -497,6 +521,7 @@ const syncWorkspaceTitle = () => {
|
|||||||
headerVeto: $workspaceIsPage.get(),
|
headerVeto: $workspaceIsPage.get(),
|
||||||
placement: 'main',
|
placement: 'main',
|
||||||
minWidth: '22vw',
|
minWidth: '22vw',
|
||||||
|
stripLead: () => <SessionHistoryNav />,
|
||||||
tabDrag: workspaceTabDrag,
|
tabDrag: workspaceTabDrag,
|
||||||
tabWrap: wrapWorkspaceTab,
|
tabWrap: wrapWorkspaceTab,
|
||||||
uncloseable: true
|
uncloseable: true
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
|||||||
// zone. Review collapses to nothing while its pane is hidden (⌘G off).
|
// 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:
|
// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one:
|
||||||
// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands
|
// they're registered by watchPreviewTiles as tabs open and join the files pane's
|
||||||
// each one directly beside the file tree wherever that currently lives — so a
|
// tab group wherever that pane currently lives. Layouts without a files pane
|
||||||
// file double-click still slides a preview open as its own pane next to the
|
// fall back to a preview-only zone beside the main workspace.
|
||||||
// tree, never as a tab stacked into the files sidebar.
|
|
||||||
export const DEFAULT_TREE = split(
|
export const DEFAULT_TREE = split(
|
||||||
'row',
|
'row',
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import { ModelPickerOverlay } from '../model-picker-overlay'
|
|||||||
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
|
||||||
import { mainChatOccupied, openSession, openSessionForOwner } from '../open-session'
|
import { mainChatOccupied, openSession, openSessionForOwner } from '../open-session'
|
||||||
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
|
import { PetGenerateOverlay } from '../pet-generate/pet-generate-overlay'
|
||||||
|
import { ProfileCreateHost } from '../profiles/profile-create-host'
|
||||||
import { FileActionDialogs } from '../right-sidebar/file-actions'
|
import { FileActionDialogs } from '../right-sidebar/file-actions'
|
||||||
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
||||||
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
|
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
|
||||||
@@ -1307,7 +1308,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||||||
{!isHudWindow() && !isBrowserWindow() && (
|
{!isHudWindow() && !isBrowserWindow() && (
|
||||||
<TitlebarControls
|
<TitlebarControls
|
||||||
leftTools={leftTitlebarTools}
|
leftTools={leftTitlebarTools}
|
||||||
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
|
|
||||||
tools={rightTitlebarTools}
|
tools={rightTitlebarTools}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1451,6 +1451,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
{/* Backs confirm() from @/store/confirm — renders only while one is open. */}
|
{/* Backs confirm() from @/store/confirm — renders only while one is open. */}
|
||||||
<ConfirmHost />
|
<ConfirmHost />
|
||||||
|
{!isAuxiliaryWindow() && <ProfileCreateHost />}
|
||||||
|
|
||||||
{/* Send Diagnostics consent/upload dialog — driven by $sendDiagnostics
|
{/* Send Diagnostics consent/upload dialog — driven by $sendDiagnostics
|
||||||
(error card action); renders nothing until requested. */}
|
(error card action); renders nothing until requested. */}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import {
|
|||||||
switchToDefaultProfile,
|
switchToDefaultProfile,
|
||||||
toggleShowAllProfiles
|
toggleShowAllProfiles
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { toggleProfileRailVisible } from '@/store/profile-rail-prefs'
|
|
||||||
import { openFolderAsProject } from '@/store/projects'
|
import { openFolderAsProject } from '@/store/projects'
|
||||||
import { toggleReview } from '@/store/review'
|
import { toggleReview } from '@/store/review'
|
||||||
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
|
import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session'
|
||||||
@@ -250,7 +249,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'),
|
layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'),
|
||||||
'view.toggleReview': toggleReview,
|
'view.toggleReview': toggleReview,
|
||||||
'view.toggleStatusbar': toggleStatusbarVisible,
|
'view.toggleStatusbar': toggleStatusbarVisible,
|
||||||
'view.toggleProfileRail': toggleProfileRailVisible,
|
|
||||||
'view.toggleTabStrip': () => void toggleTargetZoneTabStrip(),
|
'view.toggleTabStrip': () => void toggleTargetZoneTabStrip(),
|
||||||
'view.showFiles': showFiles,
|
'view.showFiles': showFiles,
|
||||||
'view.showBrowser': openBrowserTab,
|
'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. */
|
* when the backend the app talks to swaps underneath a still-mounted view. */
|
||||||
export function useOnProfileSwitch(onSwitch: () => void): void {
|
export function useOnProfileSwitch(onSwitch: () => void): void {
|
||||||
const profile = useStore($activeGatewayProfile)
|
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)
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (first.current) {
|
if (seen.current === profile) {
|
||||||
first.current = false
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seen.current = profile
|
||||||
onSwitch()
|
onSwitch()
|
||||||
// Fire on profile change only; onSwitch identity is intentionally ignored.
|
// Fire on profile change only; onSwitch identity is intentionally ignored.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,14 @@ const PROFILE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function signedIn(overrides: Record<string, unknown> = {}) {
|
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> = {}) {
|
function stubBridge(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -177,7 +184,12 @@ describe('MePage', () => {
|
|||||||
|
|
||||||
it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
|
it('机器码随绑定状态展示(已绑定与未绑定同口径)', async () => {
|
||||||
stubBridge({
|
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()
|
signedIn()
|
||||||
render(<MePage />)
|
render(<MePage />)
|
||||||
@@ -204,7 +216,7 @@ describe('MePage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('UserAccountCard', () => {
|
describe('UserAccountCard', () => {
|
||||||
it('signed_in:渲染展示名+邮箱;未登录不渲染', () => {
|
it('signed_in:只渲染展示名;未登录不渲染', () => {
|
||||||
stubBridge()
|
stubBridge()
|
||||||
signedIn()
|
signedIn()
|
||||||
|
|
||||||
@@ -215,7 +227,6 @@ describe('UserAccountCard', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(screen.getByText('小赫')).toBeTruthy()
|
expect(screen.getByText('小赫')).toBeTruthy()
|
||||||
expect(screen.getByText('u@example.com')).toBeTruthy()
|
|
||||||
unmount()
|
unmount()
|
||||||
|
|
||||||
$userAccount.set({ status: 'signed_out' })
|
$userAccount.set({ status: 'signed_out' })
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
|||||||
|
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
alt={name}
|
alt={name}
|
||||||
copy={t.desktop}
|
|
||||||
onClick={download}
|
onClick={download}
|
||||||
onOpenChange={setViewing}
|
onOpenChange={setViewing}
|
||||||
open={viewing}
|
open={viewing}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ vi.mock('@/components/chat/code-editor', () => ({
|
|||||||
CodeEditor: () => null
|
CodeEditor: () => null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../chat/sidebar/profile-switcher', () => ({ ProfileRail: () => <div data-testid="profile-controls" /> }))
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
createProfile: vi.fn(async () => ({ name: 'x', ok: true, path: '/x' })),
|
createProfile: vi.fn(async () => ({ name: 'x', ok: true, path: '/x' })),
|
||||||
deleteProfile: vi.fn(async () => ({ ok: true, path: '/x' })),
|
deleteProfile: vi.fn(async () => ({ ok: true, path: '/x' })),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { normalize } from '@/lib/text'
|
|||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { $profileColors, profileLabel, refreshProfiles } from '@/store/profile'
|
import { $profileColors, profileLabel, refreshProfiles } from '@/store/profile'
|
||||||
|
|
||||||
|
import { ProfileRail } from '../chat/sidebar/profile-switcher'
|
||||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||||
import {
|
import {
|
||||||
Panel,
|
Panel,
|
||||||
@@ -120,6 +121,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PanelHeader subtitle={p.count(profiles.length)} title={p.title} />
|
<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>
|
<PanelBody>
|
||||||
<PanelList
|
<PanelList
|
||||||
onSearchChange={setQuery}
|
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() }))
|
const { openFileWithSystemApp } = vi.hoisted(() => ({ openFileWithSystemApp: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('@/lib/office-files', () => ({
|
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
|
openFileWithSystemApp
|
||||||
}))
|
}))
|
||||||
vi.mock('../file-actions', async importOriginal => {
|
vi.mock('../file-actions', async importOriginal => {
|
||||||
@@ -58,12 +59,30 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ProjectTreeRow double-click', () => {
|
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')
|
const { onPreviewFile, view } = renderRow('/w/report.docx')
|
||||||
|
|
||||||
fireEvent.doubleClick(view.container.querySelector('[title="/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()
|
expect(onPreviewFile).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TreeSkeleton } from '@/components/chat/skeletons'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { markRightPanePerf } from '@/debug/right-pane-events'
|
import { markRightPanePerf } from '@/debug/right-pane-events'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
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 { cn } from '@/lib/utils'
|
||||||
import { type RepoChangeKind, repoChangeKindForPath } from '@/store/coding-status'
|
import { type RepoChangeKind, repoChangeKindForPath } from '@/store/coding-status'
|
||||||
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
|
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
|
||||||
@@ -327,9 +327,9 @@ export function ProjectTreeRow({
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
|
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
|
||||||
// Office documents have no inline renderer — hand them to the OS
|
// Only Office formats the preview pane can't render (legacy .doc/.ppt
|
||||||
// default application instead of the (useless) binary refusal page.
|
// etc.) go to the OS default application; .docx/.pptx preview inline.
|
||||||
if (isOfficeFilePath(node.data.id)) {
|
if (isOfficeFilePath(node.data.id) && !inlineOfficeFormat(node.data.id)) {
|
||||||
void openFileWithSystemApp(node.data.id)
|
void openFileWithSystemApp(node.data.id)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSide
|
|||||||
<aside
|
<aside
|
||||||
aria-label={r.aria}
|
aria-label={r.aria}
|
||||||
className={cn(
|
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
|
panesFlipped
|
||||||
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
? '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)]'
|
: '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
|
const workspaceTarget = hasWorkspaceTarget
|
||||||
? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget)
|
? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget)
|
||||||
: undefined
|
: 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()
|
resetViewSync()
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
@@ -525,11 +529,11 @@ export function useSessionActions({
|
|||||||
setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined)
|
setNewChatWorkspaceTarget(hasWorkspaceTarget ? workspaceTarget : undefined)
|
||||||
|
|
||||||
if (!hasWorkspaceTarget) {
|
if (!hasWorkspaceTarget) {
|
||||||
// In a project → the repo's default-branch checkout; not in a project →
|
// In a project → the repo's default-branch checkout; outside a project
|
||||||
// detached. So cmd-n does not inherit an unrelated linked worktree.
|
// → the previous conversation's workspace, then the configured default.
|
||||||
// Transient: a resolved default is not the user naming a workspace, and
|
// Transient: a resolved default is not the user naming a workspace, and
|
||||||
// remembering it here would make the NEXT new chat inherit it.
|
// remembering it here would make the NEXT new chat inherit it.
|
||||||
setCurrentCwdTransient(resolveNewSessionCwd())
|
setCurrentCwdTransient(resolvedWorkspaceCwd)
|
||||||
} else if (workspaceTarget === null) {
|
} else if (workspaceTarget === null) {
|
||||||
setCurrentCwdTransient('')
|
setCurrentCwdTransient('')
|
||||||
} else if (typeof workspaceTarget === 'string') {
|
} 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload per target profile; copy is stable
|
||||||
}, [profile])
|
}, [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'>>) {
|
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
|
||||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||||
}
|
}
|
||||||
@@ -186,6 +200,7 @@ export function useEnvCredentials(profile?: string): UseEnvCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
refresh,
|
||||||
saveValue,
|
saveValue,
|
||||||
vars,
|
vars,
|
||||||
rowProps: {
|
rowProps: {
|
||||||
@@ -207,6 +222,7 @@ interface CategoryHeadingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseEnvCredentials {
|
interface UseEnvCredentials {
|
||||||
|
refresh: () => Promise<void>
|
||||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||||
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
||||||
vars: Record<string, EnvVarInfo> | null
|
vars: Record<string, EnvVarInfo> | null
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { notify } from '@/store/notifications'
|
||||||
|
|
||||||
import { FreeModel2ApiSettings } from './freemodel2api-settings'
|
import { FreeModel2ApiSettings } from './freemodel2api-settings'
|
||||||
|
|
||||||
const fm2 = {
|
const fm2 = {
|
||||||
@@ -9,25 +11,86 @@ const fm2 = {
|
|||||||
keys: vi.fn(),
|
keys: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
rememberedLoad: vi.fn(),
|
rememberedLoad: vi.fn(),
|
||||||
rememberedSave: vi.fn()
|
rememberedSave: vi.fn(),
|
||||||
|
keyBalance: vi.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveCustomEndpoint = vi.fn()
|
|
||||||
const validateCustomEndpoint = 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', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
saveCustomEndpoint: (...args: any[]) => saveCustomEndpoint(...args),
|
getApiRequestProfile: () => 'default',
|
||||||
validateCustomEndpoint: (...args: any[]) => validateCustomEndpoint(...args)
|
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('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
|
||||||
vi.mock('@/lib/haptics', () => ({ triggerHaptic: 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 }
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
fm2.rememberedLoad.mockResolvedValue(null)
|
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 }
|
;(window as any).hermesDesktop = { ...(window as any).hermesDesktop, freemodel2api: fm2 }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -37,16 +100,42 @@ describe('FreeModel2ApiSettings', () => {
|
|||||||
it('shows login form when not configured', async () => {
|
it('shows login form when not configured', async () => {
|
||||||
fm2.status.mockResolvedValue({ configured: false })
|
fm2.status.mockResolvedValue({ configured: false })
|
||||||
render(<FreeModel2ApiSettings />)
|
render(<FreeModel2ApiSettings />)
|
||||||
await screen.findByLabelText(/base url/i)
|
await screen.findByLabelText(/FreeModelAPI URL/i)
|
||||||
expect(screen.getByLabelText(/email/i)).toBeTruthy()
|
expect(screen.getByLabelText(/用户\(邮箱\)/)).toBeTruthy()
|
||||||
expect(screen.getByLabelText(/password/i)).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 () => {
|
it('auto-connects from stored credentials', async () => {
|
||||||
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
|
fm2.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 3 })
|
||||||
fm2.keys.mockResolvedValue({ keys: [] })
|
fm2.keys.mockResolvedValue({ keys: [] })
|
||||||
render(<FreeModel2ApiSettings />)
|
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()
|
expect(screen.getByText(/no api keys/i)).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,52 +145,261 @@ describe('FreeModel2ApiSettings', () => {
|
|||||||
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
||||||
|
|
||||||
render(<FreeModel2ApiSettings />)
|
render(<FreeModel2ApiSettings />)
|
||||||
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com/' } })
|
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://sub.example.com/' } })
|
||||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
|
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
|
||||||
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
|
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
|
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 })
|
const select = await screen.findByRole('combobox', { name: /api key/i })
|
||||||
// 余额必须在 key 下拉列表上方
|
// 余额必须在 key 下拉列表上方
|
||||||
expect(balance.compareDocumentPosition(select) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
expect(balance.compareDocumentPosition(select) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
// 下拉项显示名称 + 首尾片段,不露完整 key
|
// 下拉项显示名称 + 状态;掩码在下面那行,完整 key 哪里都不出现
|
||||||
const option = screen.getByRole('option', { name: /main/ })
|
const option = screen.getByRole('option', { name: /main/ })
|
||||||
expect(option.textContent).toContain('sk-ful')
|
expect(option.textContent).toContain('可用')
|
||||||
expect(option.textContent).toContain('1234')
|
|
||||||
expect(option.textContent).not.toContain('sk-full-value-1234')
|
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.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 9.5 })
|
||||||
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
||||||
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4', 'claude-x'] })
|
validateCustomEndpoint.mockResolvedValue({ ok: true, reachable: true, message: '', models: ['gpt-5.4', 'claude-x'] })
|
||||||
saveCustomEndpoint.mockResolvedValue({ ok: true, endpoints: [], current: null })
|
|
||||||
|
|
||||||
render(<FreeModel2ApiSettings />)
|
render(<FreeModel2ApiSettings onConfigSaved={onConfigSaved} />)
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /test models/i }))
|
|
||||||
|
|
||||||
|
// 不点任何按钮,选中 key 就位即自动拉取
|
||||||
await waitFor(() => expect(validateCustomEndpoint).toHaveBeenCalled())
|
await waitFor(() => expect(validateCustomEndpoint).toHaveBeenCalled())
|
||||||
expect(validateCustomEndpoint.mock.calls[0][0]).toMatchObject({
|
expect(validateCustomEndpoint.mock.calls[0][0]).toMatchObject({
|
||||||
base_url: 'https://sub.example.com/v1',
|
base_url: 'https://sub.example.com/v1',
|
||||||
api_key: 'sk-full-value-1234'
|
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 }))
|
// 该 key 的模型只是「显示」:keys 区没有勾选框,也没有主模型下拉
|
||||||
await waitFor(() => expect(saveCustomEndpoint).toHaveBeenCalled())
|
// (页面顶部的「记住用户名密码」勾选框不属于这个 section)
|
||||||
expect(saveCustomEndpoint.mock.calls[0][0]).toMatchObject({
|
await screen.findAllByText('gpt-5.4')
|
||||||
id: 'FreeModel2API',
|
await screen.findAllByText('claude-x')
|
||||||
name: 'FreeModel2API',
|
const keysSection = screen.getByRole('combobox', { name: /api key/i }).closest('section')
|
||||||
base_url: 'https://sub.example.com/v1',
|
expect(keysSection?.querySelectorAll('input[type="checkbox"]').length).toBe(0)
|
||||||
api_key: 'sk-full-value-1234',
|
expect(screen.queryByRole('combobox', { name: /main model/i })).toBeNull()
|
||||||
model: 'gpt-5.4',
|
|
||||||
models: ['gpt-5.4', 'claude-x'],
|
fireEvent.click(screen.getByRole('button', { name: /保存并设为默认/ }))
|
||||||
discover_models: true,
|
await waitFor(() => expect(setEnvVar).toHaveBeenCalledTimes(2))
|
||||||
make_default: false
|
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 () => {
|
it('refreshes balance and keys when clicking refresh', async () => {
|
||||||
@@ -110,13 +408,13 @@ describe('FreeModel2ApiSettings', () => {
|
|||||||
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
fm2.keys.mockResolvedValue({ keys: [KEY] })
|
||||||
|
|
||||||
render(<FreeModel2ApiSettings />)
|
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.status.mockResolvedValue({ configured: true, baseUrl: 'https://sub.example.com', email: 'u@example.com', balance: 12.5 })
|
||||||
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
|
fm2.keys.mockResolvedValue({ keys: [KEY, KEY2] })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /refresh/i }))
|
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())
|
await waitFor(() => expect(screen.getByRole('option', { name: /backup/ })).toBeTruthy())
|
||||||
// 已选中的 key 仍然存在时保持选中
|
// 已选中的 key 仍然存在时保持选中
|
||||||
expect((screen.getByRole('combobox', { name: /api key/i }) as HTMLSelectElement).value).toBe('7')
|
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'))
|
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.status.mockResolvedValue({ configured: false })
|
||||||
fm2.rememberedLoad.mockResolvedValue({ baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123' })
|
fm2.rememberedLoad.mockResolvedValue({ baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw-123' })
|
||||||
|
|
||||||
render(<FreeModel2ApiSettings />)
|
render(<FreeModel2ApiSettings />)
|
||||||
await waitFor(() => expect((screen.getByLabelText(/base url/i) as HTMLInputElement).value).toBe('https://sub.example.com'))
|
await waitFor(() => expect((screen.getByLabelText(/FreeModelAPI 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(/用户\(邮箱\)/) 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)
|
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.status.mockResolvedValue({ configured: false })
|
||||||
fm2.login.mockResolvedValue({ ok: true, email: 'u@example.com', balance: 1 })
|
fm2.login.mockResolvedValue({ ok: true, email: 'u@example.com', balance: 1 })
|
||||||
fm2.keys.mockResolvedValue({ keys: [] })
|
fm2.keys.mockResolvedValue({ keys: [] })
|
||||||
|
|
||||||
const { unmount } = render(<FreeModel2ApiSettings />)
|
// 勾着(默认):登录后落盘
|
||||||
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com' } })
|
const first = render(<FreeModel2ApiSettings />)
|
||||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
|
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://sub.example.com' } })
|
||||||
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
|
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
|
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
|
||||||
|
|
||||||
await waitFor(() => expect(fm2.rememberedSave).toHaveBeenCalledWith({
|
await waitFor(() => expect(fm2.rememberedSave).toHaveBeenCalledWith({
|
||||||
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw'
|
baseUrl: 'https://sub.example.com', email: 'u@example.com', password: 'pw'
|
||||||
}))
|
}))
|
||||||
unmount()
|
first.unmount()
|
||||||
|
|
||||||
// 取消勾选后连接 → 清除记住的登录信息
|
// 取消勾选:这一次登录不落盘(没 Disconnect,只能换个新渲染从没登录态开始)
|
||||||
fm2.rememberedSave.mockClear()
|
fm2.rememberedSave.mockClear()
|
||||||
|
fm2.login.mockClear()
|
||||||
render(<FreeModel2ApiSettings />)
|
render(<FreeModel2ApiSettings />)
|
||||||
fireEvent.change(await screen.findByLabelText(/base url/i), { target: { value: 'https://sub.example.com' } })
|
fireEvent.change(await screen.findByLabelText(/FreeModelAPI URL/i), { target: { value: 'https://other.example.com' } })
|
||||||
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'u@example.com' } })
|
fireEvent.change(screen.getByLabelText(/用户\(邮箱\)/), { target: { value: 'u@example.com' } })
|
||||||
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } })
|
fireEvent.change(screen.getByLabelText(/FreeModelAPI 密码/i), { target: { value: 'pw' } })
|
||||||
fireEvent.click(screen.getByLabelText(/remember/i))
|
fireEvent.click(screen.getByRole('checkbox', { name: /记住用户名密码/ }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: /connect/i }))
|
fireEvent.click(screen.getByRole('button', { name: /登录 FreeModelAPI/ }))
|
||||||
await waitFor(() => expect(fm2.rememberedSave).toHaveBeenCalledWith(null))
|
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
@@ -22,6 +22,7 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
ShieldLock,
|
ShieldLock,
|
||||||
Upload,
|
Upload,
|
||||||
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
Zap
|
Zap
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
@@ -101,7 +102,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
}, [activeView, setActiveView])
|
}, [activeView, setActiveView])
|
||||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||||
// sub-view is deep-linkable and survives a refresh.
|
// 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')
|
const [keysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
|
||||||
|
|
||||||
// Jump to a section + its sub-view in one navigate. Two sequential setters
|
// 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(
|
const openProviderView = useCallback(
|
||||||
(view: ProviderView) => openSubView('providers', 'pview', view, 'custom-endpoints'),
|
(view: ProviderView) => openSubView('providers', 'pview', view, 'freemodel2api'),
|
||||||
[openSubView]
|
[openSubView]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -216,6 +217,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||||||
{
|
{
|
||||||
active: activeView === 'providers',
|
active: activeView === 'providers',
|
||||||
children: [
|
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',
|
active: activeView === 'providers' && providerView === 'custom-endpoints',
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ vi.mock('@/hermes', () => ({
|
|||||||
getApiRequestProfile: () => 'default',
|
getApiRequestProfile: () => 'default',
|
||||||
getMoaModels: (profile?: null | string) => getMoaModels(profile),
|
getMoaModels: (profile?: null | string) => getMoaModels(profile),
|
||||||
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
|
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
|
||||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
setModelAssignment: (body: unknown, profile?: null | string) =>
|
||||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
profile == null ? setModelAssignment(body) : setModelAssignment(body, profile),
|
||||||
|
getRecommendedDefaultModel: (slug: string, profile?: null | string) => getRecommendedDefaultModel(slug, profile),
|
||||||
saveMoaModels: (body: unknown) => saveMoaModels(body),
|
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(),
|
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
|
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
|
||||||
setApiRequestProfile: () => {}
|
setApiRequestProfile: () => {}
|
||||||
@@ -199,6 +200,69 @@ describe('ModelSettings', () => {
|
|||||||
expect(startManualOnboarding).not.toHaveBeenCalled()
|
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 () => {
|
it('replaces the selected provider and model when the active profile changes', async () => {
|
||||||
getGlobalModelInfo
|
getGlobalModelInfo
|
||||||
.mockResolvedValueOnce({ provider: 'custom', model: 'local-a' })
|
.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
|
// Inline API-key entry for picking an unconfigured `api_key` provider in
|
||||||
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
|
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
|
||||||
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
||||||
|
const [apiBaseUrlDraft, setApiBaseUrlDraft] = useState('')
|
||||||
const [activating, setActivating] = useState(false)
|
const [activating, setActivating] = useState(false)
|
||||||
|
|
||||||
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
|
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
|
||||||
@@ -381,6 +382,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
setSelectedProvider('')
|
setSelectedProvider('')
|
||||||
setSelectedModel('')
|
setSelectedModel('')
|
||||||
setApiKeyDraft('')
|
setApiKeyDraft('')
|
||||||
|
setApiBaseUrlDraft('')
|
||||||
void refresh({ replaceSelection: true })
|
void refresh({ replaceSelection: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -417,9 +419,13 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
const needsSetup = optionsLoaded && !!selectedProvider && !isProviderReady(selectedProviderRow)
|
const needsSetup = optionsLoaded && !!selectedProvider && !isProviderReady(selectedProviderRow)
|
||||||
const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env
|
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.
|
// Clear any half-typed key when switching provider so it can't leak across.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setApiKeyDraft('')
|
setApiKeyDraft('')
|
||||||
|
setApiBaseUrlDraft('')
|
||||||
}, [selectedProvider])
|
}, [selectedProvider])
|
||||||
|
|
||||||
const auxDraftProviderModels = useMemo(
|
const auxDraftProviderModels = useMemo(
|
||||||
@@ -634,7 +640,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
const keyEnv = selectedProviderRow?.key_env
|
const keyEnv = selectedProviderRow?.key_env
|
||||||
const slug = selectedProviderRow?.slug
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +653,13 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await setEnvVar(keyEnv, apiKeyDraft.trim(), scopeProfile)
|
await setEnvVar(keyEnv, apiKeyDraft.trim(), scopeProfile)
|
||||||
|
|
||||||
|
if (baseUrlEnv) {
|
||||||
|
await setEnvVar(baseUrlEnv, apiBaseUrlDraft.trim(), scopeProfile)
|
||||||
|
}
|
||||||
|
|
||||||
setApiKeyDraft('')
|
setApiKeyDraft('')
|
||||||
|
setApiBaseUrlDraft('')
|
||||||
|
|
||||||
// Pick a sensible default for the freshly-activated provider (mirrors
|
// Pick a sensible default for the freshly-activated provider (mirrors
|
||||||
// `hermes model` curation). Best-effort — fall through to the refreshed
|
// `hermes model` curation). Best-effort — fall through to the refreshed
|
||||||
@@ -658,7 +673,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
nextModel = ''
|
nextModel = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await getGlobalModelOptions(undefined, scopeProfile)
|
const options = await getGlobalModelOptions({ refresh: true }, scopeProfile)
|
||||||
|
|
||||||
if (profileEpoch.current !== epoch) {
|
if (profileEpoch.current !== epoch) {
|
||||||
return
|
return
|
||||||
@@ -667,13 +682,50 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
|
|||||||
setProviders(options.providers || [])
|
setProviders(options.providers || [])
|
||||||
const refreshedRow = options.providers?.find(p => p.slug === slug)
|
const refreshedRow = options.providers?.find(p => p.slug === slug)
|
||||||
const fallbackModel = refreshedRow?.models?.[0] ?? ''
|
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) {
|
} catch (err) {
|
||||||
setCaughtError(err, m.loadFailed)
|
setCaughtError(err, m.loadFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setActivating(false)
|
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
|
// 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
|
// 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"
|
type="password"
|
||||||
value={apiKeyDraft}
|
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
|
<Button
|
||||||
disabled={!apiKeyDraft.trim() || activating}
|
disabled={!apiKeyDraft.trim() || (setupBaseUrlEnv ? !apiBaseUrlDraft.trim() : false) || activating}
|
||||||
onClick={() => void activateApiKeyProvider()}
|
onClick={() => void activateApiKeyProvider()}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
|||||||
|
|
||||||
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
|
||||||
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
import { CustomEndpointsSettings } from './custom-endpoints-settings'
|
||||||
import { FreeModel2ApiSettings } from './freemodel2api-settings'
|
|
||||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||||
|
import { FreeModel2ApiSettings } from './freemodel2api-settings'
|
||||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||||
import { LocalModelsSettings } from './local-models-settings'
|
import { LocalModelsSettings } from './local-models-settings'
|
||||||
import { SettingsContent, SettingsSkeleton } from './primitives'
|
import { SettingsContent, SettingsSkeleton } from './primitives'
|
||||||
@@ -356,7 +356,7 @@ export function ProvidersSettings({
|
|||||||
}: ProvidersSettingsProps) {
|
}: ProvidersSettingsProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const scopeProfile = useStore($settingsRequestProfile)
|
const scopeProfile = useStore($settingsRequestProfile)
|
||||||
const { rowProps, vars } = useEnvCredentials(scopeProfile)
|
const { refresh: refreshEnvVars, rowProps, vars } = useEnvCredentials(scopeProfile)
|
||||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||||
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
||||||
@@ -525,7 +525,18 @@ export function ProvidersSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view === 'freemodel2api') {
|
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') {
|
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 { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { ConnectionSwitcher } from '@/app/chat/sidebar/connection-switcher'
|
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 type { CommandCenterSection } from '@/app/command-center'
|
||||||
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
|
import { ContextUsagePanel } from '@/app/shell/context-usage-panel'
|
||||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
Command,
|
Command,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
Hash,
|
|
||||||
Layers3,
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -32,18 +30,15 @@ import { runtimeReadinessDisplay, type RuntimeReadinessResult } from '@/lib/runt
|
|||||||
import { cacheHitLabel, contextBarLabel, LiveDuration, tokensPerSecondLabel, usageContextLabel } from '@/lib/statusbar'
|
import { cacheHitLabel, contextBarLabel, LiveDuration, tokensPerSecondLabel, usageContextLabel } from '@/lib/statusbar'
|
||||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { resolveVersionStatus } from '@/lib/version-status'
|
|
||||||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||||
import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier'
|
import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier'
|
||||||
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
||||||
import { revealFileInTree } from '@/store/layout'
|
import { revealFileInTree } from '@/store/layout'
|
||||||
import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate'
|
import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate'
|
||||||
import { $profileRailVisible } from '@/store/profile-rail-prefs'
|
|
||||||
import { $projectTree, projectNameForCwd } from '@/store/projects'
|
import { $projectTree, projectNameForCwd } from '@/store/projects'
|
||||||
import {
|
import {
|
||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
$busy,
|
$busy,
|
||||||
$connection,
|
|
||||||
$currentCwd,
|
$currentCwd,
|
||||||
$currentUsage,
|
$currentUsage,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
@@ -57,14 +52,6 @@ import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from
|
|||||||
import { $statusbarHiddenIds } from '@/store/statusbar-prefs'
|
import { $statusbarHiddenIds } from '@/store/statusbar-prefs'
|
||||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||||
import { $gatewayRestarting } from '@/store/system-actions'
|
import { $gatewayRestarting } from '@/store/system-actions'
|
||||||
import {
|
|
||||||
$backendUpdateApply,
|
|
||||||
$backendUpdateStatus,
|
|
||||||
$desktopVersion,
|
|
||||||
$updateApply,
|
|
||||||
$updateStatus,
|
|
||||||
openUpdateOverlayFor
|
|
||||||
} from '@/store/updates'
|
|
||||||
import type { StatusResponse, UsageStats } from '@/types/hermes'
|
import type { StatusResponse, UsageStats } from '@/types/hermes'
|
||||||
|
|
||||||
import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes'
|
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.
|
// minimized zone, which lit the button for a pane the user couldn't see.
|
||||||
const terminalShowing = useStore($paneVisible('terminal'))
|
const terminalShowing = useStore($paneVisible('terminal'))
|
||||||
const sessionsShowing = useStore($paneVisible('sessions'))
|
const sessionsShowing = useStore($paneVisible('sessions'))
|
||||||
const profileRailVisible = useStore($profileRailVisible)
|
|
||||||
const botsShowing = useStore($paneVisible('hermes-bots:pane'))
|
const botsShowing = useStore($paneVisible('hermes-bots:pane'))
|
||||||
const primaryBusy = useStore($busy)
|
const primaryBusy = useStore($busy)
|
||||||
// Draft / primary composer atom — used only while the focused surface is the
|
// 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.
|
// subscription is what makes the check reactive.
|
||||||
useStore($onboardingGate)
|
useStore($onboardingGate)
|
||||||
const guideOwnsSignIn = guidedOnboardingActive()
|
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
|
// The FOCUSED session (interacted tile, else the primary — the same
|
||||||
// derivation the titlebar title follows): every session-scoped readout
|
// derivation the titlebar title follows): every session-scoped readout
|
||||||
@@ -341,93 +321,6 @@ export function useStatusbarItems({
|
|||||||
? 'text-amber-600 hover:text-amber-600'
|
? 'text-amber-600 hover:text-amber-600'
|
||||||
: 'text-destructive hover:text-destructive'
|
: '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[]>(
|
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -448,14 +341,6 @@ export function useStatusbarItems({
|
|||||||
lockedVisible: true,
|
lockedVisible: true,
|
||||||
render: () => <StatusbarGatewaySwitcher />
|
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,
|
className: gatewayRestarting ? undefined : gatewayClassName,
|
||||||
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
|
detail: gatewayRestarting ? copy.gatewayRestarting : gatewayDetail,
|
||||||
@@ -609,7 +494,6 @@ export function useStatusbarItems({
|
|||||||
inferenceReady,
|
inferenceReady,
|
||||||
inferenceStatus?.reason,
|
inferenceStatus?.reason,
|
||||||
openAgents,
|
openAgents,
|
||||||
profileRailVisible,
|
|
||||||
projectName,
|
projectName,
|
||||||
sessionsShowing,
|
sessionsShowing,
|
||||||
subagentsFailed,
|
subagentsFailed,
|
||||||
@@ -682,16 +566,12 @@ export function useStatusbarItems({
|
|||||||
title: terminalShowing ? copy.hideTerminal : copy.showTerminal,
|
title: terminalShowing ? copy.hideTerminal : copy.showTerminal,
|
||||||
toggleLabel: copy.toggleTerminal,
|
toggleLabel: copy.toggleTerminal,
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
},
|
}
|
||||||
clientVersionItem,
|
|
||||||
...(backendVersionItem ? [backendVersionItem] : [])
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
backendVersionItem,
|
|
||||||
busy,
|
busy,
|
||||||
cacheHit,
|
cacheHit,
|
||||||
chatOpen,
|
chatOpen,
|
||||||
clientVersionItem,
|
|
||||||
contextBar,
|
contextBar,
|
||||||
contextBreakdown,
|
contextBreakdown,
|
||||||
contextBreakdownLoading,
|
contextBreakdownLoading,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function renderControls(pathname: string, props?: { leftTools?: TitlebarTool[];
|
|||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[pathname]}>
|
<MemoryRouter initialEntries={[pathname]}>
|
||||||
<I18nProvider configClient={null} initialLocale="en">
|
<I18nProvider configClient={null} initialLocale="en">
|
||||||
<TitlebarControls leftTools={props?.leftTools} onOpenSettings={() => {}} tools={props?.tools} />
|
<TitlebarControls leftTools={props?.leftTools} tools={props?.tools} />
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
@@ -140,32 +140,36 @@ describe('titlebar app-action cluster', () => {
|
|||||||
cleanup()
|
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('/')
|
renderControls('/')
|
||||||
|
|
||||||
const left = screen.getByLabelText('Window controls')
|
const left = screen.getByLabelText('Window controls')
|
||||||
const right = screen.getByLabelText('App 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('Layout editor')).toBeTruthy()
|
||||||
expect(within(right).getByLabelText('HUD mode')).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('Open settings')).toBeNull()
|
||||||
expect(within(left).queryByLabelText('Layout editor')).toBeNull()
|
expect(within(left).queryByLabelText('Layout editor')).toBeNull()
|
||||||
expect(within(left).queryByLabelText('HUD mode')).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')
|
setTitlebarAppActionsSide('left')
|
||||||
renderControls('/')
|
renderControls('/')
|
||||||
|
|
||||||
const left = screen.getByLabelText('Window controls')
|
const left = screen.getByLabelText('Window controls')
|
||||||
const right = screen.getByLabelText('App 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('Layout editor')).toBeTruthy()
|
||||||
expect(within(left).getByLabelText('HUD mode')).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('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'> {
|
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||||
leftTools?: readonly TitlebarTool[]
|
leftTools?: readonly TitlebarTool[]
|
||||||
tools?: readonly TitlebarTool[]
|
tools?: readonly TitlebarTool[]
|
||||||
onOpenSettings: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,7 +130,7 @@ function useModifierHeld(): boolean {
|
|||||||
return held
|
return held
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
export function TitlebarControls({ leftTools = [], tools = [] }: TitlebarControlsProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -152,14 +151,14 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
const titleBarRight = useContributions('titleBar.right')
|
const titleBarRight = useContributions('titleBar.right')
|
||||||
const pageOwnsTitlebar = titleBarLeft.length + titleBarCenter.length + titleBarRight.length > 0
|
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
|
// side of the main zone (the layout tree collapses the whole side), so they
|
||||||
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
|
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
|
||||||
// $fileBrowserOpen ≙ right side. Never an active highlight — plain
|
// $fileBrowserOpen ≙ right side. The pane-swap action is fixed in the left
|
||||||
// show/hide affordances.
|
// titlebar cluster and is handled separately below.
|
||||||
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
const leftEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||||
const rightEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
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 rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar
|
||||||
|
|
||||||
const sidebarTool: TitlebarTool = {
|
const sidebarTool: TitlebarTool = {
|
||||||
@@ -198,19 +197,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
tour: 'right-pane-toggle'
|
tour: 'right-pane-toggle'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static system tools — always pinned to the screen's right edge so the
|
// Static system tools follow the configured app-action side. The pane-swap
|
||||||
// left titlebar stays free for tabs (#107351).
|
// control remains left-fixed so the titlebar keeps one stable navigation
|
||||||
|
// cluster regardless of that preference.
|
||||||
const systemTools: TitlebarTool[] = [
|
const systemTools: TitlebarTool[] = [
|
||||||
{
|
|
||||||
actionId: 'nav.settings',
|
|
||||||
icon: <TitlebarIcon name="settings-gear" />,
|
|
||||||
id: 'settings',
|
|
||||||
label: t.titlebar.openSettings,
|
|
||||||
onSelect: () => {
|
|
||||||
triggerHaptic('open')
|
|
||||||
onOpenSettings()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
className: 'group/tool',
|
className: 'group/tool',
|
||||||
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
|
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
|
||||||
@@ -289,7 +279,9 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visibleLeftTools = (
|
const visibleLeftTools = (
|
||||||
appActionsSide === 'left' ? [sidebarTool, ...systemTools, ...leftTools] : [sidebarTool, ...leftTools]
|
appActionsSide === 'left'
|
||||||
|
? [sidebarTool, flipTool, ...systemTools, ...leftTools]
|
||||||
|
: [sidebarTool, flipTool, ...leftTools]
|
||||||
).filter(tool => !tool.hidden)
|
).filter(tool => !tool.hidden)
|
||||||
|
|
||||||
const visibleSystemTools = appActionsSide === 'right' ? systemTools.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 => (
|
{visibleSystemTools.map(tool => (
|
||||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||||
))}
|
))}
|
||||||
<TitlebarToolButton navigate={navigate} tool={flipTool} />
|
|
||||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||||
<Slot area="titleBar.right" />
|
<Slot area="titleBar.right" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Backdrop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<img
|
||||||
alt=""
|
alt=""
|
||||||
className="h-[160dvh] w-auto min-w-dvw object-cover object-left-top [filter:invert(var(--backdrop-invert-mul,1))]"
|
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
|
<ImageLightbox
|
||||||
alt={address}
|
alt={address}
|
||||||
copy={t.desktop}
|
|
||||||
onClick={download}
|
onClick={download}
|
||||||
onOpenChange={open => setOpenAt(open ? position : null)}
|
onOpenChange={open => setOpenAt(open ? position : null)}
|
||||||
open={openAt !== null}
|
open={openAt !== null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type FC, useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
|
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
|
||||||
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
|
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
|
||||||
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { useImageDownload } from '@/hooks/use-image-download'
|
import { useImageDownload } from '@/hooks/use-image-download'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||||
@@ -133,32 +134,34 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{src && (
|
{src && (
|
||||||
<button
|
<Tip label={copy.openImage}>
|
||||||
className="absolute inset-0 block size-full cursor-zoom-in"
|
<button
|
||||||
onClick={() => setLightboxOpen(true)}
|
aria-label={copy.openImage}
|
||||||
title={copy.openImage}
|
className="absolute inset-0 block size-full cursor-zoom-in"
|
||||||
type="button"
|
onClick={() => setLightboxOpen(true)}
|
||||||
>
|
type="button"
|
||||||
<img
|
>
|
||||||
alt="Generated image"
|
<img
|
||||||
className={cn(
|
alt="Generated image"
|
||||||
'absolute inset-0 size-full object-contain opacity-0 transition-opacity duration-500 ease-out',
|
className={cn(
|
||||||
loaded && 'opacity-100'
|
'absolute inset-0 size-full object-contain opacity-0 transition-opacity duration-500 ease-out',
|
||||||
)}
|
loaded && 'opacity-100'
|
||||||
draggable={false}
|
)}
|
||||||
onError={() => setFailed(true)}
|
draggable={false}
|
||||||
onLoad={event => {
|
onError={() => setFailed(true)}
|
||||||
const { naturalHeight, naturalWidth } = event.currentTarget
|
onLoad={event => {
|
||||||
|
const { naturalHeight, naturalWidth } = event.currentTarget
|
||||||
|
|
||||||
if (naturalWidth && naturalHeight) {
|
if (naturalWidth && naturalHeight) {
|
||||||
setRatio(naturalWidth / naturalHeight)
|
setRatio(naturalWidth / naturalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}}
|
}}
|
||||||
src={src}
|
src={src}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</Tip>
|
||||||
)}
|
)}
|
||||||
{loaded && src && (
|
{loaded && src && (
|
||||||
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
|
<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 && (
|
{src && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
alt="Generated image"
|
alt="Generated image"
|
||||||
copy={copy}
|
|
||||||
onClick={download}
|
onClick={download}
|
||||||
onOpenChange={setLightboxOpen}
|
onOpenChange={setLightboxOpen}
|
||||||
open={lightboxOpen}
|
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":"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":"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":"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":"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":"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."}
|
{"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."}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
|
|||||||
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
|
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 {
|
function resolveCopy(personality?: string, seed?: number): IntroCopy {
|
||||||
const personalityKey = normalizeKey(personality)
|
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
|
* 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.
|
* empty chat.
|
||||||
*
|
*
|
||||||
* The doubled children are load-bearing, not a typo: `.fit-text` sizes the
|
* The doubled children are load-bearing, not a typo: `.fit-text` sizes the
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { type ComponentProps, useState } from 'react'
|
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 { useImageDownload } from '@/hooks/use-image-download'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { Download } from '@/lib/icons'
|
import { Download } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { ImageViewer } from './image-viewer'
|
||||||
|
|
||||||
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
||||||
containerClassName?: string
|
containerClassName?: string
|
||||||
slot?: 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)}
|
className={cn('group/image relative inline-block max-w-full align-top', containerClassName)}
|
||||||
data-slot={slot ?? 'aui_zoomable-image'}
|
data-slot={slot ?? 'aui_zoomable-image'}
|
||||||
>
|
>
|
||||||
<button
|
<Tip label={canOpen ? copy.openImage : undefined}>
|
||||||
className="contents"
|
<button
|
||||||
disabled={!canOpen}
|
aria-label={copy.openImage}
|
||||||
onClick={() => canOpen && setLightboxOpen(true)}
|
className="contents"
|
||||||
title={canOpen ? copy.openImage : undefined}
|
disabled={!canOpen}
|
||||||
type="button"
|
onClick={() => canOpen && setLightboxOpen(true)}
|
||||||
>
|
type="button"
|
||||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
>
|
||||||
</button>
|
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||||
|
</button>
|
||||||
|
</Tip>
|
||||||
{src && (
|
{src && (
|
||||||
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
|
<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 && (
|
{src && (
|
||||||
<ImageLightbox
|
<ImageLightbox
|
||||||
alt={alt}
|
alt={alt}
|
||||||
copy={copy}
|
|
||||||
onClick={download}
|
onClick={download}
|
||||||
onOpenChange={setLightboxOpen}
|
onOpenChange={setLightboxOpen}
|
||||||
open={lightboxOpen}
|
open={lightboxOpen}
|
||||||
@@ -59,44 +63,35 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageLightbox({
|
export interface ImageLightboxProps {
|
||||||
alt,
|
|
||||||
copy,
|
|
||||||
onClick,
|
|
||||||
onOpenChange,
|
|
||||||
open,
|
|
||||||
saving,
|
|
||||||
src
|
|
||||||
}: {
|
|
||||||
alt?: string
|
alt?: string
|
||||||
copy: ImageActionCopy
|
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
open: boolean
|
open: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
src: string
|
src: string
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function ImageLightbox({ alt, onClick, onOpenChange, open, saving, src }: ImageLightboxProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
bodyClassName="block overflow-visible p-0"
|
aria-describedby={undefined}
|
||||||
className="w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] border-0 bg-transparent shadow-none"
|
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}
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<div className="group/lightbox relative inline-block">
|
<DialogTitle className="sr-only">{alt || t.desktop.openImage}</DialogTitle>
|
||||||
<img
|
<ImageViewer
|
||||||
alt={alt ?? ''}
|
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"
|
key={src}
|
||||||
onClick={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
src={src}
|
onDownload={onClick}
|
||||||
/>
|
saving={saving}
|
||||||
<ImageActionButton
|
src={src}
|
||||||
className="group-hover/lightbox:opacity-100"
|
/>
|
||||||
copy={copy}
|
|
||||||
onClick={onClick}
|
|
||||||
saving={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
@@ -114,21 +109,22 @@ export function ImageActionButton({
|
|||||||
saving: boolean
|
saving: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Tip label={saving ? copy.savingImage : copy.downloadImage}>
|
||||||
aria-label={saving ? copy.savingImage : copy.downloadImage}
|
<button
|
||||||
className={cn(
|
aria-label={saving ? copy.savingImage : copy.downloadImage}
|
||||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
|
className={cn(
|
||||||
className
|
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
|
||||||
)}
|
className
|
||||||
disabled={saving}
|
)}
|
||||||
onClick={event => {
|
disabled={saving}
|
||||||
event.stopPropagation()
|
onClick={event => {
|
||||||
void onClick()
|
event.stopPropagation()
|
||||||
}}
|
void onClick()
|
||||||
title={saving ? copy.savingImage : copy.downloadImage}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||||
</button>
|
</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
|
/** 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. */
|
* build's own progress, in first-build.ts. */
|
||||||
export function buildHandoffCompleteNote(task: string): string {
|
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
|
/** 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
|
/** 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. */
|
* 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"]'
|
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. */
|
* 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> {
|
async function waitFor(selector: string, timeoutMs = 6000): Promise<boolean> {
|
||||||
const deadline = Date.now() + timeoutMs
|
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. */
|
/** The caller does not await this, so the tour does not delay the handoff. */
|
||||||
export async function showHandoffTour(): Promise<void> {
|
export async function showHandoffTour(): Promise<void> {
|
||||||
if (!(await waitFor(RAIL))) {
|
if (!(await waitFor(PROFILE_MENU))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +51,10 @@ export async function showHandoffTour(): Promise<void> {
|
|||||||
const { startTour } = await import('@/lib/tour')
|
const { startTour } = await import('@/lib/tour')
|
||||||
|
|
||||||
await startTour([
|
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
|
...(sessionsVisible
|
||||||
? [{ selector: SESSIONS, side: 'right' as const, text: copy('sessionsText'), title: copy('sessionsTitle') }]
|
? [{ 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 { 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 { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
|
||||||
import { makeOAuthProvider } from '@/test/oauth-provider'
|
import { makeOAuthProvider } from '@/test/oauth-provider'
|
||||||
import type { OAuthProvider } from '@/types/hermes'
|
import type { OAuthProvider } from '@/types/hermes'
|
||||||
|
|
||||||
import { Picker } from '.'
|
import { ApiKeyForm, Picker } from '.'
|
||||||
|
|
||||||
function setProviders(providers: OAuthProvider[]) {
|
function setProviders(providers: OAuthProvider[]) {
|
||||||
$desktopOnboarding.set({
|
$desktopOnboarding.set({
|
||||||
@@ -121,3 +121,52 @@ describe('onboarding Picker', () => {
|
|||||||
expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull()
|
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 {
|
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
|
description?: string
|
||||||
docsUrl: string
|
docsUrl: string
|
||||||
envKey: string
|
envKey: string
|
||||||
@@ -82,9 +85,16 @@ export interface ApiKeyOption {
|
|||||||
short?: string
|
short?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Curated order mirrors CANONICAL_PROVIDERS: Fireworks sits #2 overall (after
|
// FreeModel2API is the default inference route. Keep it first so the direct-key setup path lands
|
||||||
// Nous Portal OAuth), ahead of OpenRouter and the rest of the key catalog.
|
// on the product default even before the dynamic provider catalog finishes loading.
|
||||||
const API_KEY_OPTIONS: ApiKeyOption[] = [
|
const API_KEY_OPTIONS: ApiKeyOption[] = [
|
||||||
|
{
|
||||||
|
id: 'freemodel2api',
|
||||||
|
name: 'FreeModel2API',
|
||||||
|
envKey: 'FREEMODEL2API_API_KEY',
|
||||||
|
baseUrlEnvKey: 'FREEMODEL2API_BASE_URL',
|
||||||
|
docsUrl: ''
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fireworks',
|
id: 'fireworks',
|
||||||
name: 'Fireworks AI',
|
name: 'Fireworks AI',
|
||||||
@@ -179,6 +189,7 @@ function useApiKeyCatalog(): ApiKeyOption[] {
|
|||||||
id: row.slug,
|
id: row.slug,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
envKey,
|
envKey,
|
||||||
|
baseUrlEnvKey: (row.base_url_env as string | undefined) || undefined,
|
||||||
description: `Direct API access to ${row.name}.`,
|
description: `Direct API access to ${row.name}.`,
|
||||||
docsUrl: ''
|
docsUrl: ''
|
||||||
})
|
})
|
||||||
@@ -357,7 +368,10 @@ export function DesktopOnboardingOverlay({
|
|||||||
// do we know whether to dismiss (true) or surface the picker (false).
|
// do we know whether to dismiss (true) or surface the picker (false).
|
||||||
// EXCEPTION: manual mode (user opened the selector from a working app to
|
// EXCEPTION: manual mode (user opened the selector from a working app to
|
||||||
// add/switch a provider) shows the overlay regardless of configured state.
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +610,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
|
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
|
||||||
const [showAll, setShowAll] = useState(readShowAll)
|
const [showAll, setShowAll] = useState(readShowAll)
|
||||||
// Which key-form option to preselect when we flip to 'apikey' mode. The
|
// 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 [apiKeyInitialEnv, setApiKeyInitialEnv] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
const openKeyForm = (envKey?: string) => {
|
const openKeyForm = (envKey?: string) => {
|
||||||
@@ -619,7 +633,9 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
canGoBack={hasOauth && !localEndpoint}
|
canGoBack={hasOauth && !localEndpoint}
|
||||||
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv}
|
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : apiKeyInitialEnv}
|
||||||
onBack={() => setOnboardingMode('oauth')}
|
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}
|
options={apiKeyOptions}
|
||||||
/>
|
/>
|
||||||
{manual ? null : (
|
{manual ? null : (
|
||||||
@@ -740,7 +756,13 @@ export function ApiKeyForm({
|
|||||||
isSet?: (envKey: string) => boolean
|
isSet?: (envKey: string) => boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onClear?: (envKey: string) => 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[]
|
options?: ApiKeyOption[]
|
||||||
redactedValue?: (envKey: string) => null | string | undefined
|
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
|
// Optional endpoint API key, only used by the local / custom endpoint option
|
||||||
// (whose `value` is the base URL). Cleared whenever the option changes.
|
// (whose `value` is the base URL). Cleared whenever the option changes.
|
||||||
const [localKey, setLocalKey] = useState('')
|
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 [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<null | string>(null)
|
const [error, setError] = useState<null | string>(null)
|
||||||
// `options` can change at runtime when callers filter the catalog (e.g. the
|
// `options` can change at runtime when callers filter the catalog (e.g. the
|
||||||
@@ -762,6 +787,7 @@ export function ApiKeyForm({
|
|||||||
setOption(options[0])
|
setOption(options[0])
|
||||||
setValue('')
|
setValue('')
|
||||||
setLocalKey('')
|
setLocalKey('')
|
||||||
|
setBaseUrl('')
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
}, [option.envKey, options])
|
}, [option.envKey, options])
|
||||||
@@ -774,6 +800,7 @@ export function ApiKeyForm({
|
|||||||
setOption(o)
|
setOption(o)
|
||||||
setValue('')
|
setValue('')
|
||||||
setLocalKey('')
|
setLocalKey('')
|
||||||
|
setBaseUrl('')
|
||||||
setError(null)
|
setError(null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
@@ -782,13 +809,16 @@ export function ApiKeyForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLocal = option.envKey === 'OPENAI_BASE_URL'
|
const isLocal = option.envKey === 'OPENAI_BASE_URL'
|
||||||
|
const baseUrlEnvKey = option.baseUrlEnvKey
|
||||||
const alreadySet = isSet?.(option.envKey) ?? false
|
const alreadySet = isSet?.(option.envKey) ?? false
|
||||||
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
|
// 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.
|
// placeholder so users can eyeball that the right key is in place.
|
||||||
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
|
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
|
||||||
// Only require a non-empty value — no length/format validation, so a short
|
// Only require a non-empty value — no length/format validation, so a short
|
||||||
// or unusual key can't block the user from continuing.
|
// or unusual key can't block the user from continuing. EXCEPTION: a
|
||||||
const canSave = value.trim().length >= 1
|
// 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 optionCopy = t.onboarding.apiKeyOptions[option.id]
|
||||||
const optionDescription = optionCopy?.description ?? option.description
|
const optionDescription = optionCopy?.description ?? option.description
|
||||||
|
|
||||||
@@ -799,11 +829,19 @@ export function ApiKeyForm({
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
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) {
|
if (result.ok) {
|
||||||
setValue('')
|
setValue('')
|
||||||
setLocalKey('')
|
setLocalKey('')
|
||||||
|
setBaseUrl('')
|
||||||
} else {
|
} else {
|
||||||
setError(result.message ?? t.onboarding.couldNotSave)
|
setError(result.message ?? t.onboarding.couldNotSave)
|
||||||
}
|
}
|
||||||
@@ -871,6 +909,17 @@ export function ApiKeyForm({
|
|||||||
value={localKey}
|
value={localKey}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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}
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user