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/2] 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/2] 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; }