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/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 67ec014b7..845ef7a6c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,8 @@ 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' @@ -18,6 +19,7 @@ type SessionAlivePayload = { model?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + modelMode?: ModelMode } type SessionEndPayload = { @@ -50,6 +52,18 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) +const sessionAliveSchema = z.object({ + 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().max(256).nullable().optional(), + effort: z.string().max(64).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/package.json b/package.json index 6d3f7b88d..8c0b05347 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", "test:e2e:web": "cd web && bun run test:e2e", - "test:e2e:web:session-sort": "cd web && bun run test:e2e:session-sort", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, 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 new file mode 100644 index 000000000..9df48c4f5 --- /dev/null +++ b/web/e2e/session-metadata.ui.e2e.spec.ts @@ -0,0 +1,83 @@ +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.getByPlaceholder('Access token')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.locator('.session-list-item').first()).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 9f82a8754..e719e5eb4 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,6 @@ "preview": "vite preview", "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:session-sort": "BUN_BIN=${BUN_BIN:-$(command -v bun || echo $HOME/.bun/bin/bun)} playwright test --config=playwright.config.ts --grep \"session sort\"", "test:e2e:install": "playwright install chromium" }, "dependencies": { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 178ec7108..b19469a85 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -2,10 +2,27 @@ 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-session-sort-${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' +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/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 badges */}
{title}
-
- - {flavorLabel} - - {permissionLabel && permissionBadgeClass ? ( - - {permissionLabel} - - ) : null} - {showModelModeBadge ? ( - - {session.modelMode || 'default'} - - ) : null} - {worktreeBranch ? ( - - {worktreeBranch} - - ) : null} +
+ {metadataItems.map((item, index) => ( + + {index > 0 ? ( + + ) : null} + {item} + + ))}
@@ -174,7 +167,7 @@ export function SessionHeader(props: { setMenuOpen(false)} sessionActive={s.active} - manualMode={manualMode} - onMoveUp={onMoveUp} - onMoveDown={onMoveDown} - canMoveUp={canMoveUp} - canMoveDown={canMoveDown} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} @@ -403,18 +344,12 @@ function GroupHeader(props: { group: SessionGroup isCollapsed: boolean machineLabel: string - manualMode: boolean onToggle: () => void - onLongPressMenu: (groupKey: string, point: { x: number; y: number }) => void }) { const { haptic } = usePlatform() const longPressHandlers = useLongPress({ - onLongPress: (point) => { - if (!props.manualMode) { - return - } + onLongPress: () => { haptic.impact('medium') - props.onLongPressMenu(props.group.key, point) }, onClick: props.onToggle, threshold: 500 @@ -424,27 +359,28 @@ function GroupHeader(props: { - + {t('sessions.count', { n: props.sessions.length, m: displayGroups.length })}
+
) : null}
- {orderedGroups.map((group) => { + {displayGroups.map((group) => { const isCollapsed = isGroupCollapsed(group) - const groupMachineLabel = resolveMachineLabel(group.machineId) + const machineLabel = resolveMachineLabel(group.machineId) return (
toggleGroup(group.key, isCollapsed)} - onLongPressMenu={openGroupActionMenu} /> {!isCollapsed ? (
- {group.sessions.map((s, index) => ( + {group.sessions.map((s) => ( moveSessionInPreference(group.key, s.id, 'up')} - onMoveDown={() => moveSessionInPreference(group.key, s.id, 'down')} - canMoveUp={index > 0} - canMoveDown={index < group.sessions.length - 1} + animateEnter={enteringSessionIds.has(s.id)} /> ))}
@@ -618,25 +533,6 @@ export function SessionList(props: { })}
- { - if (!groupMenuKey) { - return - } - moveGroupInPreference(groupMenuKey, 'up') - }} - onMoveDown={() => { - if (!groupMenuKey) { - return - } - moveGroupInPreference(groupMenuKey, 'down') - }} - canMoveUp={canMoveGroupUp} - canMoveDown={canMoveGroupDown} - anchorPoint={groupMenuAnchor} - />
) } 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/hooks/useLongPress.ts b/web/src/hooks/useLongPress.ts index 5673b2d7d..ec65f7882 100644 --- a/web/src/hooks/useLongPress.ts +++ b/web/src/hooks/useLongPress.ts @@ -9,12 +9,11 @@ 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 + onPointerMove: React.PointerEventHandler + onPointerUp: React.PointerEventHandler + onPointerLeave: React.PointerEventHandler + onPointerCancel: React.PointerEventHandler onContextMenu: React.MouseEventHandler onKeyDown: React.KeyboardEventHandler } @@ -24,8 +23,9 @@ 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 movedBeyondThresholdRef = useRef(false) + const moveThresholdPx = 8 const clearTimer = useCallback(() => { if (timerRef.current) { @@ -39,7 +39,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers clearTimer() isLongPressRef.current = false - touchMoved.current = false + movedBeyondThresholdRef.current = false pressPointRef.current = { x: clientX, y: clientY } timerRef.current = setTimeout(() => { @@ -51,44 +51,48 @@ 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 + movedBeyondThresholdRef.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(() => { - handleEnd(!isLongPressRef.current) + 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 && !movedBeyondThresholdRef.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 +111,11 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers }, [disabled, onClick]) return { - onMouseDown, - onMouseUp, - onMouseLeave, - onTouchStart, - onTouchEnd, - onTouchMove, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerLeave, + onPointerCancel, onContextMenu, onKeyDown } diff --git a/web/src/index.css b/web/src/index.css index e3b9b4276..5bcc9bd2f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11,7 +11,6 @@ --app-banner-bg: var(--tg-theme-button-color, #111827); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #f3f4f6); - --app-selected-bg: rgba(59, 130, 246, 0.08); /* Theme-aware colors (light mode defaults) */ --app-border: rgba(0, 0, 0, 0.1); @@ -34,9 +33,9 @@ --app-git-untracked-color: #8E8E93; /* Badge colors (light) */ - --app-badge-info-bg: rgba(59, 130, 246, 0.15); + --app-badge-info-bg: rgba(59, 130, 246, 0.12); --app-badge-info-text: #1d4ed8; - --app-badge-info-border: rgba(59, 130, 246, 0.25); + --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); @@ -47,21 +46,25 @@ --app-badge-error-text: #b91c1c; --app-badge-error-border: rgba(239, 68, 68, 0.3); - --app-perm-warning: #c2410c; - - /* Agent flavor colors (light) */ - --app-flavor-claude: #92400e; + --app-flavor-claude: #b45309; --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); - --app-flavor-claude-border: rgba(245, 158, 11, 0.25); - --app-flavor-codex: #065f46; - --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); - --app-flavor-codex-border: rgba(16, 185, 129, 0.25); - --app-flavor-gemini: #1e40af; - --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); - --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); - --app-flavor-opencode: #5b21b6; - --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); - --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); + --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; } @@ -77,7 +80,6 @@ --app-banner-bg: var(--tg-theme-button-color, #3A3A3C); --app-banner-text: var(--tg-theme-button-text-color, #ffffff); --app-secondary-bg: var(--tg-theme-secondary-bg-color, #2C2C2E); - --app-selected-bg: rgba(59, 130, 246, 0.08); --app-border: rgba(255, 255, 255, 0.1); --app-divider: rgba(255, 255, 255, 0.08); @@ -99,9 +101,9 @@ --app-git-untracked-color: #9ca3af; /* Badge colors (dark) */ - --app-badge-info-bg: rgba(96, 165, 250, 0.2); - --app-badge-info-text: #60a5fa; - --app-badge-info-border: rgba(96, 165, 250, 0.3); + --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); @@ -112,212 +114,25 @@ --app-badge-error-text: #fca5a5; --app-badge-error-border: rgba(248, 113, 113, 0.35); - --app-perm-warning: #fb923c; - - /* Agent flavor colors (dark) */ - --app-flavor-claude: #fbbf24; - --app-flavor-claude-bg: rgba(245, 158, 11, 0.12); - --app-flavor-claude-border: rgba(245, 158, 11, 0.25); - --app-flavor-codex: #34d399; - --app-flavor-codex-bg: rgba(16, 185, 129, 0.12); - --app-flavor-codex-border: rgba(16, 185, 129, 0.25); - --app-flavor-gemini: #60a5fa; - --app-flavor-gemini-bg: rgba(59, 130, 246, 0.12); - --app-flavor-gemini-border: rgba(59, 130, 246, 0.25); - --app-flavor-opencode: #a78bfa; - --app-flavor-opencode-bg: rgba(139, 92, 246, 0.12); - --app-flavor-opencode-border: rgba(139, 92, 246, 0.25); -} - -[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-selected-bg: rgba(137, 180, 250, 0.06); - - --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-perm-warning: #fab387; - - /* Agent flavor colors — Catpuccin Mocha */ - --app-flavor-claude: #fab387; - --app-flavor-claude-bg: rgba(250, 179, 135, 0.10); - --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-border: rgba(166, 227, 161, 0.20); - --app-flavor-gemini: #74c7ec; - --app-flavor-gemini-bg: rgba(116, 199, 236, 0.10); - --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-border: rgba(203, 166, 247, 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; - --app-selected-bg: rgba(176, 68, 64, 0.06); - - /* 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-perm-warning: #b84430; - - /* Agent flavors — cinnabar, verdigris, lapis, violet */ - --app-flavor-claude: #a04038; - --app-flavor-claude-bg: rgba(160, 64, 56, 0.08); - --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-border: rgba(42, 104, 88, 0.18); - --app-flavor-gemini: #3a5888; - --app-flavor-gemini-bg: rgba(58, 88, 136, 0.08); - --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-border: rgba(106, 80, 144, 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; - --app-selected-bg: rgba(208, 96, 88, 0.06); - - /* 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-perm-warning: #d08050; - - /* Agent flavors */ - --app-flavor-claude: #d08858; - --app-flavor-claude-bg: rgba(208, 136, 88, 0.10); - --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-border: rgba(104, 184, 160, 0.20); - --app-flavor-gemini: #6890c8; - --app-flavor-gemini-bg: rgba(104, 144, 200, 0.10); - --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-border: rgba(160, 136, 192, 0.20); + --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); } html { @@ -329,22 +144,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); @@ -425,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; @@ -527,6 +328,30 @@ html[data-theme="gaius-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; + 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..cdc6cbbc9 --- /dev/null +++ b/web/src/lib/agentFlavorUtils.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + formatEffortLabel, + getFlavorTextClass, + META_DOT_SEPARATOR_CLASS, + supportsModelChange +} 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') + }) +}) + +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 228658b93..52cfa640c 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -1,97 +1,57 @@ -import type { PermissionModeTone } from '@hapi/protocol' - -export const PERMISSION_TONE_BADGE: Record = { - neutral: 'text-[var(--app-fg)] bg-[var(--app-subtle-bg)] border-[var(--app-border)]', - info: 'text-[var(--app-badge-info-text)] bg-[var(--app-badge-info-bg)] border-[var(--app-badge-info-border)]', - warning: 'text-[var(--app-perm-warning)] bg-[var(--app-badge-warning-bg)] border-[var(--app-badge-warning-border)]', - danger: 'text-[var(--app-badge-error-text)] bg-[var(--app-badge-error-bg)] border-[var(--app-badge-error-border)]' +function normalizeFlavor(flavor?: string | null): string | null { + const normalized = flavor?.trim().toLowerCase() + return normalized || null } -export const PERMISSION_TONE_TEXT: Record = { - neutral: 'text-[var(--app-fg)]', - info: 'text-[var(--app-badge-info-text)]', - warning: 'text-[var(--app-perm-warning)]', - danger: 'text-[var(--app-badge-error-text)]' -} +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) } -type FlavorColors = { - text: string - bg: string - border: string +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' } -const FLAVOR_COLORS: Record = { - claude: { - text: 'text-[var(--app-flavor-claude)]', - bg: 'bg-[var(--app-flavor-claude-bg)]', - border: 'border-[var(--app-flavor-claude-border)]' - }, - codex: { - text: 'text-[var(--app-flavor-codex)]', - bg: 'bg-[var(--app-flavor-codex-bg)]', - border: 'border-[var(--app-flavor-codex-border)]' - }, - gemini: { - text: 'text-[var(--app-flavor-gemini)]', - bg: 'bg-[var(--app-flavor-gemini-bg)]', - border: 'border-[var(--app-flavor-gemini-border)]' - }, - opencode: { - text: 'text-[var(--app-flavor-opencode)]', - bg: 'bg-[var(--app-flavor-opencode-bg)]', - border: 'border-[var(--app-flavor-opencode-border)]' - } -} - -const DEFAULT_FLAVOR_COLORS: FlavorColors = { - text: 'text-[var(--app-hint)]', - bg: 'bg-[var(--app-subtle-bg)]', - border: 'border-[var(--app-border)]' -} - -export function getFlavorColors(flavor?: string | null): FlavorColors { - const key = flavor?.trim() - if (key && FLAVOR_COLORS[key]) return FLAVOR_COLORS[key] - return DEFAULT_FLAVOR_COLORS +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 function getFlavorBadgeClass(flavor?: string | null): string { - const colors = getFlavorColors(flavor) - return `${colors.text} ${colors.bg} ${colors.border}` -} +export const META_DOT_SEPARATOR_CLASS = 'text-[var(--app-hint)] opacity-40' -export function getFlavorTextClass(flavor?: string | null): string { - const colors = getFlavorColors(flavor) - return colors.text -} +export function formatEffortLabel(effort?: string | null): string | null { + const normalized = effort?.trim() + if (!normalized) return null -export function getFlavorDotClass(flavor?: string | null): string { - const key = flavor?.trim() - switch (key) { - case 'claude': return 'bg-[var(--app-flavor-claude)]' - case 'codex': return 'bg-[var(--app-flavor-codex)]' - case 'gemini': return 'bg-[var(--app-flavor-gemini)]' - case 'opencode': return 'bg-[var(--app-flavor-opencode)]' - default: return 'bg-[var(--app-hint)]' - } + 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' + const normalized = normalizeFlavor(flavor) + return normalized === 'claude' || normalized === 'gemini' } diff --git a/web/src/router.tsx b/web/src/router.tsx index 9a91bb9a3..b78f50ef9 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -12,9 +12,8 @@ import { useParams, } from '@tanstack/react-router' import { App } from '@/App' -import { SortIcon, PinIcon } from '@/components/icons/SortIcons' import { SessionChat } from '@/components/SessionChat' -import { SessionList, groupSessionsByDirectory } from '@/components/SessionList' +import { SessionList } from '@/components/SessionList' import { NewSession } from '@/components/NewSession' import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' @@ -27,7 +26,6 @@ import { useSessions } from '@/hooks/queries/useSessions' import { useSlashCommands } from '@/hooks/queries/useSlashCommands' import { useSkills } from '@/hooks/queries/useSkills' import { useSendMessage } from '@/hooks/mutations/useSendMessage' -import { useSortToggle } from '@/hooks/useSortToggle' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' @@ -116,13 +114,11 @@ function SessionsPage() { void refetch() }, [refetch]) - const projectCount = new Set(sessions.map(s => { + 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 - const groups = useMemo(() => groupSessionsByDirectory(sessions), [sessions]) - const { sortMode, isSortPreferencePending, toggleSortMode } = useSortToggle(api, groups) + })).size, [sessions]) const machineLabelsById = useMemo(() => { const labels: Record = {} for (const machine of machines) { @@ -153,18 +149,6 @@ function SessionsPage() { > -
- {machinesError ? ( -
- {machinesError} -
- ) : null} - - +
+ {machinesError ? ( +
+ {machinesError} +
+ ) : null} + + +
) }