From ca3b5a583007b0200b35dc368d94ec9ad39d92d0 Mon Sep 17 00:00:00 2001 From: gaius-codius <206332531+gaius-codius@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:42:32 +0000 Subject: [PATCH 1/5] feat(web): polish session list, header, and new-session UX --- hub/src/sync/sessionCache.ts | 16 +- hub/src/sync/syncEngine.ts | 25 +- package.json | 1 + shared/src/modes.ts | 36 +++ shared/src/schemas.ts | 7 +- shared/src/sessionSummary.ts | 7 +- shared/src/types.ts | 2 + web/e2e/session-metadata.ui.e2e.spec.ts | 82 +++++ web/package.json | 5 +- web/playwright.config.ts | 37 +++ .../components/NewSession/ActionButtons.tsx | 2 +- web/src/components/SessionActionMenu.tsx | 2 +- web/src/components/SessionHeader.test.tsx | 143 +++++++++ web/src/components/SessionHeader.tsx | 54 ++-- web/src/components/SessionList.test.tsx | 136 ++++++++ web/src/components/SessionList.tsx | 270 +++++++++------- web/src/hooks/useLongPress.ts | 54 ++-- web/src/index.css | 301 ++++++++++++++++-- web/src/lib/agentFlavorUtils.test.ts | 50 +++ web/src/lib/agentFlavorUtils.ts | 43 ++- web/src/lib/locales/en.ts | 8 + web/src/lib/locales/zh-CN.ts | 8 + web/src/router.tsx | 17 +- web/src/types/api.ts | 1 + 24 files changed, 1089 insertions(+), 218 deletions(-) create mode 100644 web/e2e/session-metadata.ui.e2e.spec.ts create mode 100644 web/playwright.config.ts create mode 100644 web/src/components/SessionHeader.test.tsx create mode 100644 web/src/components/SessionList.test.tsx create mode 100644 web/src/lib/agentFlavorUtils.test.ts diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 618447c7d..c1a45d158 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -136,7 +136,8 @@ export class SessionCache { model: stored.model, effort: stored.effort, permissionMode: existing?.permissionMode, - collaborationMode: existing?.collaborationMode + collaborationMode: existing?.collaborationMode, + modelMode: existing?.modelMode } this.sessions.set(sessionId, session) @@ -160,6 +161,7 @@ export class SessionCache { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -173,6 +175,7 @@ export class SessionCache { const previousModel = session.model const previousEffort = session.effort const previousCollaborationMode = session.collaborationMode + const previousModelMode = session.modelMode session.active = true session.activeAt = Math.max(session.activeAt, t) @@ -200,6 +203,9 @@ export class SessionCache { if (payload.collaborationMode !== undefined) { session.collaborationMode = payload.collaborationMode } + if (payload.modelMode !== undefined) { + session.modelMode = payload.modelMode + } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 @@ -207,6 +213,7 @@ export class SessionCache { || previousModel !== session.model || previousEffort !== session.effort || previousCollaborationMode !== session.collaborationMode + || previousModelMode !== session.modelMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) || modeChanged @@ -224,7 +231,8 @@ export class SessionCache { permissionMode: session.permissionMode, model: session.model, effort: session.effort, - collaborationMode: session.collaborationMode + collaborationMode: session.collaborationMode, + modelMode: session.modelMode } }) } @@ -266,6 +274,7 @@ export class SessionCache { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] } ): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) @@ -301,6 +310,9 @@ export class SessionCache { if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode } + if (config.modelMode !== undefined) { + session.modelMode = config.modelMode + } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..7e27fcf99 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,13 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { + CodexCollaborationMode, + DecryptedMessage, + PermissionMode, + Session, + SyncEvent +} from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -42,6 +48,7 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } + export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache @@ -97,6 +104,7 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } @@ -190,6 +198,7 @@ export class SyncEngine { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] }): void { this.sessionCache.handleSessionAlive(payload) } @@ -293,9 +302,16 @@ export class SyncEngine { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: Session['modelMode'] } ): Promise { - const result = await this.rpcGateway.requestSessionConfig(sessionId, config) + const rpcConfig = { + permissionMode: config.permissionMode, + model: config.model, + effort: config.effort, + collaborationMode: config.collaborationMode + } + const result = await this.rpcGateway.requestSessionConfig(sessionId, rpcConfig) if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } @@ -312,7 +328,10 @@ export class SyncEngine { throw new Error('Missing applied session config') } - this.sessionCache.applySessionConfig(sessionId, applied) + this.sessionCache.applySessionConfig(sessionId, { + ...applied, + modelMode: config.modelMode + }) } async spawnSession( diff --git a/package.json b/package.json index 8cc9e91a7..8c0b05347 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:cli": "cd cli && bun run test", "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", + "test:e2e:web": "cd web && bun run test:e2e", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 76b0fb00c..c3d1068b9 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -35,6 +35,9 @@ export const PERMISSION_MODES = [ ] as const export type PermissionMode = typeof PERMISSION_MODES[number] +export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const +export type ModelMode = typeof MODEL_MODES[number] + export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number] @@ -82,6 +85,11 @@ export type PermissionModeOption = { tone: PermissionModeTone } +export type ModelModeOption = { + mode: ModelMode + label: string +} + export type CodexCollaborationModeOption = { mode: CodexCollaborationMode label: string @@ -94,6 +102,12 @@ export const CLAUDE_MODEL_LABELS: Record = { 'opus[1m]': 'Opus 1M' } +export const MODEL_MODE_LABELS: Record = { + default: 'Default', + sonnet: 'Sonnet', + opus: 'Opus' +} + export const CODEX_COLLABORATION_MODE_LABELS: Record = { default: 'Default', plan: 'Plan' @@ -152,6 +166,28 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: return getPermissionModesForFlavor(flavor).includes(mode) } +export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor') { + return [] + } + return MODEL_MODES +} + +export function isModelModeAllowedForFlavor(mode: ModelMode, flavor?: string | null): boolean { + return getModelModesForFlavor(flavor).includes(mode) +} + +export function getModelModeLabel(mode: ModelMode): string { + return MODEL_MODE_LABELS[mode] +} + +export function getModelModeOptionsForFlavor(flavor?: string | null): ModelModeOption[] { + return getModelModesForFlavor(flavor).map((mode) => ({ + mode, + label: getModelModeLabel(mode) + })) +} + export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { return CODEX_COLLABORATION_MODES.map((mode) => ({ mode, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52ec83737..a0b696e0d 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,9 +1,9 @@ import { z } from 'zod' -import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' +import { CODEX_COLLABORATION_MODES, MODEL_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) +export const ModelModeSchema = z.enum(MODEL_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) - const MetadataSummarySchema = z.object({ text: z.string(), updatedAt: z.number() @@ -177,6 +177,7 @@ export const SessionSchema = z.object({ model: z.string().nullable(), effort: z.string().nullable(), permissionMode: PermissionModeSchema.optional(), + modelMode: ModelModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) @@ -236,7 +237,7 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ status: z.string(), subscriptionId: z.string().optional() }).optional() - }) + }), ]) export type SyncEvent = z.infer diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index e717a57dd..51d93aae1 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,3 +1,4 @@ +import type { ModelMode, PermissionMode } from './modes' import type { Session, WorktreeMetadata } from './schemas' export type SessionSummaryMetadata = { @@ -20,6 +21,8 @@ export type SessionSummary = { pendingRequestsCount: number model: string | null effort: string | null + permissionMode?: PermissionMode + modelMode?: ModelMode } export function toSessionSummary(session: Session): SessionSummary { @@ -49,6 +52,8 @@ export function toSessionSummary(session: Session): SessionSummary { todoProgress, pendingRequestsCount, model: session.model, - effort: session.effort + effort: session.effort, + permissionMode: session.permissionMode, + modelMode: session.modelMode } } diff --git a/shared/src/types.ts b/shared/src/types.ts index 37333a60e..1d6466580 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -28,6 +28,8 @@ export type { GeminiPermissionMode, OpencodePermissionMode, ClaudeModelPreset, + ModelMode, + ModelModeOption, PermissionMode, PermissionModeOption, PermissionModeTone diff --git a/web/e2e/session-metadata.ui.e2e.spec.ts b/web/e2e/session-metadata.ui.e2e.spec.ts new file mode 100644 index 000000000..887c48965 --- /dev/null +++ b/web/e2e/session-metadata.ui.e2e.spec.ts @@ -0,0 +1,82 @@ +import { expect, test, type Page } from '@playwright/test' + +const BASE_URL = process.env.HAPI_E2E_BASE_URL ?? 'http://127.0.0.1:3906' +const BASE_TOKEN = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' +const RUN_ID = process.env.HAPI_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +function token(namespaceSuffix: string): string { + return `${BASE_TOKEN}:session-metadata-${RUN_ID}-${namespaceSuffix}` +} + +async function login(page: Page, accessToken: string): Promise { + await page.goto(BASE_URL, { waitUntil: 'networkidle' }) + await page.getByPlaceholder('Access token').fill(accessToken) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.getByText(/sessions in .* projects/i)).toBeVisible({ timeout: 15_000 }) +} + +async function createCliSession( + accessToken: string, + tag: string, + name: string, + path: string, + machineId: string +): Promise { + const response = await fetch(`${BASE_URL}/cli/sessions`, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + tag, + metadata: { + name, + path, + host: 'pw-host', + machineId, + flavor: 'codex', + worktree: { + basePath: '/work/repo', + branch: 'feature/chips', + name: 'feature-chips' + } + }, + agentState: null, + model: 'gpt-5.4', + effort: 'very-high' + }) + }) + expect(response.status).toBe(200) + const json = await response.json() as { session: { id: string } } + return json.session.id +} + +test('session metadata chips render in list and header', async ({ page }) => { + const accessToken = token('chips') + + const activeSessionId = await createCliSession(accessToken, 's-active', 'Active Session', '/work/repo/project-a', 'm1') + await createCliSession(accessToken, 's-inactive', 'Inactive Session', '/work/repo/project-a', 'm1') + + await login(page, accessToken) + + await page.getByRole('button', { + name: /work\/repo/i + }).first().click() + + const activeRow = page.locator('.session-list-item', { hasText: 'Active Session' }).first() + await expect(activeRow).toContainText('codex') + await expect(activeRow).toContainText('gpt-5.4') + await expect(activeRow).toContainText('feature/chips') + + await page.goto(`${BASE_URL}/sessions/${activeSessionId}`, { waitUntil: 'domcontentloaded' }) + + const headerTitle = page.locator('div.truncate.font-semibold').first() + await expect(headerTitle).toHaveText('Active Session') + + const headerMeta = headerTitle.locator('xpath=following-sibling::div[1]') + await expect(headerMeta).toContainText('codex') + await expect(headerMeta).toContainText('gpt-5.4') + await expect(headerMeta).toContainText('Very High') + await expect(headerMeta).toContainText('feature/chips') +}) diff --git a/web/package.json b/web/package.json index e22d39631..e719e5eb4 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,9 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "BUN_BIN=${BUN_BIN:-$(command -v bun || echo $HOME/.bun/bin/bun)} playwright test --config=playwright.config.ts", + "test:e2e:install": "playwright install chromium" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -44,6 +46,7 @@ "workbox-window": "^7.4.0" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/react": "^19.2.7", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..531209ce3 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' + +const port = Number(process.env.HAPI_E2E_PORT ?? '3906') +const baseUrl = process.env.HAPI_E2E_BASE_URL ?? `http://127.0.0.1:${port}` +const hapiHome = process.env.HAPI_E2E_HAPI_HOME ?? `/tmp/hapi-playwright-${port}` +const cliApiToken = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' +const bunBin = process.env.BUN_BIN ?? 'bun' + +export default defineConfig({ + testDir: './e2e', + timeout: 180_000, + expect: { + timeout: 20_000 + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? [['github'], ['line']] : 'line', + use: { + baseURL: baseUrl, + trace: 'on-first-retry', + locale: 'en-US' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ], + webServer: { + command: `${bunBin} run build && rm -rf "${hapiHome}" && mkdir -p "${hapiHome}" && CLI_API_TOKEN=${cliApiToken} HAPI_HOME="${hapiHome}" HAPI_LISTEN_HOST=127.0.0.1 HAPI_LISTEN_PORT=${port} ${bunBin} run --cwd ../hub src/index.ts`, + url: `${baseUrl}/health`, + timeout: 120_000, + reuseExistingServer: false + } +}) diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx index 00b6b9f81..72292b247 100644 --- a/web/src/components/NewSession/ActionButtons.tsx +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -13,7 +13,7 @@ export function ActionButtons(props: { const { t } = useTranslation() return ( -
+
- {/* Session info - two lines: title and path */}
{title}
-
- - - {session.metadata?.flavor?.trim() || 'unknown'} - - {modelLabel ? ( - - {t(modelLabel.key)}: {modelLabel.value} - - ) : null} - {worktreeBranch ? ( - {t('session.item.worktree')}: {worktreeBranch} - ) : null} +
+ {metadataItems.map((item, index) => ( + + {index > 0 ? ( + + ) : null} + {item} + + ))}
@@ -155,7 +167,7 @@ export function SessionHeader(props: { void +}) { + const { haptic } = usePlatform() + const longPressHandlers = useLongPress({ + onLongPress: () => { + haptic.impact('medium') + }, + onClick: props.onToggle, + threshold: 500 + }) + + return ( + + ) +} + export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void @@ -363,14 +400,31 @@ export function SessionList(props: { selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props + const { + renderHeader = true, + api, + selectedSessionId, + machineLabelsById = {} + } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] ) + const displayGroups = groups + const knownSessionIdsRef = useRef>(new Set(props.sessions.map(session => session.id))) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + const enteringSessionIds = useMemo(() => { + const entering = new Set() + props.sessions.forEach(session => { + if (!knownSessionIdsRef.current.has(session.id)) { + entering.add(session.id) + } + }) + return entering + }, [props.sessions]) + const isGroupCollapsed = (group: SessionGroup): boolean => { const override = collapseOverrides.get(group.key) if (override !== undefined) return override @@ -401,7 +455,7 @@ export function SessionList(props: { useEffect(() => { if (!selectedSessionId) return setCollapseOverrides(prev => { - const group = groups.find(g => + const group = displayGroups.find(g => g.sessions.some(s => s.id === selectedSessionId) ) if (!group || !prev.has(group.key) || !prev.get(group.key)) return prev @@ -409,13 +463,13 @@ export function SessionList(props: { next.delete(group.key) return next }) - }, [selectedSessionId, groups]) + }, [selectedSessionId, displayGroups]) useEffect(() => { setCollapseOverrides(prev => { if (prev.size === 0) return prev const next = new Map(prev) - const knownGroups = new Set(groups.map(group => group.key)) + const knownGroups = new Set(displayGroups.map(group => group.key)) let changed = false for (const groupKey of next.keys()) { if (!knownGroups.has(groupKey)) { @@ -425,19 +479,25 @@ export function SessionList(props: { } return changed ? next : prev }) - }, [groups]) + }, [displayGroups]) + + useEffect(() => { + props.sessions.forEach(session => { + knownSessionIdsRef.current.add(session.id) + }) + }, [props.sessions]) return (
{renderHeader ? (
- {t('sessions.count', { n: props.sessions.length, m: groups.length })} + {t('sessions.count', { n: props.sessions.length, m: displayGroups.length })}
+ toggleGroup(group.key, isCollapsed)} + /> {!isCollapsed ? (
- {group.sessions.map((s) => ( + {group.sessions.map((s, index) => ( ))}
@@ -496,6 +535,7 @@ export function SessionList(props: { ) })}
+
) } diff --git a/web/src/hooks/useLongPress.ts b/web/src/hooks/useLongPress.ts index 5673b2d7d..ffeb81399 100644 --- a/web/src/hooks/useLongPress.ts +++ b/web/src/hooks/useLongPress.ts @@ -9,12 +9,10 @@ type UseLongPressOptions = { } type UseLongPressHandlers = { - onMouseDown: React.MouseEventHandler - onMouseUp: React.MouseEventHandler - onMouseLeave: React.MouseEventHandler - onTouchStart: React.TouchEventHandler - onTouchEnd: React.TouchEventHandler - onTouchMove: React.TouchEventHandler + onPointerDown: React.PointerEventHandler + onPointerUp: React.PointerEventHandler + onPointerLeave: React.PointerEventHandler + onPointerCancel: React.PointerEventHandler onContextMenu: React.MouseEventHandler onKeyDown: React.KeyboardEventHandler } @@ -24,7 +22,6 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers const timerRef = useRef | null>(null) const isLongPressRef = useRef(false) - const touchMoved = useRef(false) const pressPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) const clearTimer = useCallback(() => { @@ -39,7 +36,6 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers clearTimer() isLongPressRef.current = false - touchMoved.current = false pressPointRef.current = { x: clientX, y: clientY } timerRef.current = setTimeout(() => { @@ -51,44 +47,34 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers const handleEnd = useCallback((shouldTriggerClick: boolean) => { clearTimer() - if (shouldTriggerClick && !isLongPressRef.current && !touchMoved.current && onClick) { + if (shouldTriggerClick && !isLongPressRef.current && onClick) { onClick() } isLongPressRef.current = false - touchMoved.current = false }, [clearTimer, onClick]) - const onMouseDown = useCallback((e) => { - if (e.button !== 0) return + const onPointerDown = useCallback((e) => { + if (!e.isPrimary) return + if (e.pointerType === 'mouse' && e.button !== 0) return startTimer(e.clientX, e.clientY) }, [startTimer]) - const onMouseUp = useCallback(() => { + const onPointerUp = useCallback((e) => { + if (!e.isPrimary) return handleEnd(!isLongPressRef.current) }, [handleEnd]) - const onMouseLeave = useCallback(() => { + const onPointerLeave = useCallback((e) => { + if (!e.isPrimary) return handleEnd(false) }, [handleEnd]) - const onTouchStart = useCallback((e) => { - const touch = e.touches[0] - startTimer(touch.clientX, touch.clientY) - }, [startTimer]) - - const onTouchEnd = useCallback((e) => { - if (isLongPressRef.current) { - e.preventDefault() - } - handleEnd(!isLongPressRef.current) + const onPointerCancel = useCallback((e) => { + if (!e.isPrimary) return + handleEnd(false) }, [handleEnd]) - const onTouchMove = useCallback(() => { - touchMoved.current = true - clearTimer() - }, [clearTimer]) - const onContextMenu = useCallback((e) => { if (!disabled) { e.preventDefault() @@ -107,12 +93,10 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers }, [disabled, onClick]) return { - onMouseDown, - onMouseUp, - onMouseLeave, - onTouchStart, - onTouchEnd, - onTouchMove, + onPointerDown, + onPointerUp, + onPointerLeave, + onPointerCancel, onContextMenu, onKeyDown } diff --git a/web/src/index.css b/web/src/index.css index 41bc44877..744c279d4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -33,6 +33,9 @@ --app-git-untracked-color: #8E8E93; /* Badge colors (light) */ + --app-badge-info-bg: rgba(59, 130, 246, 0.12); + --app-badge-info-text: #1d4ed8; + --app-badge-info-border: rgba(59, 130, 246, 0.22); --app-badge-warning-bg: rgba(245, 158, 11, 0.2); --app-badge-warning-text: #b45309; --app-badge-warning-border: rgba(245, 158, 11, 0.3); @@ -43,6 +46,26 @@ --app-badge-error-text: #b91c1c; --app-badge-error-border: rgba(239, 68, 68, 0.3); + --app-flavor-claude: #b45309; + --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); + --app-flavor-claude-text: #b45309; + --app-flavor-claude-border: rgba(245, 158, 11, 0.24); + --app-flavor-codex: #1d4ed8; + --app-flavor-codex-bg: rgba(59, 130, 246, 0.12); + --app-flavor-codex-text: #1d4ed8; + --app-flavor-codex-border: rgba(59, 130, 246, 0.24); + --app-flavor-gemini: #6d28d9; + --app-flavor-gemini-bg: rgba(139, 92, 246, 0.12); + --app-flavor-gemini-text: #6d28d9; + --app-flavor-gemini-border: rgba(139, 92, 246, 0.24); + --app-flavor-opencode: #047857; + --app-flavor-opencode-bg: rgba(16, 185, 129, 0.12); + --app-flavor-opencode-text: #047857; + --app-flavor-opencode-border: rgba(16, 185, 129, 0.24); + --app-flavor-cursor-bg: rgba(100, 116, 139, 0.12); + --app-flavor-cursor-text: #475569; + --app-flavor-cursor-border: rgba(100, 116, 139, 0.24); + --app-font-scale: 1; } @@ -78,6 +101,9 @@ --app-git-untracked-color: #9ca3af; /* Badge colors (dark) */ + --app-badge-info-bg: rgba(96, 165, 250, 0.18); + --app-badge-info-text: #93c5fd; + --app-badge-info-border: rgba(96, 165, 250, 0.28); --app-badge-warning-bg: rgba(251, 191, 36, 0.2); --app-badge-warning-text: #fbbf24; --app-badge-warning-border: rgba(251, 191, 36, 0.3); @@ -87,6 +113,226 @@ --app-badge-error-bg: rgba(248, 113, 113, 0.2); --app-badge-error-text: #fca5a5; --app-badge-error-border: rgba(248, 113, 113, 0.35); + + --app-flavor-claude: #fcd34d; + --app-flavor-claude-bg: rgba(251, 191, 36, 0.16); + --app-flavor-claude-text: #fcd34d; + --app-flavor-claude-border: rgba(251, 191, 36, 0.28); + --app-flavor-codex: #93c5fd; + --app-flavor-codex-bg: rgba(96, 165, 250, 0.16); + --app-flavor-codex-text: #93c5fd; + --app-flavor-codex-border: rgba(96, 165, 250, 0.28); + --app-flavor-gemini: #c4b5fd; + --app-flavor-gemini-bg: rgba(167, 139, 250, 0.16); + --app-flavor-gemini-text: #c4b5fd; + --app-flavor-gemini-border: rgba(167, 139, 250, 0.28); + --app-flavor-opencode: #6ee7b7; + --app-flavor-opencode-bg: rgba(52, 211, 153, 0.16); + --app-flavor-opencode-text: #6ee7b7; + --app-flavor-opencode-border: rgba(52, 211, 153, 0.28); + --app-flavor-cursor-bg: rgba(148, 163, 184, 0.16); + --app-flavor-cursor-text: #cbd5e1; + --app-flavor-cursor-border: rgba(148, 163, 184, 0.28); +} + +[data-theme="catpuccin"] { + /* Primary colors — Catpuccin Mocha */ + --app-bg: #1e1e2e; + --app-fg: #cdd6f4; + --app-hint: #6c7086; + --app-link: #cdd6f4; + --app-button: #cdd6f4; + --app-button-text: #1e1e2e; + --app-banner-bg: #313244; + --app-banner-text: #cdd6f4; + --app-secondary-bg: #313244; + + --app-border: rgba(255, 255, 255, 0.1); + --app-divider: rgba(255, 255, 255, 0.08); + --app-subtle-bg: rgba(255, 255, 255, 0.05); + --app-code-bg: #282c34; + --app-inline-code-bg: rgba(255, 255, 255, 0.1); + + /* Diff colors (dark) */ + --app-diff-added-bg: #0d2e1f; + --app-diff-added-text: #c9d1d9; + --app-diff-removed-bg: #3f1b23; + --app-diff-removed-text: #c9d1d9; + + /* Git status colors */ + --app-git-staged-color: #a6e3a1; + --app-git-unstaged-color: #fab387; + --app-git-deleted-color: #f38ba8; + --app-git-renamed-color: #89b4fa; + --app-git-untracked-color: #6c7086; + + /* Badge colors — Catpuccin Mocha */ + --app-badge-info-bg: rgba(137, 180, 250, 0.12); + --app-badge-info-text: #89b4fa; + --app-badge-info-border: rgba(137, 180, 250, 0.22); + --app-badge-warning-bg: rgba(250, 179, 135, 0.15); + --app-badge-warning-text: #fab387; + --app-badge-warning-border: rgba(250, 179, 135, 0.25); + --app-badge-success-bg: rgba(166, 227, 161, 0.15); + --app-badge-success-text: #a6e3a1; + --app-badge-success-border: rgba(166, 227, 161, 0.25); + --app-badge-error-bg: rgba(243, 139, 168, 0.15); + --app-badge-error-text: #f38ba8; + --app-badge-error-border: rgba(243, 139, 168, 0.25); + + --app-flavor-claude: #fab387; + --app-flavor-claude-bg: rgba(250, 179, 135, 0.10); + --app-flavor-claude-text: #fab387; + --app-flavor-claude-border: rgba(250, 179, 135, 0.20); + --app-flavor-codex: #a6e3a1; + --app-flavor-codex-bg: rgba(166, 227, 161, 0.10); + --app-flavor-codex-text: #a6e3a1; + --app-flavor-codex-border: rgba(166, 227, 161, 0.20); + --app-flavor-gemini: #74c7ec; + --app-flavor-gemini-bg: rgba(116, 199, 236, 0.10); + --app-flavor-gemini-text: #74c7ec; + --app-flavor-gemini-border: rgba(116, 199, 236, 0.20); + --app-flavor-opencode: #cba6f7; + --app-flavor-opencode-bg: rgba(203, 166, 247, 0.10); + --app-flavor-opencode-text: #cba6f7; + --app-flavor-opencode-border: rgba(203, 166, 247, 0.20); + --app-flavor-cursor-bg: rgba(186, 194, 222, 0.10); + --app-flavor-cursor-text: #bac2de; + --app-flavor-cursor-border: rgba(186, 194, 222, 0.20); +} + +[data-theme="gaius-light"] { + /* Primary — warm pearl base */ + --app-bg: #f8f5f2; + --app-fg: #2a2832; + --app-hint: #85808a; + --app-link: #b04440; + --app-button: #2a2832; + --app-button-text: #f8f5f2; + --app-banner-bg: #eceae6; + --app-banner-text: #2a2832; + --app-secondary-bg: #f0ede9; + + /* Overlays */ + --app-border: rgba(42, 40, 50, 0.10); + --app-divider: rgba(42, 40, 50, 0.07); + --app-subtle-bg: rgba(42, 40, 50, 0.03); + --app-code-bg: #f0ede8; + --app-inline-code-bg: rgba(42, 40, 50, 0.05); + + /* Diffs — verdigris added, cinnabar removed */ + --app-diff-added-bg: #e4edd8; + --app-diff-added-text: #2a2832; + --app-diff-removed-bg: #f2dcd8; + --app-diff-removed-text: #2a2832; + + /* Git status */ + --app-git-staged-color: #3a7868; + --app-git-unstaged-color: #b07830; + --app-git-deleted-color: #b84440; + --app-git-renamed-color: #4068a0; + --app-git-untracked-color: #85808a; + + /* Badges — lapis, gold, verdigris, cinnabar */ + --app-badge-info-bg: rgba(64, 104, 160, 0.10); + --app-badge-info-text: #3a5888; + --app-badge-info-border: rgba(64, 104, 160, 0.20); + --app-badge-warning-bg: rgba(176, 120, 48, 0.12); + --app-badge-warning-text: #8a5820; + --app-badge-warning-border: rgba(176, 120, 48, 0.22); + --app-badge-success-bg: rgba(58, 120, 104, 0.10); + --app-badge-success-text: #2a6858; + --app-badge-success-border: rgba(58, 120, 104, 0.20); + --app-badge-error-bg: rgba(184, 68, 64, 0.10); + --app-badge-error-text: #983838; + --app-badge-error-border: rgba(184, 68, 64, 0.20); + + --app-flavor-claude: #a04038; + --app-flavor-claude-bg: rgba(160, 64, 56, 0.08); + --app-flavor-claude-text: #a04038; + --app-flavor-claude-border: rgba(160, 64, 56, 0.18); + --app-flavor-codex: #2a6858; + --app-flavor-codex-bg: rgba(42, 104, 88, 0.08); + --app-flavor-codex-text: #2a6858; + --app-flavor-codex-border: rgba(42, 104, 88, 0.18); + --app-flavor-gemini: #3a5888; + --app-flavor-gemini-bg: rgba(58, 88, 136, 0.08); + --app-flavor-gemini-text: #3a5888; + --app-flavor-gemini-border: rgba(58, 88, 136, 0.18); + --app-flavor-opencode: #6a5090; + --app-flavor-opencode-bg: rgba(106, 80, 144, 0.08); + --app-flavor-opencode-text: #6a5090; + --app-flavor-opencode-border: rgba(106, 80, 144, 0.18); + --app-flavor-cursor-bg: rgba(106, 104, 116, 0.08); + --app-flavor-cursor-text: #6a6874; + --app-flavor-cursor-border: rgba(106, 104, 116, 0.18); +} + +[data-theme="gaius-dark"] { + /* Primary — deep slate base */ + --app-bg: #1e1d22; + --app-fg: #e6e3de; + --app-hint: #88848e; + --app-link: #d06058; + --app-button: #e6e3de; + --app-button-text: #1e1d22; + --app-banner-bg: #2a2930; + --app-banner-text: #e6e3de; + --app-secondary-bg: #252428; + + /* Overlays */ + --app-border: rgba(230, 227, 222, 0.08); + --app-divider: rgba(230, 227, 222, 0.06); + --app-subtle-bg: rgba(230, 227, 222, 0.04); + --app-code-bg: #252430; + --app-inline-code-bg: rgba(230, 227, 222, 0.07); + + /* Diffs */ + --app-diff-added-bg: rgba(80, 150, 130, 0.12); + --app-diff-added-text: #d8d5d0; + --app-diff-removed-bg: rgba(200, 80, 70, 0.12); + --app-diff-removed-text: #d8d5d0; + + /* Git status */ + --app-git-staged-color: #68b8a0; + --app-git-unstaged-color: #d0a060; + --app-git-deleted-color: #d87068; + --app-git-renamed-color: #6890c8; + --app-git-untracked-color: #88848e; + + /* Badges */ + --app-badge-info-bg: rgba(104, 144, 200, 0.12); + --app-badge-info-text: #6890c8; + --app-badge-info-border: rgba(104, 144, 200, 0.20); + --app-badge-warning-bg: rgba(208, 160, 96, 0.15); + --app-badge-warning-text: #d0a060; + --app-badge-warning-border: rgba(208, 160, 96, 0.25); + --app-badge-success-bg: rgba(104, 184, 160, 0.10); + --app-badge-success-text: #68b8a0; + --app-badge-success-border: rgba(104, 184, 160, 0.20); + --app-badge-error-bg: rgba(216, 112, 104, 0.12); + --app-badge-error-text: #d87068; + --app-badge-error-border: rgba(216, 112, 104, 0.22); + + --app-flavor-claude: #d08858; + --app-flavor-claude-bg: rgba(208, 136, 88, 0.10); + --app-flavor-claude-text: #d08858; + --app-flavor-claude-border: rgba(208, 136, 88, 0.20); + --app-flavor-codex: #68b8a0; + --app-flavor-codex-bg: rgba(104, 184, 160, 0.10); + --app-flavor-codex-text: #68b8a0; + --app-flavor-codex-border: rgba(104, 184, 160, 0.20); + --app-flavor-gemini: #6890c8; + --app-flavor-gemini-bg: rgba(104, 144, 200, 0.10); + --app-flavor-gemini-text: #6890c8; + --app-flavor-gemini-border: rgba(104, 144, 200, 0.20); + --app-flavor-opencode: #a088c0; + --app-flavor-opencode-bg: rgba(160, 136, 192, 0.10); + --app-flavor-opencode-text: #a088c0; + --app-flavor-opencode-border: rgba(160, 136, 192, 0.20); + --app-flavor-cursor-bg: rgba(176, 172, 182, 0.10); + --app-flavor-cursor-text: #b0acb6; + --app-flavor-cursor-border: rgba(176, 172, 182, 0.20); } html { @@ -98,22 +344,12 @@ body { height: 100vh; height: 100dvh; height: var(--tg-viewport-stable-height, 100dvh); + overflow: hidden; touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-text-size-adjust: 100%; } -html[data-telegram-app="true"], -html[data-telegram-app="true"] body { - overflow: hidden; -} - -html:not([data-telegram-app="true"]), -html:not([data-telegram-app="true"]) body { - overflow-x: hidden; - overflow-y: auto; -} - body { font-size: 1rem; background: var(--app-bg); @@ -149,16 +385,6 @@ body { } } -/* - * content-visibility: auto lets the browser skip layout/paint for messages - * scrolled out of the viewport. Big win on Windows with long conversations (#310). - * contain-intrinsic-size gives a rough height hint so scrollbar doesn't jump. - */ -.happy-thread-messages > * { - content-visibility: auto; - contain-intrinsic-size: auto 80px; -} - /* Markdown styles */ .markdown-content a { color: var(--app-link); text-decoration: underline; } .markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; } @@ -194,7 +420,11 @@ body { } html[data-theme="dark"] .shiki, -html[data-theme="dark"] .shiki span { +html[data-theme="dark"] .shiki span, +html[data-theme="catpuccin"] .shiki, +html[data-theme="catpuccin"] .shiki span, +html[data-theme="gaius-dark"] .shiki, +html[data-theme="gaius-dark"] .shiki span { color: var(--shiki-dark) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; @@ -292,6 +522,33 @@ html[data-theme="dark"] .shiki span { animation: bounce-in 0.3s ease-out; } +/* Session list item enter animation */ +@keyframes session-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-session-enter { + animation: session-enter 0.2s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .animate-session-enter { + animation: none; + } + + .animate-session-enter { + opacity: 1; + transform: none; + } +} + /* ReactQueryDevtools button - move to middle-right to avoid blocking UI */ .tsqd-open-btn-container { bottom: 50% !important; diff --git a/web/src/lib/agentFlavorUtils.test.ts b/web/src/lib/agentFlavorUtils.test.ts new file mode 100644 index 000000000..689d6612d --- /dev/null +++ b/web/src/lib/agentFlavorUtils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + formatEffortLabel, + getFlavorTextClass, + META_DOT_SEPARATOR_CLASS +} from './agentFlavorUtils' + +describe('getFlavorTextClass', () => { + it.each([ + ['claude', 'text-[var(--app-flavor-claude-text)] font-medium'], + ['codex', 'text-[var(--app-flavor-codex-text)] font-medium'], + ['gemini', 'text-[var(--app-flavor-gemini-text)] font-medium'], + ['opencode', 'text-[var(--app-flavor-opencode-text)] font-medium'], + ['cursor', 'text-[var(--app-flavor-cursor-text)] font-medium'] + ])('returns flavor class for %s', (flavor, expected) => { + expect(getFlavorTextClass(flavor)).toBe(expected) + }) + + it('falls back for unknown flavors', () => { + expect(getFlavorTextClass('mystery')).toBe('text-[var(--app-hint)] font-medium') + }) + + it('falls back for nullish values', () => { + expect(getFlavorTextClass(null)).toBe('text-[var(--app-hint)] font-medium') + expect(getFlavorTextClass(undefined)).toBe('text-[var(--app-hint)] font-medium') + }) + + it('normalizes whitespace and casing', () => { + expect(getFlavorTextClass(' CoDeX ')).toBe('text-[var(--app-flavor-codex-text)] font-medium') + }) +}) + +describe('formatEffortLabel', () => { + it('returns null for nullish and blank values', () => { + expect(formatEffortLabel(null)).toBeNull() + expect(formatEffortLabel('')).toBeNull() + expect(formatEffortLabel(' ')).toBeNull() + }) + + it('title-cases segmented effort labels', () => { + expect(formatEffortLabel('very-high')).toBe('Very High') + expect(formatEffortLabel('max_reasoning effort')).toBe('Max Reasoning Effort') + }) +}) + +describe('META_DOT_SEPARATOR_CLASS', () => { + it('exports the expected separator class', () => { + expect(META_DOT_SEPARATOR_CLASS).toBe('text-[var(--app-hint)] opacity-40') + }) +}) diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index d835b3505..b2e463e50 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -1,19 +1,56 @@ +function normalizeFlavor(flavor?: string | null): string | null { + const normalized = flavor?.trim().toLowerCase() + return normalized || null +} + +const SESSION_META_BADGE_BASE = 'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-medium leading-none' + +export const SESSION_ACTIVITY_BADGE = `${SESSION_META_BADGE_BASE} border-[var(--app-badge-info-border)] bg-[var(--app-badge-info-bg)] text-[var(--app-badge-info-text)]` +export const SESSION_PENDING_BADGE = `${SESSION_META_BADGE_BASE} border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)] text-[var(--app-badge-warning-text)]` + export function isCodexFamilyFlavor(flavor?: string | null): boolean { - return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' + const normalized = normalizeFlavor(flavor) + return normalized === 'codex' || normalized === 'gemini' || normalized === 'opencode' } export function isClaudeFlavor(flavor?: string | null): boolean { - return flavor === 'claude' + return normalizeFlavor(flavor) === 'claude' } export function isCursorFlavor(flavor?: string | null): boolean { - return flavor === 'cursor' + return normalizeFlavor(flavor) === 'cursor' } export function isKnownFlavor(flavor?: string | null): boolean { return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor) } +const FLAVOR_TEXT_CLASSES: Record = { + claude: 'text-[var(--app-flavor-claude-text)] font-medium', + codex: 'text-[var(--app-flavor-codex-text)] font-medium', + gemini: 'text-[var(--app-flavor-gemini-text)] font-medium', + opencode: 'text-[var(--app-flavor-opencode-text)] font-medium', + cursor: 'text-[var(--app-flavor-cursor-text)] font-medium' +} + +export function getFlavorTextClass(flavor?: string | null): string { + const normalized = normalizeFlavor(flavor) + return normalized ? (FLAVOR_TEXT_CLASSES[normalized] ?? 'text-[var(--app-hint)] font-medium') : 'text-[var(--app-hint)] font-medium' +} + +export const META_DOT_SEPARATOR_CLASS = 'text-[var(--app-hint)] opacity-40' + +export function formatEffortLabel(effort?: string | null): string | null { + const normalized = effort?.trim() + if (!normalized) return null + + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + export function supportsModelChange(flavor?: string | null): boolean { return flavor === 'claude' || flavor === 'gemini' } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f26126109..b90f8eb6c 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -257,6 +257,14 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', + 'settings.display.theme': 'Theme', + 'settings.display.theme.system': 'System', + 'settings.display.theme.light': 'Light', + 'settings.display.theme.dark': 'Dark', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius Light', + 'settings.display.theme.gaius-dark': 'Gaius Dark', 'settings.display.appearance': 'Appearance', 'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.dark': 'Dark', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ea220f5a7..d024d6381 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -259,6 +259,14 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', + 'settings.display.theme': '主题', + 'settings.display.theme.system': '跟随系统', + 'settings.display.theme.light': '浅色', + 'settings.display.theme.dark': '深色', + 'settings.display.theme.catpuccin': 'Catppuccin', + 'settings.display.theme.gaius': 'Gaius', + 'settings.display.theme.gaius-light': 'Gaius 浅色', + 'settings.display.theme.gaius-dark': 'Gaius 深色', 'settings.display.appearance': '外观', 'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.dark': '深色', diff --git a/web/src/router.tsx b/web/src/router.tsx index 7527abcdd..b78f50ef9 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -114,9 +114,11 @@ function SessionsPage() { void refetch() }, [refetch]) - const projectCount = useMemo(() => new Set(sessions.map(s => - s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' - )).size, [sessions]) + const projectCount = useMemo(() => new Set(sessions.map(s => { + const path = s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other' + const machineId = s.metadata?.machineId ?? '__unknown__' + return `${machineId}::${path}` + })).size, [sessions]) const machineLabelsById = useMemo(() => { const labels: Record = {} for (const machine of machines) { @@ -159,7 +161,7 @@ function SessionsPage() {
-
+
{error ? (
{error}
@@ -285,7 +287,6 @@ function SessionPage() { // Get agent type from session metadata for slash commands const agentType = session?.metadata?.flavor ?? 'claude' const { - commands: slashCommands, getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) const { @@ -332,7 +333,6 @@ function SessionPage() { onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} - availableSlashCommands={slashCommands} /> ) } @@ -386,10 +386,7 @@ function NewSessionPage() {
{t('newSession.title')}
-
+
{machinesError ? (
{machinesError} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..e8cb24190 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -212,6 +212,7 @@ export type PushVapidPublicKeyResponse = { publicKey: string } + export type VisibilityPayload = { subscriptionId: string visibility: 'visible' | 'hidden' From 46553476226e13a4dc11aee52c8d705b02f28bf2 Mon Sep 17 00:00:00 2001 From: gaius-codius <206332531+gaius-codius@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:34:09 +0000 Subject: [PATCH 2/5] fix: address PR review follow-ups --- .../socket/handlers/cli/sessionHandlers.ts | 24 +++++++++++++++---- hub/src/sync/syncEngine.ts | 3 +++ web/playwright.config.ts | 19 ++++++++++++++- web/src/components/SessionList.tsx | 13 ++++------ web/src/hooks/useLongPress.ts | 21 +++++++++++++++- web/src/index.css | 13 +++++++--- 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 67ec014b7..50c43ed7b 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,4 +1,5 @@ import type { ClientToServerEvents } from '@hapi/protocol' +import { CodexCollaborationModeSchema, ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { z } from 'zod' import { randomUUID } from 'node:crypto' import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' @@ -18,6 +19,7 @@ type SessionAlivePayload = { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: import('@hapi/protocol/types').ModelMode } type SessionEndPayload = { @@ -50,6 +52,18 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) +const sessionAliveSchema = z.object({ + sid: z.string(), + time: z.number(), + thinking: z.boolean().optional(), + mode: z.enum(['local', 'remote']).optional(), + permissionMode: PermissionModeSchema.optional(), + model: z.string().nullable().optional(), + effort: z.string().nullable().optional(), + collaborationMode: CodexCollaborationModeSchema.optional(), + modelMode: ModelModeSchema.optional() +}) + export type SessionHandlersDeps = { store: Store resolveSessionAccess: ResolveSessionAccess @@ -235,15 +249,17 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session socket.on('update-state', handleUpdateState) socket.on('session-alive', (data: SessionAlivePayload) => { - if (!data || typeof data.sid !== 'string' || typeof data.time !== 'number') { + const parsed = sessionAliveSchema.safeParse(data) + if (!parsed.success) { return } - const sessionAccess = resolveSessionAccess(data.sid) + const payload = parsed.data + const sessionAccess = resolveSessionAccess(payload.sid) if (!sessionAccess.ok) { - emitAccessError('session', data.sid, sessionAccess.reason) + emitAccessError('session', payload.sid, sessionAccess.reason) return } - onSessionAlive?.(data) + onSessionAlive?.(payload) }) socket.on('session-end', (data: SessionEndPayload) => { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 7e27fcf99..ef8ee69f6 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -305,6 +305,9 @@ export class SyncEngine { modelMode?: Session['modelMode'] } ): Promise { + // modelMode is currently hub-managed metadata only. CLI agents do not yet + // accept it through set-session-config RPC, so we apply the validated value + // locally after the agent acknowledges the rest of the config. const rpcConfig = { permissionMode: config.permissionMode, model: config.model, diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 531209ce3..b19469a85 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,6 +6,23 @@ const hapiHome = process.env.HAPI_E2E_HAPI_HOME ?? `/tmp/hapi-playwright-${port} const cliApiToken = process.env.HAPI_E2E_CLI_TOKEN ?? 'pw-test-token' const bunBin = process.env.BUN_BIN ?? 'bun' +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'` +} + +const buildCommand = [ + `${shellQuote(bunBin)} run build`, + `rm -rf ${shellQuote(hapiHome)}`, + `mkdir -p ${shellQuote(hapiHome)}`, + [ + `CLI_API_TOKEN=${shellQuote(cliApiToken)}`, + `HAPI_HOME=${shellQuote(hapiHome)}`, + 'HAPI_LISTEN_HOST=127.0.0.1', + `HAPI_LISTEN_PORT=${port}`, + `${shellQuote(bunBin)} run --cwd ../hub src/index.ts` + ].join(' ') +].join(' && ') + export default defineConfig({ testDir: './e2e', timeout: 180_000, @@ -29,7 +46,7 @@ export default defineConfig({ } ], webServer: { - command: `${bunBin} run build && rm -rf "${hapiHome}" && mkdir -p "${hapiHome}" && CLI_API_TOKEN=${cliApiToken} HAPI_HOME="${hapiHome}" HAPI_LISTEN_HOST=127.0.0.1 HAPI_LISTEN_PORT=${port} ${bunBin} run --cwd ../hub src/index.ts`, + command: buildCommand, url: `${baseUrl}/health`, timeout: 120_000, reuseExistingServer: false diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 744bd1a60..794a2a456 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -417,11 +417,14 @@ export function SessionList(props: { ) const enteringSessionIds = useMemo(() => { const entering = new Set() + const nextKnownSessionIds = new Set(knownSessionIdsRef.current) props.sessions.forEach(session => { - if (!knownSessionIdsRef.current.has(session.id)) { + if (!nextKnownSessionIds.has(session.id)) { entering.add(session.id) } + nextKnownSessionIds.add(session.id) }) + knownSessionIdsRef.current = nextKnownSessionIds return entering }, [props.sessions]) @@ -481,12 +484,6 @@ export function SessionList(props: { }) }, [displayGroups]) - useEffect(() => { - props.sessions.forEach(session => { - knownSessionIdsRef.current.add(session.id) - }) - }, [props.sessions]) - return (
{renderHeader ? ( @@ -519,7 +516,7 @@ export function SessionList(props: { /> {!isCollapsed ? (
- {group.sessions.map((s, index) => ( + {group.sessions.map((s) => ( | null>(null) const isLongPressRef = useRef(false) const pressPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const movedBeyondThresholdRef = useRef(false) + const moveThresholdPx = 8 const clearTimer = useCallback(() => { if (timerRef.current) { @@ -36,6 +39,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers clearTimer() isLongPressRef.current = false + movedBeyondThresholdRef.current = false pressPointRef.current = { x: clientX, y: clientY } timerRef.current = setTimeout(() => { @@ -52,6 +56,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers } isLongPressRef.current = false + movedBeyondThresholdRef.current = false }, [clearTimer, onClick]) const onPointerDown = useCallback((e) => { @@ -60,9 +65,22 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers startTimer(e.clientX, e.clientY) }, [startTimer]) + const onPointerMove = useCallback((e) => { + if (!e.isPrimary || movedBeyondThresholdRef.current) return + + const dx = e.clientX - pressPointRef.current.x + const dy = e.clientY - pressPointRef.current.y + if (Math.hypot(dx, dy) < moveThresholdPx) { + return + } + + movedBeyondThresholdRef.current = true + clearTimer() + }, [clearTimer]) + const onPointerUp = useCallback((e) => { if (!e.isPrimary) return - handleEnd(!isLongPressRef.current) + handleEnd(!isLongPressRef.current && !movedBeyondThresholdRef.current) }, [handleEnd]) const onPointerLeave = useCallback((e) => { @@ -94,6 +112,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers return { onPointerDown, + onPointerMove, onPointerUp, onPointerLeave, onPointerCancel, diff --git a/web/src/index.css b/web/src/index.css index 744c279d4..8252547c4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -385,6 +385,16 @@ body { } } +/* + * content-visibility: auto lets the browser skip layout/paint for messages + * scrolled out of the viewport. Big win on Windows with long conversations (#310). + * contain-intrinsic-size gives a rough height hint so scrollbar doesn't jump. + */ +.happy-thread-messages > * { + content-visibility: auto; + contain-intrinsic-size: auto 80px; +} + /* Markdown styles */ .markdown-content a { color: var(--app-link); text-decoration: underline; } .markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; } @@ -541,9 +551,6 @@ html[data-theme="gaius-dark"] .shiki span { @media (prefers-reduced-motion: reduce) { .animate-session-enter { animation: none; - } - - .animate-session-enter { opacity: 1; transform: none; } From 6524779d8364139202de56f89aaa357cc98db35a Mon Sep 17 00:00:00 2001 From: gaius-codius <206332531+gaius-codius@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:14:07 +0000 Subject: [PATCH 3/5] chore: remove out-of-scope theme definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip catpuccin, gaius-light, and gaius-dark theme blocks from CSS and locale files — these belong in a separate theming PR. via [HAPI](https://hapi.run) --- web/src/index.css | 206 +---------------------------------- web/src/lib/locales/en.ts | 8 -- web/src/lib/locales/zh-CN.ts | 8 -- 3 files changed, 1 insertion(+), 221 deletions(-) diff --git a/web/src/index.css b/web/src/index.css index 8252547c4..5bcc9bd2f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -135,206 +135,6 @@ --app-flavor-cursor-border: rgba(148, 163, 184, 0.28); } -[data-theme="catpuccin"] { - /* Primary colors — Catpuccin Mocha */ - --app-bg: #1e1e2e; - --app-fg: #cdd6f4; - --app-hint: #6c7086; - --app-link: #cdd6f4; - --app-button: #cdd6f4; - --app-button-text: #1e1e2e; - --app-banner-bg: #313244; - --app-banner-text: #cdd6f4; - --app-secondary-bg: #313244; - - --app-border: rgba(255, 255, 255, 0.1); - --app-divider: rgba(255, 255, 255, 0.08); - --app-subtle-bg: rgba(255, 255, 255, 0.05); - --app-code-bg: #282c34; - --app-inline-code-bg: rgba(255, 255, 255, 0.1); - - /* Diff colors (dark) */ - --app-diff-added-bg: #0d2e1f; - --app-diff-added-text: #c9d1d9; - --app-diff-removed-bg: #3f1b23; - --app-diff-removed-text: #c9d1d9; - - /* Git status colors */ - --app-git-staged-color: #a6e3a1; - --app-git-unstaged-color: #fab387; - --app-git-deleted-color: #f38ba8; - --app-git-renamed-color: #89b4fa; - --app-git-untracked-color: #6c7086; - - /* Badge colors — Catpuccin Mocha */ - --app-badge-info-bg: rgba(137, 180, 250, 0.12); - --app-badge-info-text: #89b4fa; - --app-badge-info-border: rgba(137, 180, 250, 0.22); - --app-badge-warning-bg: rgba(250, 179, 135, 0.15); - --app-badge-warning-text: #fab387; - --app-badge-warning-border: rgba(250, 179, 135, 0.25); - --app-badge-success-bg: rgba(166, 227, 161, 0.15); - --app-badge-success-text: #a6e3a1; - --app-badge-success-border: rgba(166, 227, 161, 0.25); - --app-badge-error-bg: rgba(243, 139, 168, 0.15); - --app-badge-error-text: #f38ba8; - --app-badge-error-border: rgba(243, 139, 168, 0.25); - - --app-flavor-claude: #fab387; - --app-flavor-claude-bg: rgba(250, 179, 135, 0.10); - --app-flavor-claude-text: #fab387; - --app-flavor-claude-border: rgba(250, 179, 135, 0.20); - --app-flavor-codex: #a6e3a1; - --app-flavor-codex-bg: rgba(166, 227, 161, 0.10); - --app-flavor-codex-text: #a6e3a1; - --app-flavor-codex-border: rgba(166, 227, 161, 0.20); - --app-flavor-gemini: #74c7ec; - --app-flavor-gemini-bg: rgba(116, 199, 236, 0.10); - --app-flavor-gemini-text: #74c7ec; - --app-flavor-gemini-border: rgba(116, 199, 236, 0.20); - --app-flavor-opencode: #cba6f7; - --app-flavor-opencode-bg: rgba(203, 166, 247, 0.10); - --app-flavor-opencode-text: #cba6f7; - --app-flavor-opencode-border: rgba(203, 166, 247, 0.20); - --app-flavor-cursor-bg: rgba(186, 194, 222, 0.10); - --app-flavor-cursor-text: #bac2de; - --app-flavor-cursor-border: rgba(186, 194, 222, 0.20); -} - -[data-theme="gaius-light"] { - /* Primary — warm pearl base */ - --app-bg: #f8f5f2; - --app-fg: #2a2832; - --app-hint: #85808a; - --app-link: #b04440; - --app-button: #2a2832; - --app-button-text: #f8f5f2; - --app-banner-bg: #eceae6; - --app-banner-text: #2a2832; - --app-secondary-bg: #f0ede9; - - /* Overlays */ - --app-border: rgba(42, 40, 50, 0.10); - --app-divider: rgba(42, 40, 50, 0.07); - --app-subtle-bg: rgba(42, 40, 50, 0.03); - --app-code-bg: #f0ede8; - --app-inline-code-bg: rgba(42, 40, 50, 0.05); - - /* Diffs — verdigris added, cinnabar removed */ - --app-diff-added-bg: #e4edd8; - --app-diff-added-text: #2a2832; - --app-diff-removed-bg: #f2dcd8; - --app-diff-removed-text: #2a2832; - - /* Git status */ - --app-git-staged-color: #3a7868; - --app-git-unstaged-color: #b07830; - --app-git-deleted-color: #b84440; - --app-git-renamed-color: #4068a0; - --app-git-untracked-color: #85808a; - - /* Badges — lapis, gold, verdigris, cinnabar */ - --app-badge-info-bg: rgba(64, 104, 160, 0.10); - --app-badge-info-text: #3a5888; - --app-badge-info-border: rgba(64, 104, 160, 0.20); - --app-badge-warning-bg: rgba(176, 120, 48, 0.12); - --app-badge-warning-text: #8a5820; - --app-badge-warning-border: rgba(176, 120, 48, 0.22); - --app-badge-success-bg: rgba(58, 120, 104, 0.10); - --app-badge-success-text: #2a6858; - --app-badge-success-border: rgba(58, 120, 104, 0.20); - --app-badge-error-bg: rgba(184, 68, 64, 0.10); - --app-badge-error-text: #983838; - --app-badge-error-border: rgba(184, 68, 64, 0.20); - - --app-flavor-claude: #a04038; - --app-flavor-claude-bg: rgba(160, 64, 56, 0.08); - --app-flavor-claude-text: #a04038; - --app-flavor-claude-border: rgba(160, 64, 56, 0.18); - --app-flavor-codex: #2a6858; - --app-flavor-codex-bg: rgba(42, 104, 88, 0.08); - --app-flavor-codex-text: #2a6858; - --app-flavor-codex-border: rgba(42, 104, 88, 0.18); - --app-flavor-gemini: #3a5888; - --app-flavor-gemini-bg: rgba(58, 88, 136, 0.08); - --app-flavor-gemini-text: #3a5888; - --app-flavor-gemini-border: rgba(58, 88, 136, 0.18); - --app-flavor-opencode: #6a5090; - --app-flavor-opencode-bg: rgba(106, 80, 144, 0.08); - --app-flavor-opencode-text: #6a5090; - --app-flavor-opencode-border: rgba(106, 80, 144, 0.18); - --app-flavor-cursor-bg: rgba(106, 104, 116, 0.08); - --app-flavor-cursor-text: #6a6874; - --app-flavor-cursor-border: rgba(106, 104, 116, 0.18); -} - -[data-theme="gaius-dark"] { - /* Primary — deep slate base */ - --app-bg: #1e1d22; - --app-fg: #e6e3de; - --app-hint: #88848e; - --app-link: #d06058; - --app-button: #e6e3de; - --app-button-text: #1e1d22; - --app-banner-bg: #2a2930; - --app-banner-text: #e6e3de; - --app-secondary-bg: #252428; - - /* Overlays */ - --app-border: rgba(230, 227, 222, 0.08); - --app-divider: rgba(230, 227, 222, 0.06); - --app-subtle-bg: rgba(230, 227, 222, 0.04); - --app-code-bg: #252430; - --app-inline-code-bg: rgba(230, 227, 222, 0.07); - - /* Diffs */ - --app-diff-added-bg: rgba(80, 150, 130, 0.12); - --app-diff-added-text: #d8d5d0; - --app-diff-removed-bg: rgba(200, 80, 70, 0.12); - --app-diff-removed-text: #d8d5d0; - - /* Git status */ - --app-git-staged-color: #68b8a0; - --app-git-unstaged-color: #d0a060; - --app-git-deleted-color: #d87068; - --app-git-renamed-color: #6890c8; - --app-git-untracked-color: #88848e; - - /* Badges */ - --app-badge-info-bg: rgba(104, 144, 200, 0.12); - --app-badge-info-text: #6890c8; - --app-badge-info-border: rgba(104, 144, 200, 0.20); - --app-badge-warning-bg: rgba(208, 160, 96, 0.15); - --app-badge-warning-text: #d0a060; - --app-badge-warning-border: rgba(208, 160, 96, 0.25); - --app-badge-success-bg: rgba(104, 184, 160, 0.10); - --app-badge-success-text: #68b8a0; - --app-badge-success-border: rgba(104, 184, 160, 0.20); - --app-badge-error-bg: rgba(216, 112, 104, 0.12); - --app-badge-error-text: #d87068; - --app-badge-error-border: rgba(216, 112, 104, 0.22); - - --app-flavor-claude: #d08858; - --app-flavor-claude-bg: rgba(208, 136, 88, 0.10); - --app-flavor-claude-text: #d08858; - --app-flavor-claude-border: rgba(208, 136, 88, 0.20); - --app-flavor-codex: #68b8a0; - --app-flavor-codex-bg: rgba(104, 184, 160, 0.10); - --app-flavor-codex-text: #68b8a0; - --app-flavor-codex-border: rgba(104, 184, 160, 0.20); - --app-flavor-gemini: #6890c8; - --app-flavor-gemini-bg: rgba(104, 144, 200, 0.10); - --app-flavor-gemini-text: #6890c8; - --app-flavor-gemini-border: rgba(104, 144, 200, 0.20); - --app-flavor-opencode: #a088c0; - --app-flavor-opencode-bg: rgba(160, 136, 192, 0.10); - --app-flavor-opencode-text: #a088c0; - --app-flavor-opencode-border: rgba(160, 136, 192, 0.20); - --app-flavor-cursor-bg: rgba(176, 172, 182, 0.10); - --app-flavor-cursor-text: #b0acb6; - --app-flavor-cursor-border: rgba(176, 172, 182, 0.20); -} - html { font-size: calc(100% * var(--app-font-scale, 1)); } @@ -430,11 +230,7 @@ body { } html[data-theme="dark"] .shiki, -html[data-theme="dark"] .shiki span, -html[data-theme="catpuccin"] .shiki, -html[data-theme="catpuccin"] .shiki span, -html[data-theme="gaius-dark"] .shiki, -html[data-theme="gaius-dark"] .shiki span { +html[data-theme="dark"] .shiki span { color: var(--shiki-dark) !important; font-style: var(--shiki-dark-font-style) !important; font-weight: var(--shiki-dark-font-weight) !important; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index b90f8eb6c..f26126109 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -257,14 +257,6 @@ export default { 'settings.language.title': 'Language', 'settings.language.label': 'Language', 'settings.display.title': 'Display', - 'settings.display.theme': 'Theme', - 'settings.display.theme.system': 'System', - 'settings.display.theme.light': 'Light', - 'settings.display.theme.dark': 'Dark', - 'settings.display.theme.catpuccin': 'Catppuccin', - 'settings.display.theme.gaius': 'Gaius', - 'settings.display.theme.gaius-light': 'Gaius Light', - 'settings.display.theme.gaius-dark': 'Gaius Dark', 'settings.display.appearance': 'Appearance', 'settings.display.appearance.system': 'Follow System', 'settings.display.appearance.dark': 'Dark', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index d024d6381..ea220f5a7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -259,14 +259,6 @@ export default { 'settings.language.title': '语言', 'settings.language.label': '语言', 'settings.display.title': '显示', - 'settings.display.theme': '主题', - 'settings.display.theme.system': '跟随系统', - 'settings.display.theme.light': '浅色', - 'settings.display.theme.dark': '深色', - 'settings.display.theme.catpuccin': 'Catppuccin', - 'settings.display.theme.gaius': 'Gaius', - 'settings.display.theme.gaius-light': 'Gaius 浅色', - 'settings.display.theme.gaius-dark': 'Gaius 深色', 'settings.display.appearance': '外观', 'settings.display.appearance.system': '跟随系统', 'settings.display.appearance.dark': '深色', From c9d5660b0b0467f06bb9a75387e681252db3cb18 Mon Sep 17 00:00:00 2001 From: gaius-codius <206332531+gaius-codius@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:00:40 +0000 Subject: [PATCH 4/5] chore: sync branch with main and refresh lockfile --- bun.lock | 4 +- hub/src/sync/syncEngine.ts | 66 ++++++++++++++++++++++++++-- shared/src/schemas.ts | 27 ++++++++++++ web/src/components/SessionHeader.tsx | 2 - web/src/components/SessionList.tsx | 50 +++++---------------- web/src/index.css | 8 +--- web/src/router.tsx | 33 +++++--------- web/src/types/api.ts | 13 ++++++ 8 files changed, 129 insertions(+), 74 deletions(-) diff --git a/bun.lock b/bun.lock index caa5d8187..bc1f24036 100644 --- a/bun.lock +++ b/bun.lock @@ -883,7 +883,9 @@ "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.16.5", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-UlYpOd89C3ePAiy/6Zcz9NziN2vFIEMipBeS0mb6s4U7qP3Um6E4BwoKIUhIlriy8RxyZxRvXq1EtMxECh6M+g=="], - "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.15.2", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-vuQH9INKvqwVZ9JphUmEyzKz6zWxHy3GE4j0mDqTulO234nGEhljkN3Qod0d7bLVncmO53zSMcm+ahwZ3qL4OA=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.16.5", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-Cdo2B/BCnDJRkkGHxMo8UVVcKXJyuS7bnr+JtJL4f6kp7o8T2T9o/85S05Z/TF8k5dAxz2Np8rpJgmhBd/1boA=="], + + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.16.5", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-jnl5zxT2AIslIy2X9jYklTUIob/Om8RlPIx2QbobkYP5+mMpPWUpJsBFx1NdN8FDMApcVQ/tqh6v2d72m16rDA=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 0efe6144b..77e147b54 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -12,6 +12,9 @@ import type { DecryptedMessage, PermissionMode, Session, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, SyncEvent } from '@hapi/protocol/types' import type { Server } from 'socket.io' @@ -48,6 +51,10 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type SetSessionSortPreferenceResult = + | { result: 'success'; preference: SessionSortPreference } + | { result: 'version-mismatch'; preference: SessionSortPreference } + | { result: 'error' } export class SyncEngine { private readonly store: Store @@ -106,6 +113,62 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + getSessionSortPreference(userId: number, namespace: string): SessionSortPreference { + const preference = this.store.sessionSortPreferences.getByUser(userId, namespace) + return { + sortMode: preference.sortMode, + manualOrder: preference.manualOrder, + version: preference.version, + updatedAt: preference.updatedAt + } + } + + setSessionSortPreference( + userId: number, + namespace: string, + input: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number + } + ): SetSessionSortPreferenceResult { + const result = this.store.sessionSortPreferences.upsertByUser( + userId, + namespace, + { + sortMode: input.sortMode, + manualOrder: input.manualOrder + }, + input.expectedVersion + ) + + if (result.result === 'error') { + return { result: 'error' } + } + + const preference: SessionSortPreference = { + sortMode: result.preference.sortMode, + manualOrder: result.preference.manualOrder, + version: result.preference.version, + updatedAt: result.preference.updatedAt + } + + if (result.result === 'success') { + this.eventPublisher.emit({ + type: 'session-sort-preference-updated', + namespace, + data: { + userId, + version: preference.version + } + }) + } + + return { + result: result.result, + preference + } + } getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined @@ -307,9 +370,6 @@ export class SyncEngine { modelMode?: Session['modelMode'] } ): Promise { - // modelMode is currently hub-managed metadata only. CLI agents do not yet - // accept it through set-session-config RPC, so we apply the validated value - // locally after the agent acknowledges the rest of the config. const rpcConfig = { permissionMode: config.permissionMode, model: config.model, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a0b696e0d..d630942b1 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -4,6 +4,26 @@ import { CODEX_COLLABORATION_MODES, MODEL_MODES, PERMISSION_MODES } from './mode export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const ModelModeSchema = z.enum(MODEL_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) +export const SessionSortModeSchema = z.enum(['auto', 'manual']) + +export type SessionSortMode = z.infer + +export const SessionManualOrderSchema = z.object({ + groupOrder: z.array(z.string().max(256)).max(500), + sessionOrder: z.record(z.string().max(256), z.array(z.string().max(128)).max(200)) +}).strict() + +export type SessionManualOrder = z.infer + +export const SessionSortPreferenceSchema = z.object({ + sortMode: SessionSortModeSchema, + manualOrder: SessionManualOrderSchema, + version: z.number().int().positive(), + updatedAt: z.number().int().nonnegative() +}).strict() + +export type SessionSortPreference = z.infer + const MetadataSummarySchema = z.object({ text: z.string(), updatedAt: z.number() @@ -238,6 +258,13 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ subscriptionId: z.string().optional() }).optional() }), + SessionEventBaseSchema.extend({ + type: z.literal('session-sort-preference-updated'), + data: z.object({ + userId: z.number().int().nonnegative(), + version: z.number().int().positive() + }) + }) ]) export type SyncEvent = z.infer diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 68147f31e..416f5a9ff 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -2,7 +2,6 @@ import { Fragment, type ReactNode, useId, useMemo, useRef, useState } from 'reac import { getPermissionModeLabel, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import type { Session } from '@/types/api' import type { ApiClient } from '@/api/client' -import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { isTelegramApp } from '@/hooks/useTelegram' import { useSessionActions } from '@/hooks/mutations/useSessionActions' import { SessionActionMenu } from '@/components/SessionActionMenu' @@ -11,7 +10,6 @@ import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { formatEffortLabel, getFlavorTextClass, META_DOT_SEPARATOR_CLASS } from '@/lib/agentFlavorUtils' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' -import { getFlavorBadgeClass, PERMISSION_TONE_BADGE } from '@/lib/agentFlavorUtils' function getSessionTitle(session: Session): string { if (session.metadata?.name) { diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 40249859a..794a2a456 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,14 +1,10 @@ import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import type { SessionSummary } from '@/types/api' import type { ApiClient } from '@/api/client' -import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' import { useLongPress } from '@/hooks/useLongPress' import { usePlatform } from '@/hooks/usePlatform' import { useSessionActions } from '@/hooks/mutations/useSessionActions' -import { useSortToggle } from '@/hooks/useSortToggle' -import { SortIcon, PinIcon } from '@/components/icons/SortIcons' import { SessionActionMenu } from '@/components/SessionActionMenu' -import { GroupActionMenu } from '@/components/GroupActionMenu' import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { @@ -19,7 +15,6 @@ import { } from '@/lib/agentFlavorUtils' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' -import { getFlavorTextClass, PERMISSION_TONE_TEXT } from '@/lib/agentFlavorUtils' type SessionGroup = { key: string @@ -47,7 +42,7 @@ export function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGro sessions.forEach(session => { const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other' const machineId = session.metadata?.machineId ?? null - const key = `${machineId ?? '__unknown__'}::${path}` + const key = `${machineId ?? UNKNOWN_MACHINE_ID}::${path}` if (!groups.has(key)) { groups.set(key, { directory: path, @@ -239,18 +234,6 @@ function SessionItem(props: { const statusDotClass = s.active ? (s.thinking ? 'bg-[var(--app-badge-info-text)]' : 'bg-[var(--app-badge-success-text)]') : 'bg-[var(--app-hint)]' - - const flavor = s.metadata?.flavor?.trim() ?? null - const flavorTextClass = getFlavorTextClass(flavor) - - const permMode = s.permissionMode - && s.permissionMode !== 'default' - && isPermissionModeAllowedForFlavor(s.permissionMode, flavor) - ? s.permissionMode - : null - const permLabel = permMode ? getPermissionModeLabel(permMode).toLowerCase() : null - const permTone = permMode ? getPermissionModeTone(permMode) : null - const permTextClass = permTone ? PERMISSION_TONE_TEXT[permTone] : '' const todoProgress = getTodoProgress(s) const inactiveClass = s.active ? '' : 'opacity-[0.55]' const metadataItems = [ @@ -316,11 +299,6 @@ function SessionItem(props: { isOpen={menuOpen} onClose={() => setMenuOpen(false)} sessionActive={s.active} - manualMode={manualMode} - onMoveUp={onMoveUp} - onMoveDown={onMoveDown} - canMoveUp={canMoveUp} - canMoveDown={canMoveDown} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} @@ -459,14 +437,14 @@ export function SessionList(props: { return !group.hasActiveSession && !hasSelectedSession } - const closeGroupActionMenu = () => { - setGroupMenuOpen(false) + const toggleGroup = (groupKey: string, isCollapsed: boolean) => { + setCollapseOverrides(prev => { + const next = new Map(prev) + next.set(groupKey, !isCollapsed) + return next + }) } - const groupMenuIndex = groupMenuKey ? orderedGroups.findIndex((group) => group.key === groupMenuKey) : -1 - const canMoveGroupUp = groupMenuIndex > 0 - const canMoveGroupDown = groupMenuIndex >= 0 && groupMenuIndex < orderedGroups.length - 1 - const resolveMachineLabel = (machineId: string | null): string => { if (machineId && machineLabelsById[machineId]) { return machineLabelsById[machineId] @@ -476,20 +454,16 @@ export function SessionList(props: { } return t('machine.unknown') } - const isGroupCollapsed = (group: SessionGroup): boolean => { - const override = collapseOverrides.get(group.key) - if (override !== undefined) return override - return !group.hasActiveSession - } - const toggleGroup = (groupKey: string, isCollapsed: boolean) => { + useEffect(() => { + if (!selectedSessionId) return setCollapseOverrides(prev => { const group = displayGroups.find(g => g.sessions.some(s => s.id === selectedSessionId) ) if (!group || !prev.has(group.key) || !prev.get(group.key)) return prev const next = new Map(prev) - next.set(groupKey, !isCollapsed) + next.delete(group.key) return next }) }, [selectedSessionId, displayGroups]) @@ -531,7 +505,7 @@ export function SessionList(props: {
{displayGroups.map((group) => { const isCollapsed = isGroupCollapsed(group) - const groupMachineLabel = resolveMachineLabel(group.machineId) + const machineLabel = resolveMachineLabel(group.machineId) return (
{!isCollapsed ? (
- {group.sessions.map((s, index) => ( + {group.sessions.map((s) => ( -
) } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 738991ceb..29dc5e53e 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -218,6 +218,19 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type SessionSortPreferenceResponse = { + preference: SessionSortPreference +} + +export type SetSessionSortPreferencePayload = { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number +} + +export type SetSessionSortPreferenceResult = + | { status: 'success'; preference: SessionSortPreference } + | { status: 'version-mismatch'; preference: SessionSortPreference } export type VisibilityPayload = { subscriptionId: string From 156c907e0ed85665bb19592fee53a581b96dece4 Mon Sep 17 00:00:00 2001 From: gaius-codius <206332531+gaius-codius@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:35:59 +0000 Subject: [PATCH 5/5] fix: address PR #4 review findings - normalize flavor in supportsModelChange and add coverage - tighten sessionAliveSchema bounds (sid, time, model, effort) - replace inline ModelMode import with named type import - add useLongPress unit tests (pointer gating, threshold, keyboard) - add positive assertions alongside SessionHeader negative checks - decouple e2e login wait from English copy - document session-metadata e2e spec and shared env knobs - note session-alive payload validation in hub README --- hub/README.md | 2 +- .../socket/handlers/cli/sessionHandlers.ts | 12 +- web/README.md | 7 +- web/e2e/session-metadata.ui.e2e.spec.ts | 3 +- web/src/components/SessionHeader.test.tsx | 4 + web/src/hooks/useLongPress.test.ts | 204 ++++++++++++++++++ web/src/lib/agentFlavorUtils.test.ts | 24 ++- web/src/lib/agentFlavorUtils.ts | 3 +- 8 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 web/src/hooks/useLongPress.test.ts diff --git a/hub/README.md b/hub/README.md index faed72973..8bef705f9 100644 --- a/hub/README.md +++ b/hub/README.md @@ -151,7 +151,7 @@ Namespace: `/cli` - `message` - Send message to session. - `update-metadata` - Update session metadata. - `update-state` - Update agent state. -- `session-alive` - Keep session active. +- `session-alive` - Keep session active. Payloads are validated against the shared protocol schemas; unknown `permissionMode`, `modelMode`, or `collaborationMode` values and out-of-range `sid`/`time` values cause the heartbeat to be dropped. Forked or older CLIs emitting non-standard values must upgrade. - `session-end` - Mark session ended. - `machine-alive` - Keep machine online. - `rpc-register` - Register RPC handler. diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 50c43ed7b..845ef7a6c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -2,7 +2,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { CodexCollaborationModeSchema, ModelModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, ModelMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -19,7 +19,7 @@ type SessionAlivePayload = { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode - modelMode?: import('@hapi/protocol/types').ModelMode + modelMode?: ModelMode } type SessionEndPayload = { @@ -53,13 +53,13 @@ const updateStateSchema = z.object({ }) const sessionAliveSchema = z.object({ - sid: z.string(), - time: z.number(), + sid: z.string().min(1).max(128), + time: z.number().finite().nonnegative(), thinking: z.boolean().optional(), mode: z.enum(['local', 'remote']).optional(), permissionMode: PermissionModeSchema.optional(), - model: z.string().nullable().optional(), - effort: z.string().nullable().optional(), + model: z.string().max(256).nullable().optional(), + effort: z.string().max(64).nullable().optional(), collaborationMode: CodexCollaborationModeSchema.optional(), modelMode: ModelModeSchema.optional() }) diff --git a/web/README.md b/web/README.md index efca3a36a..037dcf3c9 100644 --- a/web/README.md +++ b/web/README.md @@ -142,9 +142,12 @@ The built assets land in `web/dist` and are served by hapi-hub. The single execu ## E2E (Playwright) -Session-sort backend persistence has Playwright coverage in: +Playwright covers: -- `web/e2e/session-sort.backend.e2e.spec.ts` +- `web/e2e/session-sort.backend.e2e.spec.ts` — session-sort backend persistence. +- `web/e2e/session-metadata.ui.e2e.spec.ts` — session list and header metadata chip rendering. + +Shared env knobs: `HAPI_E2E_BASE_URL` (default `http://127.0.0.1:3906`), `HAPI_E2E_CLI_TOKEN` (default `pw-test-token`), and `HAPI_E2E_RUN_ID` (defaults to a random suffix) namespace test sessions across runs. Install browser once: diff --git a/web/e2e/session-metadata.ui.e2e.spec.ts b/web/e2e/session-metadata.ui.e2e.spec.ts index 887c48965..9df48c4f5 100644 --- a/web/e2e/session-metadata.ui.e2e.spec.ts +++ b/web/e2e/session-metadata.ui.e2e.spec.ts @@ -12,7 +12,8 @@ async function login(page: Page, accessToken: string): Promise { await page.goto(BASE_URL, { waitUntil: 'networkidle' }) await page.getByPlaceholder('Access token').fill(accessToken) await page.getByRole('button', { name: 'Sign In' }).click() - await expect(page.getByText(/sessions in .* projects/i)).toBeVisible({ timeout: 15_000 }) + await expect(page.getByPlaceholder('Access token')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.locator('.session-list-item').first()).toBeVisible({ timeout: 15_000 }) } async function createCliSession( diff --git a/web/src/components/SessionHeader.test.tsx b/web/src/components/SessionHeader.test.tsx index 0fdc7da9e..9cab6f28d 100644 --- a/web/src/components/SessionHeader.test.tsx +++ b/web/src/components/SessionHeader.test.tsx @@ -93,11 +93,15 @@ describe('SessionHeader', () => { it('hides effort when absent', () => { const markup = renderHeader({ effort: null }) expect(markup).not.toContain('Very High') + expect(markup).toContain('Named Session') + expect(markup).toContain('gpt-5.4') }) it('hides permission mode when default', () => { const markup = renderHeader({ permissionMode: 'default' }) expect(markup).not.toContain('Yolo') + expect(markup).toContain('Named Session') + expect(markup).toContain('codex') }) it('hides worktree when absent', () => { diff --git a/web/src/hooks/useLongPress.test.ts b/web/src/hooks/useLongPress.test.ts new file mode 100644 index 000000000..1c0a3146f --- /dev/null +++ b/web/src/hooks/useLongPress.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type React from 'react' +import { useLongPress } from './useLongPress' + +function pointerEvent(init: Partial<{ + isPrimary: boolean + pointerType: 'mouse' | 'touch' | 'pen' + button: number + clientX: number + clientY: number +}> = {}): React.PointerEvent { + return { + isPrimary: init.isPrimary ?? true, + pointerType: init.pointerType ?? 'touch', + button: init.button ?? 0, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0 + } as unknown as React.PointerEvent +} + +describe('useLongPress', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fires onLongPress after the threshold elapses', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 10, clientY: 20 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).toHaveBeenCalledWith({ x: 10, y: 20 }) + expect(onClick).not.toHaveBeenCalled() + }) + + it('fires onClick on pointer up before the threshold', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerUp(pointerEvent()) + }) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('cancels the timer and suppresses click when movement exceeds the threshold', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 0, clientY: 0 })) + result.current.onPointerMove(pointerEvent({ clientX: 20, clientY: 0 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + act(() => { + result.current.onPointerUp(pointerEvent({ clientX: 20, clientY: 0 })) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + }) + + it('ignores sub-threshold movement', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ clientX: 0, clientY: 0 })) + result.current.onPointerMove(pointerEvent({ clientX: 3, clientY: 2 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).toHaveBeenCalledTimes(1) + }) + + it('ignores non-primary pointers and non-left mouse buttons', () => { + const onLongPress = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent({ isPrimary: false })) + result.current.onPointerDown(pointerEvent({ pointerType: 'mouse', button: 2 })) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('does not start the long-press timer when disabled', () => { + const onLongPress = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, threshold: 500, disabled: true }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(1_000) + }) + + expect(onLongPress).not.toHaveBeenCalled() + }) + + it('ignores keyboard activation when disabled', () => { + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress: vi.fn(), onClick, disabled: true }) + ) + + const preventDefault = vi.fn() + const enter = { key: 'Enter', preventDefault } as unknown as React.KeyboardEvent + + act(() => { + result.current.onKeyDown(enter) + }) + + expect(onClick).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('cancels on pointer cancel and pointer leave', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick, threshold: 500 }) + ) + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerCancel(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + + act(() => { + result.current.onPointerDown(pointerEvent()) + result.current.onPointerLeave(pointerEvent()) + }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(onLongPress).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + }) + + it('triggers onClick on Enter and Space keys', () => { + const onLongPress = vi.fn() + const onClick = vi.fn() + const { result } = renderHook(() => + useLongPress({ onLongPress, onClick }) + ) + + const preventDefault = vi.fn() + const enter = { key: 'Enter', preventDefault } as unknown as React.KeyboardEvent + const space = { key: ' ', preventDefault } as unknown as React.KeyboardEvent + + act(() => { + result.current.onKeyDown(enter) + result.current.onKeyDown(space) + }) + + expect(onClick).toHaveBeenCalledTimes(2) + expect(preventDefault).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/src/lib/agentFlavorUtils.test.ts b/web/src/lib/agentFlavorUtils.test.ts index 689d6612d..cdc6cbbc9 100644 --- a/web/src/lib/agentFlavorUtils.test.ts +++ b/web/src/lib/agentFlavorUtils.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { formatEffortLabel, getFlavorTextClass, - META_DOT_SEPARATOR_CLASS + META_DOT_SEPARATOR_CLASS, + supportsModelChange } from './agentFlavorUtils' describe('getFlavorTextClass', () => { @@ -48,3 +49,24 @@ describe('META_DOT_SEPARATOR_CLASS', () => { expect(META_DOT_SEPARATOR_CLASS).toBe('text-[var(--app-hint)] opacity-40') }) }) + +describe('supportsModelChange', () => { + it.each(['claude', 'gemini'])('returns true for %s', (flavor) => { + expect(supportsModelChange(flavor)).toBe(true) + }) + + it.each(['codex', 'cursor', 'opencode', 'mystery'])('returns false for %s', (flavor) => { + expect(supportsModelChange(flavor)).toBe(false) + }) + + it('returns false for nullish values', () => { + expect(supportsModelChange(null)).toBe(false) + expect(supportsModelChange(undefined)).toBe(false) + expect(supportsModelChange('')).toBe(false) + }) + + it('normalizes whitespace and casing', () => { + expect(supportsModelChange(' Claude ')).toBe(true) + expect(supportsModelChange('GEMINI')).toBe(true) + }) +}) diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index b2e463e50..52cfa640c 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -52,5 +52,6 @@ export function formatEffortLabel(effort?: string | null): string | null { } export function supportsModelChange(flavor?: string | null): boolean { - return flavor === 'claude' || flavor === 'gemini' + const normalized = normalizeFlavor(flavor) + return normalized === 'claude' || normalized === 'gemini' }