diff --git a/.gitignore b/.gitignore index 40f16eed5..0d2767199 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ coverage/ # OS / IDE **/.DS_Store +**/._* .idea/ .vscode/ **/*.swp @@ -37,6 +38,9 @@ coverage/ .claude/settings.local.json localdocs/ execplan/ +docs/plans/ +docs/LOCAL_FORK_WORKFLOW.md +docs/GAIUS_THEME.md # Generated npm bundle output (local) cli/npm/main/ diff --git a/bun.lock b/bun.lock index 0ed85e8c3..71f01c5f1 100644 --- a/bun.lock +++ b/bun.lock @@ -122,6 +122,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -624,6 +625,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -2098,6 +2101,10 @@ "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "pnpm": ["pnpm@10.27.0", "", { "bin": { "pnpm": "bin/pnpm.cjs", "pnpx": "bin/pnpx.cjs" } }, "sha512-ctaZ2haxF5wUup5k3HHJpAmIy9xlwmTLDkidt96RfyDc9NZNhyNiXylpulLUt+KhFwaC2awqXcrqq3MrfhbwSg=="], @@ -2976,6 +2983,8 @@ "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "pretty-format/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/hub/src/sse/sseManager.test.ts b/hub/src/sse/sseManager.test.ts index 209c278f8..2ccb85b8e 100644 --- a/hub/src/sse/sseManager.test.ts +++ b/hub/src/sse/sseManager.test.ts @@ -118,4 +118,73 @@ describe('SSEManager namespace filtering', () => { expect(received).toHaveLength(1) expect(received[0]?.id).toBe('visible') }) + + it('broadcasts session sort preference updates to namespace even with session-scoped subscription', () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const received: SyncEvent[] = [] + + manager.subscribe({ + id: 'alpha-session', + namespace: 'alpha', + userId: 1, + all: false, + sessionId: 'session-1', + send: (event) => { + received.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ + type: 'session-sort-preference-updated', + namespace: 'alpha', + data: { + userId: 1, + version: 2 + } + }) + + expect(received).toHaveLength(1) + expect(received[0]?.type).toBe('session-sort-preference-updated') + }) + + it('filters session sort preference updates by userId', () => { + const manager = new SSEManager(0, new VisibilityTracker()) + const receivedUser1: SyncEvent[] = [] + const receivedUser2: SyncEvent[] = [] + + manager.subscribe({ + id: 'user1-conn', + namespace: 'alpha', + userId: 1, + all: true, + send: (event) => { + receivedUser1.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.subscribe({ + id: 'user2-conn', + namespace: 'alpha', + userId: 2, + all: true, + send: (event) => { + receivedUser2.push(event) + }, + sendHeartbeat: () => {} + }) + + manager.broadcast({ + type: 'session-sort-preference-updated', + namespace: 'alpha', + data: { + userId: 1, + version: 3 + } + }) + + expect(receivedUser1).toHaveLength(1) + expect(receivedUser2).toHaveLength(0) + }) }) diff --git a/hub/src/sse/sseManager.ts b/hub/src/sse/sseManager.ts index 2bd267b9d..87069452f 100644 --- a/hub/src/sse/sseManager.ts +++ b/hub/src/sse/sseManager.ts @@ -5,6 +5,7 @@ import type { VisibilityTracker } from '../visibility/visibilityTracker' export type SSESubscription = { id: string namespace: string + userId: number | null all: boolean sessionId: string | null machineId: string | null @@ -29,6 +30,7 @@ export class SSEManager { subscribe(options: { id: string namespace: string + userId?: number | null all?: boolean sessionId?: string | null machineId?: string | null @@ -39,6 +41,7 @@ export class SSEManager { const subscription: SSEConnection = { id: options.id, namespace: options.namespace, + userId: options.userId ?? null, all: Boolean(options.all), sessionId: options.sessionId ?? null, machineId: options.machineId ?? null, @@ -56,6 +59,7 @@ export class SSEManager { return { id: subscription.id, namespace: subscription.namespace, + userId: subscription.userId, all: subscription.all, sessionId: subscription.sessionId, machineId: subscription.machineId @@ -163,6 +167,10 @@ export class SSEManager { return true } + if (event.type === 'session-sort-preference-updated') { + return connection.userId === null || connection.userId === event.data.userId + } + if (connection.all) { return true } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 94e596752..f8c8f0489 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -5,6 +5,7 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' import { PushStore } from './pushStore' +import { SessionSortPreferenceStore } from './sessionSortPreferenceStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' @@ -13,22 +14,26 @@ export type { StoredMessage, StoredPushSubscription, StoredSession, + StoredSessionSortPreference, StoredUser, + SessionSortPreferenceUpdateResult, VersionedUpdateResult } from './types' export { MachineStore } from './machineStore' export { MessageStore } from './messageStore' export { PushStore } from './pushStore' +export { SessionSortPreferenceStore } from './sessionSortPreferenceStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 3 +const SCHEMA_VERSION: number = 4 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'session_sort_preferences' ] as const export class Store { @@ -40,6 +45,7 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly sessionSortPreferences: SessionSortPreferenceStore constructor(dbPath: string) { this.dbPath = dbPath @@ -81,6 +87,7 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.sessionSortPreferences = new SessionSortPreferenceStore(this.db) } private initSchema(): void { @@ -98,14 +105,38 @@ export class Store { return } - if (currentVersion === 1 && SCHEMA_VERSION === 2) { + if (currentVersion === 1 && SCHEMA_VERSION >= 2) { this.migrateFromV1ToV2() + if (SCHEMA_VERSION === 2) { + this.setUserVersion(SCHEMA_VERSION) + return + } + + this.migrateFromV2ToV3() + if (SCHEMA_VERSION === 3) { + this.setUserVersion(SCHEMA_VERSION) + return + } + + this.migrateFromV3ToV4() this.setUserVersion(SCHEMA_VERSION) return } - if (currentVersion === 2 && SCHEMA_VERSION === 3) { + if (currentVersion === 2 && SCHEMA_VERSION >= 3) { this.migrateFromV2ToV3() + if (SCHEMA_VERSION === 3) { + this.setUserVersion(SCHEMA_VERSION) + return + } + + this.migrateFromV3ToV4() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 3 && SCHEMA_VERSION >= 4) { + this.migrateFromV3ToV4() this.setUserVersion(SCHEMA_VERSION) return } @@ -187,6 +218,19 @@ export class Store { UNIQUE(namespace, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + + CREATE TABLE IF NOT EXISTS session_sort_preferences ( + user_id INTEGER NOT NULL, + namespace TEXT NOT NULL, + sort_mode TEXT NOT NULL DEFAULT 'auto', + manual_order TEXT NOT NULL DEFAULT '{"groupOrder":[],"sessionOrder":{}}', + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, namespace) + ); + CREATE INDEX IF NOT EXISTS idx_session_sort_preferences_namespace + ON session_sort_preferences(namespace); `) } @@ -280,6 +324,23 @@ export class Store { return } + private migrateFromV3ToV4(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_sort_preferences ( + user_id INTEGER NOT NULL, + namespace TEXT NOT NULL, + sort_mode TEXT NOT NULL DEFAULT 'auto', + manual_order TEXT NOT NULL DEFAULT '{"groupOrder":[],"sessionOrder":{}}', + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, namespace) + ); + CREATE INDEX IF NOT EXISTS idx_session_sort_preferences_namespace + ON session_sort_preferences(namespace); + `) + } + private getMachineColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/sessionSortPreferenceStore.test.ts b/hub/src/store/sessionSortPreferenceStore.test.ts new file mode 100644 index 000000000..c773bbb6c --- /dev/null +++ b/hub/src/store/sessionSortPreferenceStore.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'bun:test' + +import { Store } from './index' + +describe('SessionSortPreferenceStore', () => { + it('returns default preference when no row exists', () => { + const store = new Store(':memory:') + + const preference = store.sessionSortPreferences.getByUser(1, 'alpha') + + expect(preference.sortMode).toBe('auto') + expect(preference.manualOrder).toEqual({ groupOrder: [], sessionOrder: {} }) + expect(preference.version).toBe(1) + expect(preference.updatedAt).toBe(0) + }) + + it('persists preference updates for user and namespace', () => { + const store = new Store(':memory:') + + const result = store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + } + }, + 1 + ) + + expect(result.result).toBe('success') + if (result.result !== 'success') { + return + } + expect(result.preference.version).toBe(2) + + const stored = store.sessionSortPreferences.getByUser(1, 'alpha') + expect(stored.sortMode).toBe('manual') + expect(stored.manualOrder.groupOrder).toEqual(['m1::/repo/app']) + expect(stored.manualOrder.sessionOrder['m1::/repo/app']).toEqual(['session-1']) + expect(stored.version).toBe(2) + expect(stored.updatedAt).toBeGreaterThan(0) + }) + + it('returns version mismatch with latest preference', () => { + const store = new Store(':memory:') + + store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + } + }, + 1 + ) + + const mismatch = store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + } + }, + 1 + ) + + expect(mismatch.result).toBe('version-mismatch') + if (mismatch.result !== 'version-mismatch') { + return + } + + expect(mismatch.preference.sortMode).toBe('manual') + expect(mismatch.preference.version).toBe(2) + }) + + it('returns version mismatch when first write uses stale expected version', () => { + const store = new Store(':memory:') + + const mismatch = store.sessionSortPreferences.upsertByUser( + 1, + 'alpha', + { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + } + }, + 9 + ) + + expect(mismatch.result).toBe('version-mismatch') + if (mismatch.result !== 'version-mismatch') { + return + } + + expect(mismatch.preference.sortMode).toBe('auto') + expect(mismatch.preference.version).toBe(1) + }) +}) diff --git a/hub/src/store/sessionSortPreferenceStore.ts b/hub/src/store/sessionSortPreferenceStore.ts new file mode 100644 index 000000000..4786c4991 --- /dev/null +++ b/hub/src/store/sessionSortPreferenceStore.ts @@ -0,0 +1,29 @@ +import type { Database } from 'bun:sqlite' +import type { SessionManualOrder, SessionSortMode } from '@hapi/protocol/types' + +import type { SessionSortPreferenceUpdateResult, StoredSessionSortPreference } from './types' +import { getSessionSortPreferenceByUser, upsertSessionSortPreferenceByUser } from './sessionSortPreferences' + +export class SessionSortPreferenceStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + getByUser(userId: number, namespace: string): StoredSessionSortPreference { + return getSessionSortPreferenceByUser(this.db, userId, namespace) + } + + upsertByUser( + userId: number, + namespace: string, + preference: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + }, + expectedVersion?: number + ): SessionSortPreferenceUpdateResult { + return upsertSessionSortPreferenceByUser(this.db, userId, namespace, preference, expectedVersion) + } +} diff --git a/hub/src/store/sessionSortPreferences.ts b/hub/src/store/sessionSortPreferences.ts new file mode 100644 index 000000000..7c2aaca22 --- /dev/null +++ b/hub/src/store/sessionSortPreferences.ts @@ -0,0 +1,152 @@ +import type { Database } from 'bun:sqlite' +import { SessionManualOrderSchema, type SessionManualOrder, type SessionSortMode } from '@hapi/protocol/schemas' + +import type { SessionSortPreferenceUpdateResult, StoredSessionSortPreference } from './types' +import { safeJsonParse } from './json' + +const EMPTY_MANUAL_ORDER: SessionManualOrder = { + groupOrder: [], + sessionOrder: {} +} + +type DbSessionSortPreferenceRow = { + user_id: number + namespace: string + sort_mode: string + manual_order: string + version: number + created_at: number + updated_at: number +} + +function normalizeSortMode(value: string): SessionSortMode { + return value === 'manual' ? 'manual' : 'auto' +} + +function normalizeManualOrder(value: unknown): SessionManualOrder { + const parsed = SessionManualOrderSchema.safeParse(value) + if (parsed.success) { + return parsed.data + } + + return { + groupOrder: [], + sessionOrder: {} + } +} + +function toStoredSessionSortPreference(row: DbSessionSortPreferenceRow): StoredSessionSortPreference { + return { + userId: row.user_id, + namespace: row.namespace, + sortMode: normalizeSortMode(row.sort_mode), + manualOrder: normalizeManualOrder(safeJsonParse(row.manual_order)), + version: Math.max(1, row.version), + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +function getDefaultSessionSortPreference(userId: number, namespace: string): StoredSessionSortPreference { + return { + userId, + namespace, + sortMode: 'auto', + manualOrder: EMPTY_MANUAL_ORDER, + version: 1, + createdAt: 0, + updatedAt: 0 + } +} + +export function getSessionSortPreferenceByUser( + db: Database, + userId: number, + namespace: string +): StoredSessionSortPreference { + const row = db.prepare( + 'SELECT * FROM session_sort_preferences WHERE user_id = ? AND namespace = ? LIMIT 1' + ).get(userId, namespace) as DbSessionSortPreferenceRow | undefined + + if (!row) { + return getDefaultSessionSortPreference(userId, namespace) + } + + return toStoredSessionSortPreference(row) +} + +export function upsertSessionSortPreferenceByUser( + db: Database, + userId: number, + namespace: string, + preference: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + }, + expectedVersion?: number +): SessionSortPreferenceUpdateResult { + try { + const current = getSessionSortPreferenceByUser(db, userId, namespace) + const now = Date.now() + const baseVersion = expectedVersion ?? current.version + const nextVersion = baseVersion + 1 + const manualOrderJson = JSON.stringify(preference.manualOrder) + + const info = db.prepare(` + INSERT INTO session_sort_preferences ( + user_id, + namespace, + sort_mode, + manual_order, + version, + created_at, + updated_at + ) + SELECT + @user_id, + @namespace, + @sort_mode, + @manual_order, + @version, + @created_at, + @updated_at + WHERE @expected_version IS NULL + OR @expected_version = 1 + ON CONFLICT(user_id, namespace) + DO UPDATE SET + sort_mode = excluded.sort_mode, + manual_order = excluded.manual_order, + version = excluded.version, + updated_at = excluded.updated_at + WHERE @expected_version IS NULL + OR session_sort_preferences.version = @expected_version + `).run({ + user_id: userId, + namespace, + sort_mode: preference.sortMode, + manual_order: manualOrderJson, + version: nextVersion, + created_at: current.createdAt || now, + updated_at: now, + expected_version: expectedVersion ?? null + }) + + if (expectedVersion !== undefined && info.changes === 0) { + return { + result: 'version-mismatch', + preference: getSessionSortPreferenceByUser(db, userId, namespace) + } + } + + const updated = getSessionSortPreferenceByUser(db, userId, namespace) + return { + result: 'success', + preference: updated + } + } catch (error) { + console.error('Failed to upsert session sort preference:', error) + return { + result: 'error' + } + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 9ef422ac3..fbbb4604b 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -1,3 +1,5 @@ +import type { SessionManualOrder, SessionSortMode } from '@hapi/protocol/types' + export type StoredSession = { id: string tag: string | null @@ -56,6 +58,21 @@ export type StoredPushSubscription = { createdAt: number } +export type StoredSessionSortPreference = { + userId: number + namespace: string + sortMode: SessionSortMode + manualOrder: SessionManualOrder + version: number + createdAt: number + updatedAt: number +} + +export type SessionSortPreferenceUpdateResult = + | { result: 'success'; preference: StoredSessionSortPreference } + | { result: 'version-mismatch'; preference: StoredSessionSortPreference } + | { result: 'error' } + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 1ab46e65f..45e9824e5 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,16 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { DecryptedMessage, ModelMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { + DecryptedMessage, + ModelMode, + PermissionMode, + Session, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, + SyncEvent +} from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -42,7 +51,13 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type SetSessionSortPreferenceResult = + | { result: 'success'; preference: SessionSortPreference } + | { result: 'version-mismatch'; preference: SessionSortPreference } + | { result: 'error' } + export class SyncEngine { + private readonly store: Store private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache private readonly machineCache: MachineCache @@ -56,6 +71,7 @@ export class SyncEngine { rpcRegistry: RpcRegistry, sseManager: SSEManager ) { + this.store = store this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) @@ -97,6 +113,63 @@ export class SyncEngine { return this.sessionCache.getSessionsByNamespace(namespace) } + getSessionSortPreference(userId: number, namespace: string): SessionSortPreference { + const preference = this.store.sessionSortPreferences.getByUser(userId, namespace) + return { + sortMode: preference.sortMode, + manualOrder: preference.manualOrder, + version: preference.version, + updatedAt: preference.updatedAt + } + } + + setSessionSortPreference( + userId: number, + namespace: string, + input: { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number + } + ): SetSessionSortPreferenceResult { + const result = this.store.sessionSortPreferences.upsertByUser( + userId, + namespace, + { + sortMode: input.sortMode, + manualOrder: input.manualOrder + }, + input.expectedVersion + ) + + if (result.result === 'error') { + return { result: 'error' } + } + + const preference: SessionSortPreference = { + sortMode: result.preference.sortMode, + manualOrder: result.preference.manualOrder, + version: result.preference.version, + updatedAt: result.preference.updatedAt + } + + if (result.result === 'success') { + this.eventPublisher.emit({ + type: 'session-sort-preference-updated', + namespace, + data: { + userId, + version: preference.version + } + }) + } + + return { + result: result.result, + preference + } + } + getSession(sessionId: string): Session | undefined { return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined } diff --git a/hub/src/web/routes/events.ts b/hub/src/web/routes/events.ts index 6da4cfe61..9a9a19b51 100644 --- a/hub/src/web/routes/events.ts +++ b/hub/src/web/routes/events.ts @@ -77,10 +77,13 @@ export function createEventsRoutes( } } + const userId = c.get('userId') + return streamSSE(c, async (stream) => { manager.subscribe({ id: subscriptionId, namespace, + userId, all, sessionId: resolvedSessionId, machineId, diff --git a/hub/src/web/routes/preferences.test.ts b/hub/src/web/routes/preferences.test.ts new file mode 100644 index 000000000..b4ce14b13 --- /dev/null +++ b/hub/src/web/routes/preferences.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' + +import type { SetSessionSortPreferenceResult, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createPreferencesRoutes } from './preferences' + +function createApp(options: { + userId: number + namespace: string + engine: SyncEngine +}): Hono { + const app = new Hono() + + app.use('/api/*', async (c, next) => { + c.set('userId', options.userId) + c.set('namespace', options.namespace) + await next() + }) + + app.route('/api', createPreferencesRoutes(() => options.engine)) + return app +} + +describe('preferences routes', () => { + it('GET returns sort preference for authenticated user and namespace', async () => { + const captured: { userId?: number; namespace?: string } = {} + const engine = { + getSessionSortPreference: (userId: number, namespace: string) => { + captured.userId = userId + captured.namespace = namespace + return { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 1, + updatedAt: 0 + } + } + } as unknown as SyncEngine + + const app = createApp({ userId: 7, namespace: 'alpha', engine }) + const response = await app.request('http://localhost/api/preferences/session-sort') + + expect(response.status).toBe(200) + const json = await response.json() as { + preference: { + sortMode: string + version: number + } + } + expect(json.preference.sortMode).toBe('auto') + expect(json.preference.version).toBe(1) + expect(captured).toEqual({ userId: 7, namespace: 'alpha' }) + }) + + it('PUT persists preference and returns updated snapshot', async () => { + const engine = { + setSessionSortPreference: () => ({ + result: 'success', + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + version: 2, + updatedAt: 100 + } + } satisfies SetSessionSortPreferenceResult) + } as unknown as SyncEngine + + const app = createApp({ userId: 9, namespace: 'beta', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(200) + const json = await response.json() as { + preference: { + sortMode: string + version: number + } + } + expect(json.preference.sortMode).toBe('manual') + expect(json.preference.version).toBe(2) + }) + + it('PUT returns 409 and latest preference on version mismatch', async () => { + const engine = { + setSessionSortPreference: () => ({ + result: 'version-mismatch', + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['m1::/repo/app'], + sessionOrder: { + 'm1::/repo/app': ['session-1'] + } + }, + version: 3, + updatedAt: 200 + } + } satisfies SetSessionSortPreferenceResult) + } as unknown as SyncEngine + + const app = createApp({ userId: 1, namespace: 'alpha', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(409) + const json = await response.json() as { + error: string + preference: { + version: number + } + } + expect(json.error).toBe('version_mismatch') + expect(json.preference.version).toBe(3) + }) + + it('PUT validates body', async () => { + const engine = { + setSessionSortPreference: () => ({ result: 'error' }) + } as unknown as SyncEngine + const app = createApp({ userId: 1, namespace: 'alpha', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'manual', + expectedVersion: 0 + }) + }) + + expect(response.status).toBe(400) + }) + + it('PUT uses auth scope from middleware', async () => { + const captured: { + userId?: number + namespace?: string + } = {} + const engine = { + setSessionSortPreference: (userId: number, namespace: string) => { + captured.userId = userId + captured.namespace = namespace + return { + result: 'success', + preference: { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 2, + updatedAt: 0 + } + } satisfies SetSessionSortPreferenceResult + } + } as unknown as SyncEngine + + const app = createApp({ userId: 99, namespace: 'team-1', engine }) + + const response = await app.request('http://localhost/api/preferences/session-sort', { + method: 'PUT', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: 1 + }) + }) + + expect(response.status).toBe(200) + expect(captured).toEqual({ userId: 99, namespace: 'team-1' }) + }) +}) diff --git a/hub/src/web/routes/preferences.ts b/hub/src/web/routes/preferences.ts new file mode 100644 index 000000000..672654ccf --- /dev/null +++ b/hub/src/web/routes/preferences.ts @@ -0,0 +1,59 @@ +import { SessionManualOrderSchema, SessionSortModeSchema } from '@hapi/protocol/schemas' +import { Hono } from 'hono' +import { z } from 'zod' + +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const setSessionSortPreferenceSchema = z.object({ + sortMode: SessionSortModeSchema, + manualOrder: SessionManualOrderSchema, + expectedVersion: z.number().int().positive().optional() +}) + +export function createPreferencesRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + app.get('/preferences/session-sort', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const userId = c.get('userId') + const namespace = c.get('namespace') + const preference = engine.getSessionSortPreference(userId, namespace) + + return c.json({ preference }) + }) + + app.put('/preferences/session-sort', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const body = await c.req.json().catch(() => null) + const parsed = setSessionSortPreferenceSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + const userId = c.get('userId') + const namespace = c.get('namespace') + const result = engine.setSessionSortPreference(userId, namespace, parsed.data) + + if (result.result === 'error') { + return c.json({ error: 'Failed to save session sort preference' }, 500) + } + + if (result.result === 'version-mismatch') { + return c.json({ error: 'version_mismatch', preference: result.preference }, 409) + } + + return c.json({ preference: result.preference }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 08800fc72..4f5137b12 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -18,6 +18,7 @@ import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createPushRoutes } from './routes/push' +import { createPreferencesRoutes } from './routes/preferences' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' import type { VisibilityTracker } from '../visibility/visibilityTracker' @@ -77,7 +78,7 @@ function createWebApp(options: { const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins const corsMiddleware = cors({ origin: corsOriginOption, - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: ['authorization', 'content-type'] }) app.use('/api/*', corsMiddleware) @@ -96,6 +97,7 @@ function createWebApp(options: { app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine)) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) + app.route('/api', createPreferencesRoutes(options.getSyncEngine)) app.route('/api', createVoiceRoutes()) // Skip static serving in relay mode, show helpful message on root diff --git a/package.json b/package.json index 8cc9e91a7..6d3f7b88d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "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", + "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/shared/src/schemas.ts b/shared/src/schemas.ts index 30e96dc4b..37c026f5f 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -3,6 +3,25 @@ import { MODEL_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const ModelModeSchema = z.enum(MODEL_MODES) +export const SessionSortModeSchema = z.enum(['auto', 'manual']) + +export type SessionSortMode = z.infer + +export const SessionManualOrderSchema = z.object({ + groupOrder: z.array(z.string().max(256)).max(500), + sessionOrder: z.record(z.string().max(256), z.array(z.string().max(128)).max(200)) +}).strict() + +export type SessionManualOrder = z.infer + +export const SessionSortPreferenceSchema = z.object({ + sortMode: SessionSortModeSchema, + manualOrder: SessionManualOrderSchema, + version: z.number().int().positive(), + updatedAt: z.number().int().nonnegative() +}).strict() + +export type SessionSortPreference = z.infer const MetadataSummarySchema = z.object({ text: z.string(), @@ -187,6 +206,13 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ status: z.string(), subscriptionId: z.string().optional() }).optional() + }), + SessionEventBaseSchema.extend({ + type: z.literal('session-sort-preference-updated'), + data: z.object({ + userId: z.number().int().nonnegative(), + version: z.number().int().positive() + }) }) ]) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 4b693ada7..fd5096dc6 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { ModelMode } from './modes' +import type { ModelMode, PermissionMode } from './modes' import type { Session, WorktreeMetadata } from './schemas' export type SessionSummaryMetadata = { @@ -19,6 +19,7 @@ export type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number + permissionMode?: PermissionMode modelMode?: ModelMode } @@ -48,6 +49,7 @@ export function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, + permissionMode: session.permissionMode, modelMode: session.modelMode } } diff --git a/shared/src/types.ts b/shared/src/types.ts index 1a885f1ae..b69d96357 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -5,6 +5,9 @@ export type { AttachmentMetadata, DecryptedMessage, Metadata, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SyncEvent, TodoItem, diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd..670b52ffe 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ dev-dist/ +playwright-report/ +test-results/ diff --git a/web/README.md b/web/README.md index 199a09134..1bce212fc 100644 --- a/web/README.md +++ b/web/README.md @@ -140,6 +140,38 @@ bun run build:web The built assets land in `web/dist` and are served by hapi-hub. The single executable can embed these assets. +## E2E (Playwright) + +Session-sort backend persistence has Playwright coverage in: + +- `web/e2e/session-sort.backend.e2e.spec.ts` + +Install browser once: + +```bash +cd web +bun run test:e2e:install +``` + +Run all Playwright tests: + +```bash +cd web +bun run test:e2e +``` + +Run only session-sort flow: + +```bash +cd web +bun run test:e2e:session-sort +``` + +Notes: + +- Seeded CLI sessions in these tests must include `metadata.host`; otherwise metadata schema parsing can drop fields and UI grouping/selectors become unstable. +- `page.reload({ waitUntil: 'networkidle' })` can hang when SSE stays active. Prefer `waitUntil: 'domcontentloaded'` for authenticated app reload assertions. + ## Standalone hosting You can host `web/dist` on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi hub: diff --git a/web/e2e/session-sort.backend.e2e.spec.ts b/web/e2e/session-sort.backend.e2e.spec.ts new file mode 100644 index 000000000..e9d1869c1 --- /dev/null +++ b/web/e2e/session-sort.backend.e2e.spec.ts @@ -0,0 +1,289 @@ +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-sort-${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 expandAllGroups(page: Page): Promise { + const headers = page.locator('button').filter({ has: page.locator('span.font-semibold') }) + const count = await headers.count() + for (let i = 0; i < count; i += 1) { + await headers.nth(i).click() + } + await expect(page.locator('.session-list-item').first()).toBeVisible({ timeout: 10_000 }) +} + +async function sessionRowTexts(page: Page): Promise { + return page.locator('.session-list-item').allTextContents() +} + +function findIndex(rows: string[], text: string): number { + return rows.findIndex((row) => row.includes(text)) +} + +async function authJwt(accessToken: string): Promise { + const response = await fetch(`${BASE_URL}/api/auth`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ accessToken }) + }) + expect(response.status).toBe(200) + const json = await response.json() as { token: string } + return json.token +} + +type SessionSortPreference = { + sortMode: 'auto' | 'manual' + manualOrder: { + groupOrder: string[] + sessionOrder: Record + } + version: number +} + +async function getPreference(jwt: string): Promise { + const response = await fetch(`${BASE_URL}/api/preferences/session-sort`, { + headers: { authorization: `Bearer ${jwt}` } + }) + expect(response.status).toBe(200) + const json = await response.json() as { preference: SessionSortPreference } + return json.preference +} + +async function putPreference( + jwt: string, + payload: { + sortMode: 'auto' | 'manual' + manualOrder: { + groupOrder: string[] + sessionOrder: Record + } + expectedVersion?: number + } +): Promise<{ status: number; json: unknown }> { + const response = await fetch(`${BASE_URL}/api/preferences/session-sort`, { + method: 'PUT', + headers: { + authorization: `Bearer ${jwt}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + const json = await response.json().catch(() => ({})) + return { status: response.status, json } +} + +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: 'claude' + }, + agentState: null + }) + }) + expect(response.status).toBe(200) +} + +async function seedBaseSessions(accessToken: string): Promise { + await createCliSession(accessToken, 's-alpha', 'Alpha', '/work/repo/a', 'm1') + await createCliSession(accessToken, 's-beta', 'Beta', '/work/repo/a', 'm1') + await createCliSession(accessToken, 's-gamma', 'Gamma', '/work/repo/b', 'm1') + await createCliSession(accessToken, 's-delta', 'Delta', '/work/repo/b', 'm1') +} + +async function resetPreferenceToAuto(accessToken: string): Promise { + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + const result = await putPreference(jwt, { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + expectedVersion: preference.version + }) + expect(result.status).toBe(200) +} + +async function bootstrapNamespace(accessToken: string): Promise { + await seedBaseSessions(accessToken) + await resetPreferenceToAuto(accessToken) +} + +test.describe.configure({ mode: 'serial' }) +test.setTimeout(180_000) + +test('session sort: manual mode UI flow + persistence + stale IDs + new session append', async ({ page }) => { + const accessToken = token('manual') + await bootstrapNamespace(accessToken) + + await login(page, accessToken) + await expandAllGroups(page) + + await expect(page.locator('button[title="Sort: automatic"]')).toBeVisible() + await page.locator('button[title="Sort: automatic"]').click() + await expect(page.locator('button[title="Sort: manual"]')).toBeVisible() + + const alphaRow = page.locator('.session-list-item', { hasText: 'Alpha' }).first() + await alphaRow.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move Up' }).click() + + const afterMoveRows = await sessionRowTexts(page) + expect(findIndex(afterMoveRows, 'Alpha')).toBeLessThan(findIndex(afterMoveRows, 'Beta')) + + await page.locator('.session-list-item', { hasText: 'Alpha' }).first().click({ button: 'right' }) + await expect(page.getByRole('menuitem', { name: 'Move Up' })).toBeDisabled() + await page.keyboard.press('Escape') + + const initialHeaders = await page.locator('span.font-semibold').allTextContents() + const initialGroups = initialHeaders.filter((text) => text.includes('repo/')) + expect(initialGroups.length).toBeGreaterThan(1) + const movedGroup = initialGroups[1] + + const movedGroupHeader = page.locator('button', { + has: page.locator('span.font-semibold', { hasText: movedGroup }) + }).first() + await movedGroupHeader.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move Up' }).click() + + await expect.poll(async () => { + const headers = await page.locator('span.font-semibold').allTextContents() + const groupNames = headers.filter((text) => text.includes('repo/')) + return groupNames[0] ?? '' + }).toBe(movedGroup) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.locator('button[title="Sort: manual"]')).toBeVisible({ timeout: 15_000 }) + await expandAllGroups(page) + + const afterReloadRows = await sessionRowTexts(page) + expect(findIndex(afterReloadRows, 'Alpha')).toBeLessThan(findIndex(afterReloadRows, 'Beta')) + + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + const stalePut = await putPreference(jwt, { + sortMode: 'manual', + expectedVersion: preference.version, + manualOrder: { + groupOrder: ['fake-group', ...preference.manualOrder.groupOrder], + sessionOrder: { + ...preference.manualOrder.sessionOrder, + 'fake-group': ['fake-session'] + } + } + }) + expect(stalePut.status).toBe(200) + + await expect(page.locator('.session-list-item', { hasText: 'Alpha' }).first()).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('.session-list-item', { hasText: 'Beta' }).first()).toBeVisible() + + await createCliSession(accessToken, 's-epsilon', 'Epsilon', '/work/repo/a', 'm1') + + await expect(page.locator('.session-list-item', { hasText: 'Epsilon' }).first()).toBeVisible({ timeout: 15_000 }) + const withNewRows = await sessionRowTexts(page) + const alphaIndex = findIndex(withNewRows, 'Alpha') + const betaIndex = findIndex(withNewRows, 'Beta') + const epsilonIndex = findIndex(withNewRows, 'Epsilon') + expect(epsilonIndex).toBeGreaterThan(alphaIndex) + expect(epsilonIndex).toBeGreaterThan(betaIndex) +}) + +test('session sort: SSE sync across two clients', async ({ browser }) => { + const accessToken = token('sse') + await bootstrapNamespace(accessToken) + await createCliSession(accessToken, 's-epsilon', 'Epsilon', '/work/repo/a', 'm1') + + const contextA = await browser.newContext() + const contextB = await browser.newContext() + const pageA = await contextA.newPage() + const pageB = await contextB.newPage() + + await login(pageA, accessToken) + await login(pageB, accessToken) + await expandAllGroups(pageA) + await expandAllGroups(pageB) + + await pageA.locator('button[title="Sort: automatic"]').click() + await expect(pageA.locator('button[title="Sort: manual"]')).toBeVisible() + await expect(pageB.locator('button[title="Sort: manual"]')).toBeVisible({ timeout: 15_000 }) + + const beforeRows = await sessionRowTexts(pageA) + const repoAOrder = ['Alpha', 'Beta', 'Epsilon'] + .map((name) => ({ name, index: findIndex(beforeRows, name) })) + .filter((entry) => entry.index >= 0) + .sort((a, b) => a.index - b.index) + expect(repoAOrder.length).toBeGreaterThan(1) + + const moveTarget = repoAOrder[1] + const expectedAbove = repoAOrder[0] + + await pageA.locator('.session-list-item', { hasText: moveTarget.name }).first().click({ button: 'right' }) + await pageA.getByRole('menuitem', { name: 'Move Up' }).click() + + await expect.poll(async () => { + const rows = await sessionRowTexts(pageB) + return { + moved: findIndex(rows, moveTarget.name), + above: findIndex(rows, expectedAbove.name) + } + }, { timeout: 20_000 }).toEqual(expect.objectContaining({ + moved: expect.any(Number), + above: expect.any(Number) + })) + + const rowsB = await sessionRowTexts(pageB) + expect(findIndex(rowsB, moveTarget.name)).toBeLessThan(findIndex(rowsB, expectedAbove.name)) + + await contextA.close() + await contextB.close() +}) + +test('session sort: API conflict path returns 409 version_mismatch', async () => { + const accessToken = token('conflict') + await bootstrapNamespace(accessToken) + + const jwt = await authJwt(accessToken) + const preference = await getPreference(jwt) + + const payload = { + sortMode: preference.sortMode, + manualOrder: preference.manualOrder, + expectedVersion: preference.version + } + + const first = await putPreference(jwt, payload) + expect(first.status).toBe(200) + + const second = await putPreference(jwt, payload) + expect(second.status).toBe(409) + expect((second.json as { error?: string }).error).toBe('version_mismatch') + expect(((second.json as { preference: SessionSortPreference }).preference.version)).toBeGreaterThan(preference.version) +}) diff --git a/web/package.json b/web/package.json index e22d39631..9f82a8754 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,10 @@ "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: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": { "@assistant-ui/react": "^0.11.53", @@ -44,6 +47,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..178ec7108 --- /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-session-sort-${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/App.tsx b/web/src/App.tsx index 1a038f6cb..57aee0db8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -195,6 +195,7 @@ function AppInner() { startSync() } const invalidations = [ + queryClient.invalidateQueries({ queryKey: queryKeys.sessionSortPreference }), queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), ...(selectedSessionId ? [ queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 347e78f18..44a9ee375 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,6 +14,9 @@ import type { PushSubscriptionPayload, PushUnsubscribePayload, PushVapidPublicKeyResponse, + SessionSortPreferenceResponse, + SetSessionSortPreferencePayload, + SetSessionSortPreferenceResult, SlashCommandsResponse, SkillsResponse, SpawnResponse, @@ -116,7 +119,9 @@ export class ApiClient { if (!res.ok) { const body = await res.text().catch(() => '') - throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`) + const code = parseErrorCode(body) + const detail = body ? `: ${body}` : '' + throw new ApiError(`HTTP ${res.status} ${res.statusText}${detail}`, res.status, code, body || undefined) } return await res.json() as T @@ -160,6 +165,42 @@ export class ApiClient { return await this.request('/api/sessions') } + async getSessionSortPreference(): Promise { + return await this.request('/api/preferences/session-sort') + } + + async setSessionSortPreference( + payload: SetSessionSortPreferencePayload + ): Promise { + try { + const response = await this.request('/api/preferences/session-sort', { + method: 'PUT', + body: JSON.stringify(payload) + }) + return { + status: 'success', + preference: response.preference + } + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 409 || !error.body) { + throw error + } + + try { + const parsed = JSON.parse(error.body) as { error?: string; preference?: SessionSortPreferenceResponse['preference'] } + if (parsed.error === 'version_mismatch' && parsed.preference) { + return { + status: 'version-mismatch', + preference: parsed.preference + } + } + } catch { + } + + throw error + } + } + async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } diff --git a/web/src/components/GroupActionMenu.tsx b/web/src/components/GroupActionMenu.tsx new file mode 100644 index 000000000..91cfc98d8 --- /dev/null +++ b/web/src/components/GroupActionMenu.tsx @@ -0,0 +1,201 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties +} from 'react' + +import { ArrowUpIcon, ArrowDownIcon } from '@/components/icons/SortIcons' +import { useTranslation } from '@/lib/use-translation' + +type GroupActionMenuProps = { + isOpen: boolean + onClose: () => void + onMoveUp: () => void + onMoveDown: () => void + canMoveUp: boolean + canMoveDown: boolean + anchorPoint: { x: number; y: number } + menuId?: string +} + +type MenuPosition = { + top: number + left: number + transformOrigin: string +} + +export function GroupActionMenu(props: GroupActionMenuProps) { + const { t } = useTranslation() + const { + isOpen, + onClose, + onMoveUp, + onMoveDown, + canMoveUp, + canMoveDown, + anchorPoint, + menuId + } = props + const menuRef = useRef(null) + const [menuPosition, setMenuPosition] = useState(null) + const internalId = useId() + const resolvedMenuId = menuId ?? `group-action-menu-${internalId}` + const headingId = `${resolvedMenuId}-heading` + + const updatePosition = useCallback(() => { + const menuEl = menuRef.current + if (!menuEl) return + + const menuRect = menuEl.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const padding = 8 + const gap = 8 + + const spaceBelow = viewportHeight - anchorPoint.y + const spaceAbove = anchorPoint.y + const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow + + let top = openAbove ? anchorPoint.y - menuRect.height - gap : anchorPoint.y + gap + let left = anchorPoint.x - menuRect.width / 2 + const transformOrigin = openAbove ? 'bottom center' : 'top center' + + top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding) + left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding) + + setMenuPosition({ top, left, transformOrigin }) + }, [anchorPoint]) + + useLayoutEffect(() => { + if (!isOpen) return + updatePosition() + }, [isOpen, updatePosition]) + + useEffect(() => { + if (!isOpen) { + setMenuPosition(null) + return + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node + if (menuRef.current?.contains(target)) return + onClose() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const items = menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') + if (!items || items.length === 0) return + const current = document.activeElement as HTMLElement + const index = Array.from(items).indexOf(current) + const next = event.key === 'ArrowDown' + ? items[(index + 1) % items.length] + : items[(index - 1 + items.length) % items.length] + next?.focus() + } + } + + const handleReflow = () => { + updatePosition() + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('resize', handleReflow) + window.addEventListener('scroll', handleReflow, true) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('resize', handleReflow) + window.removeEventListener('scroll', handleReflow, true) + } + }, [isOpen, onClose, updatePosition]) + + useEffect(() => { + if (!isOpen) return + + const frame = window.requestAnimationFrame(() => { + const firstItem = menuRef.current?.querySelector('[role="menuitem"]:not([disabled])') + firstItem?.focus() + }) + + return () => window.cancelAnimationFrame(frame) + }, [isOpen]) + + if (!isOpen) return null + + const menuStyle: CSSProperties | undefined = menuPosition + ? { + top: menuPosition.top, + left: menuPosition.left, + transformOrigin: menuPosition.transformOrigin + } + : undefined + + const baseItemClassName = + 'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-base transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]' + + return ( +
+
+ {t('group.more')} +
+ +
+ ) +} diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c..abe01774c 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -7,12 +7,18 @@ import { useState, type CSSProperties } from 'react' +import { ArrowUpIcon, ArrowDownIcon } from '@/components/icons/SortIcons' import { useTranslation } from '@/lib/use-translation' type SessionActionMenuProps = { isOpen: boolean onClose: () => void sessionActive: boolean + manualMode?: boolean + onMoveUp?: () => void + onMoveDown?: () => void + canMoveUp?: boolean + canMoveDown?: boolean onRename: () => void onArchive: () => void onDelete: () => void @@ -96,6 +102,11 @@ export function SessionActionMenu(props: SessionActionMenuProps) { isOpen, onClose, sessionActive, + manualMode = false, + onMoveUp, + onMoveDown, + canMoveUp = false, + canMoveDown = false, onRename, onArchive, onDelete, @@ -123,6 +134,22 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onDelete() } + const handleMoveUp = () => { + if (!canMoveUp || !onMoveUp) { + return + } + onClose() + onMoveUp() + } + + const handleMoveDown = () => { + if (!canMoveDown || !onMoveDown) { + return + } + onClose() + onMoveDown() + } + const updatePosition = useCallback(() => { const menuEl = menuRef.current if (!menuEl) return @@ -191,7 +218,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { if (!isOpen) return const frame = window.requestAnimationFrame(() => { - const firstItem = menuRef.current?.querySelector('[role="menuitem"]') + const firstItem = menuRef.current?.querySelector('[role="menuitem"]:not([disabled])') firstItem?.focus() }) @@ -229,6 +256,34 @@ export function SessionActionMenu(props: SessionActionMenuProps) { aria-labelledby={headingId} className="flex flex-col gap-1" > + {manualMode ? ( + <> + + +
+ + ) : null} + @@ -270,6 +353,11 @@ function SessionItem(props: { isOpen={menuOpen} onClose={() => setMenuOpen(false)} sessionActive={s.active} + manualMode={manualMode} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} + canMoveUp={canMoveUp} + canMoveDown={canMoveDown} onRename={() => setRenameOpen(true)} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} @@ -311,6 +399,59 @@ function SessionItem(props: { ) } +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 + } + haptic.impact('medium') + props.onLongPressMenu(props.group.key, point) + }, + onClick: props.onToggle, + threshold: 500 + }) + + return ( + + ) +} + export function SessionList(props: { sessions: SessionSummary[] onSelect: (sessionId: string) => void @@ -319,27 +460,63 @@ export function SessionList(props: { isLoading: boolean renderHeader?: boolean api: ApiClient | null + machineLabelsById?: Record selectedSessionId?: string | null }) { const { t } = useTranslation() - const { renderHeader = true, api, selectedSessionId } = props + const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] ) + const { + sortMode, + orderedGroups, + isSortPreferencePending, + toggleSortMode, + moveGroupInPreference, + moveSessionInPreference + } = useSortToggle(api, groups) const [collapseOverrides, setCollapseOverrides] = useState>( () => new Map() ) + const [groupMenuOpen, setGroupMenuOpen] = useState(false) + const [groupMenuAnchor, setGroupMenuAnchor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + const [groupMenuKey, setGroupMenuKey] = useState(null) + + const openGroupActionMenu = (groupKey: string, point: { x: number; y: number }) => { + setGroupMenuKey(groupKey) + setGroupMenuAnchor(point) + setGroupMenuOpen(true) + } + + const closeGroupActionMenu = () => { + setGroupMenuOpen(false) + } + + const groupMenuIndex = groupMenuKey ? orderedGroups.findIndex((group) => group.key === groupMenuKey) : -1 + const canMoveGroupUp = groupMenuIndex > 0 + const canMoveGroupDown = groupMenuIndex >= 0 && groupMenuIndex < orderedGroups.length - 1 + + const resolveMachineLabel = (machineId: string | null): string => { + if (machineId && machineLabelsById[machineId]) { + return machineLabelsById[machineId] + } + if (machineId) { + return machineId.slice(0, 8) + } + return t('machine.unknown') + } const isGroupCollapsed = (group: SessionGroup): boolean => { - const override = collapseOverrides.get(group.directory) + const override = collapseOverrides.get(group.key) if (override !== undefined) return override return !group.hasActiveSession } - const toggleGroup = (directory: string, isCollapsed: boolean) => { + const toggleGroup = (groupKey: string, isCollapsed: boolean) => { setCollapseOverrides(prev => { const next = new Map(prev) - next.set(directory, !isCollapsed) + next.set(groupKey, !isCollapsed) return next }) } @@ -348,62 +525,78 @@ export function SessionList(props: { setCollapseOverrides(prev => { if (prev.size === 0) return prev const next = new Map(prev) - const knownGroups = new Set(groups.map(group => group.directory)) + const knownGroups = new Set(orderedGroups.map(group => group.key)) let changed = false - for (const directory of next.keys()) { - if (!knownGroups.has(directory)) { - next.delete(directory) + for (const groupKey of next.keys()) { + if (!knownGroups.has(groupKey)) { + next.delete(groupKey) changed = true } } return changed ? next : prev }) - }, [groups]) + }, [orderedGroups]) + + useEffect(() => { + if (!groupMenuKey) { + return + } + + if (!orderedGroups.some((group) => group.key === groupMenuKey)) { + setGroupMenuOpen(false) + setGroupMenuKey(null) + } + }, [groupMenuKey, orderedGroups]) return (
{renderHeader ? (
- {t('sessions.count', { n: props.sessions.length, m: groups.length })} + {t('sessions.count', { n: props.sessions.length, m: orderedGroups.length })} +
+
+ +
-
) : null}
- {groups.map((group) => { + {orderedGroups.map((group) => { const isCollapsed = isGroupCollapsed(group) + const groupMachineLabel = resolveMachineLabel(group.machineId) return ( -
- +
+ toggleGroup(group.key, isCollapsed)} + onLongPressMenu={openGroupActionMenu} + /> {!isCollapsed ? ( -
- {group.sessions.map((s) => ( +
+ {group.sessions.map((s, index) => ( moveSessionInPreference(group.key, s.id, 'up')} + onMoveDown={() => moveSessionInPreference(group.key, s.id, 'down')} + canMoveUp={index > 0} + canMoveDown={index < group.sessions.length - 1} /> ))}
@@ -419,6 +617,26 @@ 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/components/icons/SortIcons.tsx b/web/src/components/icons/SortIcons.tsx new file mode 100644 index 000000000..92928ed86 --- /dev/null +++ b/web/src/components/icons/SortIcons.tsx @@ -0,0 +1,84 @@ +export function SortIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + +export function PinIcon(props: { className?: string }) { + return ( + + + + + + + ) +} + +export function ArrowUpIcon(props: { className?: string }) { + return ( + + + + + ) +} + +export function ArrowDownIcon(props: { className?: string }) { + return ( + + + + + ) +} diff --git a/web/src/hooks/mutations/useSessionSortPreference.test.tsx b/web/src/hooks/mutations/useSessionSortPreference.test.tsx new file mode 100644 index 000000000..aebe043fc --- /dev/null +++ b/web/src/hooks/mutations/useSessionSortPreference.test.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor, act } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { useSessionSortPreferenceMutation } from './useSessionSortPreference' +import { queryKeys } from '@/lib/query-keys' +import type { SessionSortPreferenceResponse } from '@/types/api' + +function createWrapper(queryClient: QueryClient) { + return function Wrapper(props: { children: ReactNode }) { + return ( + + {props.children} + + ) + } +} + +describe('useSessionSortPreferenceMutation', () => { + it('updates cache optimistically on mutate', async () => { + const queryClient = new QueryClient() + const serverPreference = { + sortMode: 'manual' as const, + manualOrder: { groupOrder: ['g1'], sessionOrder: { g1: ['s1'] } }, + version: 3, + updatedAt: 999 + } + const api = { + setSessionSortPreference: vi.fn(async () => ({ + status: 'success' as const, + preference: serverPreference + })) + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: { + sortMode: 'auto', + manualOrder: { groupOrder: [], sessionOrder: {} }, + version: 2, + updatedAt: 100 + } + } + ) + + const { result } = renderHook(() => useSessionSortPreferenceMutation(api as never), { + wrapper: createWrapper(queryClient) + }) + + await act(async () => { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: ['g1'], sessionOrder: { g1: ['s1'] } }, + expectedVersion: 2 + }) + }) + + const cached = queryClient.getQueryData(queryKeys.sessionSortPreference) + expect(cached?.preference.sortMode).toBe('manual') + expect(cached?.preference.version).toBe(3) + }) + + it('rolls back cache on error', async () => { + const queryClient = new QueryClient() + const api = { + setSessionSortPreference: vi.fn(async () => { + throw new Error('Network error') + }) + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: { + sortMode: 'auto', + manualOrder: { groupOrder: [], sessionOrder: {} }, + version: 1, + updatedAt: 100 + } + } + ) + + const { result } = renderHook(() => useSessionSortPreferenceMutation(api as never), { + wrapper: createWrapper(queryClient) + }) + + await act(async () => { + try { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: ['g1'], sessionOrder: {} }, + expectedVersion: 1 + }) + } catch { + // expected + } + }) + + await waitFor(() => { + const cached = queryClient.getQueryData(queryKeys.sessionSortPreference) + expect(cached?.preference.sortMode).toBe('auto') + expect(cached?.preference.version).toBe(1) + }) + }) + + it('throws when API is not available', async () => { + const queryClient = new QueryClient() + + const { result } = renderHook(() => useSessionSortPreferenceMutation(null), { + wrapper: createWrapper(queryClient) + }) + + await expect( + act(async () => { + await result.current.setSessionSortPreference({ + sortMode: 'manual', + manualOrder: { groupOrder: [], sessionOrder: {} } + }) + }) + ).rejects.toThrow('API unavailable') + }) +}) diff --git a/web/src/hooks/mutations/useSessionSortPreference.ts b/web/src/hooks/mutations/useSessionSortPreference.ts new file mode 100644 index 000000000..c315a0d27 --- /dev/null +++ b/web/src/hooks/mutations/useSessionSortPreference.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiClient } from '@/api/client' +import type { + SessionSortPreference, + SessionSortPreferenceResponse, + SetSessionSortPreferencePayload, + SetSessionSortPreferenceResult +} from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useSessionSortPreferenceMutation(api: ApiClient | null): { + setSessionSortPreference: (payload: SetSessionSortPreferencePayload) => Promise + isPending: boolean +} { + const queryClient = useQueryClient() + + const mutation = useMutation({ + mutationFn: async (payload: SetSessionSortPreferencePayload) => { + if (!api) { + throw new Error('API unavailable') + } + + return await api.setSessionSortPreference(payload) + }, + onMutate: async (payload) => { + await queryClient.cancelQueries({ queryKey: queryKeys.sessionSortPreference }) + const previous = queryClient.getQueryData(queryKeys.sessionSortPreference) + + const previousPreference = previous?.preference + if (previousPreference) { + const optimisticPreference: SessionSortPreference = { + sortMode: payload.sortMode, + manualOrder: payload.manualOrder, + version: payload.expectedVersion !== undefined + ? payload.expectedVersion + 1 + : previousPreference.version + 1, + updatedAt: Date.now() + } + + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { preference: optimisticPreference } + ) + } + + return { previous } + }, + onError: (_error, _payload, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.sessionSortPreference, context.previous) + } + }, + onSuccess: (result) => { + queryClient.setQueryData( + queryKeys.sessionSortPreference, + { + preference: result.preference + } + ) + } + }) + + return { + setSessionSortPreference: mutation.mutateAsync, + isPending: mutation.isPending + } +} diff --git a/web/src/hooks/queries/useSessionSortPreference.test.tsx b/web/src/hooks/queries/useSessionSortPreference.test.tsx new file mode 100644 index 000000000..29741446e --- /dev/null +++ b/web/src/hooks/queries/useSessionSortPreference.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { useSessionSortPreference } from './useSessionSortPreference' + +function createWrapper(queryClient: QueryClient) { + return function Wrapper(props: { children: ReactNode }) { + return ( + + {props.children} + + ) + } +} + +describe('useSessionSortPreference', () => { + it('loads preference from API', async () => { + const queryClient = new QueryClient() + const api = { + getSessionSortPreference: vi.fn(async () => ({ + preference: { + sortMode: 'manual', + manualOrder: { + groupOrder: ['group-a'], + sessionOrder: { + 'group-a': ['session-1'] + } + }, + version: 3, + updatedAt: 123 + } + })) + } + + const { result } = renderHook(() => useSessionSortPreference(api as never), { + wrapper: createWrapper(queryClient) + }) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(result.current.preference.sortMode).toBe('manual') + expect(result.current.preference.version).toBe(3) + expect(api.getSessionSortPreference).toHaveBeenCalledTimes(1) + }) + + it('returns defaults when API not available', () => { + const queryClient = new QueryClient() + + const { result } = renderHook(() => useSessionSortPreference(null), { + wrapper: createWrapper(queryClient) + }) + + expect(result.current.preference.sortMode).toBe('auto') + expect(result.current.preference.manualOrder.groupOrder).toEqual([]) + }) +}) diff --git a/web/src/hooks/queries/useSessionSortPreference.ts b/web/src/hooks/queries/useSessionSortPreference.ts new file mode 100644 index 000000000..2b6cbf50a --- /dev/null +++ b/web/src/hooks/queries/useSessionSortPreference.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiClient } from '@/api/client' +import type { SessionSortPreference } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +const DEFAULT_SORT_PREFERENCE: SessionSortPreference = { + sortMode: 'auto', + manualOrder: { + groupOrder: [], + sessionOrder: {} + }, + version: 1, + updatedAt: 0 +} + +export function useSessionSortPreference(api: ApiClient | null): { + preference: SessionSortPreference + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.sessionSortPreference, + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + + return await api.getSessionSortPreference() + }, + enabled: Boolean(api) + }) + + return { + preference: query.data?.preference ?? DEFAULT_SORT_PREFERENCE, + isLoading: query.isLoading, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load session sort preference' : null, + refetch: query.refetch + } +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index b28639a17..ab27e235d 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -147,6 +147,10 @@ export function useSSE(options: { void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) } + if (event.type === 'session-sort-preference-updated') { + void queryClient.invalidateQueries({ queryKey: queryKeys.sessionSortPreference }) + } + onEventRef.current(event) } diff --git a/web/src/hooks/useSortToggle.ts b/web/src/hooks/useSortToggle.ts new file mode 100644 index 000000000..821db3bd8 --- /dev/null +++ b/web/src/hooks/useSortToggle.ts @@ -0,0 +1,80 @@ +import { useMemo } from 'react' + +import type { ApiClient } from '@/api/client' +import type { SessionManualOrder } from '@/types/api' +import { useSessionSortPreference } from '@/hooks/queries/useSessionSortPreference' +import { useSessionSortPreferenceMutation } from '@/hooks/mutations/useSessionSortPreference' +import { + reconcileManualOrder, + snapshotManualOrder, + applyManualOrder, + moveGroup, + moveSession, +} from '@/lib/sessionSortOrder' + +type SortableSession = { id: string } +type SortableGroup = { + key: string + sessions: TSession[] +} + +export function useSortToggle< + TSession extends SortableSession, + TGroup extends SortableGroup +>(api: ApiClient | null, groups: TGroup[]) { + const { preference } = useSessionSortPreference(api) + const { setSessionSortPreference, isPending: isSortPreferencePending } = useSessionSortPreferenceMutation(api) + const sortMode = preference.sortMode + + const reconciledManualOrder = useMemo( + () => reconcileManualOrder(groups, preference.manualOrder), + [groups, preference.manualOrder] + ) + + const orderedGroups = useMemo( + () => (sortMode === 'manual' ? applyManualOrder(groups, reconciledManualOrder) : groups), + [groups, reconciledManualOrder, sortMode] + ) + + const persistSortPreference = (nextSortMode: 'auto' | 'manual', nextManualOrder: SessionManualOrder = reconciledManualOrder) => { + if (!api) return + void setSessionSortPreference({ + sortMode: nextSortMode, + manualOrder: nextManualOrder, + expectedVersion: preference.version + }).catch((error) => { + console.error('Failed to persist session sort preference:', error) + }) + } + + const toggleSortMode = () => { + if (sortMode === 'auto') { + persistSortPreference('manual', snapshotManualOrder(groups)) + return + } + persistSortPreference('auto', reconciledManualOrder) + } + + const moveGroupInPreference = (groupKey: string, direction: 'up' | 'down') => { + if (sortMode !== 'manual') return + const nextManualOrder = moveGroup(reconciledManualOrder, groupKey, direction) + if (nextManualOrder === reconciledManualOrder) return + persistSortPreference('manual', nextManualOrder) + } + + const moveSessionInPreference = (groupKey: string, sessionId: string, direction: 'up' | 'down') => { + if (sortMode !== 'manual') return + const nextManualOrder = moveSession(reconciledManualOrder, groupKey, sessionId, direction) + if (nextManualOrder === reconciledManualOrder) return + persistSortPreference('manual', nextManualOrder) + } + + return { + sortMode, + orderedGroups, + isSortPreferencePending, + toggleSortMode, + moveGroupInPreference, + moveSessionInPreference, + } +} diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index d190e38c9..af96820e1 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -1,3 +1,12 @@ +import type { PermissionModeTone } from '@hapi/protocol' + +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)]' +} + export function isCodexFamilyFlavor(flavor?: string | null): boolean { return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' } @@ -9,3 +18,19 @@ export function isClaudeFlavor(flavor?: string | null): boolean { export function isKnownFlavor(flavor?: string | null): boolean { return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) } + +export function getFlavorTextClass(flavor?: string | null): string { + const key = flavor?.trim() + switch (key) { + case 'claude': + return 'text-[var(--app-flavor-claude)]' + case 'codex': + return 'text-[var(--app-flavor-codex)]' + case 'gemini': + return 'text-[var(--app-flavor-gemini)]' + case 'opencode': + return 'text-[var(--app-flavor-opencode)]' + default: + return 'text-[var(--app-hint)]' + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 342ac5c45..43e7a0678 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -41,6 +41,8 @@ export default { // Sessions page 'sessions.count': '{n} sessions in {m} projects', 'sessions.new': 'New Session', + 'sessions.sort.auto': 'Sort: automatic', + 'sessions.sort.manual': 'Sort: manual', // Session list 'session.item.path': 'path', @@ -64,6 +66,11 @@ export default { 'session.action.archive': 'Archive', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', + 'session.action.moveUp': 'Move Up', + 'session.action.moveDown': 'Move Down', + 'group.more': 'Group actions', + 'group.action.moveUp': 'Move Up', + 'group.action.moveDown': 'Move Down', // Dialogs 'dialog.rename.title': 'Rename Session', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 33e5b1f38..a7efeb109 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -41,6 +41,8 @@ export default { // Sessions page 'sessions.count': '{n} 个会话,{m} 个项目', 'sessions.new': '新建会话', + 'sessions.sort.auto': '排序:自动', + 'sessions.sort.manual': '排序:手动', // Session list 'session.item.path': '路径', @@ -64,6 +66,11 @@ export default { 'session.action.archive': '归档', 'session.action.delete': '删除', 'session.action.copy': '复制', + 'session.action.moveUp': '上移', + 'session.action.moveDown': '下移', + 'group.more': '分组操作', + 'group.action.moveUp': '上移', + 'group.action.moveDown': '下移', // Dialogs 'dialog.rename.title': '重命名会话', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a00b5512b..7cfe79326 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -15,4 +15,5 @@ export const queryKeys = { ] as const, slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const, skills: (sessionId: string) => ['skills', sessionId] as const, + sessionSortPreference: ['session-sort-preference'] as const, } diff --git a/web/src/lib/sessionSortOrder.test.ts b/web/src/lib/sessionSortOrder.test.ts new file mode 100644 index 000000000..55b54aa08 --- /dev/null +++ b/web/src/lib/sessionSortOrder.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { + applyManualOrder, + moveGroup, + moveSession, + reconcileManualOrder, + snapshotManualOrder +} from './sessionSortOrder' + +type TestSession = { + id: string +} + +type TestGroup = { + key: string + sessions: TestSession[] +} + +function makeGroups(): TestGroup[] { + return [ + { + key: 'group-a', + sessions: [{ id: 'a1' }, { id: 'a2' }] + }, + { + key: 'group-b', + sessions: [{ id: 'b1' }, { id: 'b2' }] + } + ] +} + +describe('sessionSortOrder', () => { + it('captures snapshot for groups and sessions', () => { + const groups = makeGroups() + const snapshot = snapshotManualOrder(groups) + + expect(snapshot.groupOrder).toEqual(['group-a', 'group-b']) + expect(snapshot.sessionOrder['group-a']).toEqual(['a1', 'a2']) + expect(snapshot.sessionOrder['group-b']).toEqual(['b1', 'b2']) + }) + + it('reconciles stale ids and appends unknown items to bottom', () => { + const groups: TestGroup[] = [ + { + key: 'group-b', + sessions: [{ id: 'b2' }, { id: 'b3' }] + }, + { + key: 'group-c', + sessions: [{ id: 'c1' }] + } + ] + + const reconciled = reconcileManualOrder(groups, { + groupOrder: ['group-a', 'group-b'], + sessionOrder: { + 'group-b': ['b1', 'b2'] + } + }) + + expect(reconciled.groupOrder).toEqual(['group-b', 'group-c']) + expect(reconciled.sessionOrder['group-b']).toEqual(['b2', 'b3']) + expect(reconciled.sessionOrder['group-c']).toEqual(['c1']) + }) + + it('applies manual order to groups and sessions', () => { + const groups = makeGroups() + const ordered = applyManualOrder(groups, { + groupOrder: ['group-b', 'group-a'], + sessionOrder: { + 'group-a': ['a2', 'a1'], + 'group-b': ['b2', 'b1'] + } + }) + + expect(ordered.map((group) => group.key)).toEqual(['group-b', 'group-a']) + expect(ordered[0]?.sessions.map((session) => session.id)).toEqual(['b2', 'b1']) + expect(ordered[1]?.sessions.map((session) => session.id)).toEqual(['a2', 'a1']) + }) + + it('moves groups up and down', () => { + const base = { + groupOrder: ['group-a', 'group-b', 'group-c'], + sessionOrder: {} + } + + const movedDown = moveGroup(base, 'group-b', 'down') + expect(movedDown.groupOrder).toEqual(['group-a', 'group-c', 'group-b']) + + const movedUp = moveGroup(base, 'group-b', 'up') + expect(movedUp.groupOrder).toEqual(['group-b', 'group-a', 'group-c']) + + const boundary = moveGroup(base, 'group-a', 'up') + expect(boundary).toBe(base) + }) + + it('moves sessions within the same group', () => { + const base = { + groupOrder: ['group-a'], + sessionOrder: { + 'group-a': ['a1', 'a2', 'a3'] + } + } + + const moved = moveSession(base, 'group-a', 'a2', 'down') + expect(moved.sessionOrder['group-a']).toEqual(['a1', 'a3', 'a2']) + + const boundary = moveSession(base, 'group-a', 'a1', 'up') + expect(boundary).toBe(base) + }) +}) diff --git a/web/src/lib/sessionSortOrder.ts b/web/src/lib/sessionSortOrder.ts new file mode 100644 index 000000000..dee79d45f --- /dev/null +++ b/web/src/lib/sessionSortOrder.ts @@ -0,0 +1,150 @@ +import type { SessionManualOrder } from '@/types/api' + +type Direction = 'up' | 'down' + +type SortableSession = { + id: string +} + +type SortableGroup = { + key: string + sessions: TSession[] +} + +export function snapshotManualOrder>( + groups: TGroup[] +): SessionManualOrder { + const groupOrder = groups.map((group) => group.key) + const sessionOrder: Record = {} + + for (const group of groups) { + sessionOrder[group.key] = group.sessions.map((session) => session.id) + } + + return { + groupOrder, + sessionOrder + } +} + +export function reconcileManualOrder>( + groups: TGroup[], + manualOrder: SessionManualOrder +): SessionManualOrder { + const currentGroupKeys = groups.map((group) => group.key) + const currentGroupKeySet = new Set(currentGroupKeys) + + const knownGroups = manualOrder.groupOrder.filter((groupKey) => currentGroupKeySet.has(groupKey)) + const knownGroupSet = new Set(knownGroups) + const appendedGroups = currentGroupKeys.filter((groupKey) => !knownGroupSet.has(groupKey)) + + const sessionOrder: Record = {} + + for (const group of groups) { + const currentSessionIds = group.sessions.map((session) => session.id) + const currentSessionSet = new Set(currentSessionIds) + const storedSessionOrder = manualOrder.sessionOrder[group.key] ?? [] + const knownSessions = storedSessionOrder.filter((sessionId) => currentSessionSet.has(sessionId)) + const knownSessionSet = new Set(knownSessions) + const appendedSessions = currentSessionIds.filter((sessionId) => !knownSessionSet.has(sessionId)) + + sessionOrder[group.key] = [...knownSessions, ...appendedSessions] + } + + return { + groupOrder: [...knownGroups, ...appendedGroups], + sessionOrder + } +} + +export function applyManualOrder>( + groups: TGroup[], + reconciledOrder: SessionManualOrder +): TGroup[] { + const groupIndex = new Map(reconciledOrder.groupOrder.map((groupKey, index) => [groupKey, index])) + + return [...groups] + .sort((groupA, groupB) => { + const indexA = groupIndex.get(groupA.key) ?? Number.MAX_SAFE_INTEGER + const indexB = groupIndex.get(groupB.key) ?? Number.MAX_SAFE_INTEGER + return indexA - indexB + }) + .map((group) => { + const order = reconciledOrder.sessionOrder[group.key] ?? [] + const sessionIndex = new Map(order.map((sessionId, index) => [sessionId, index])) + const sessions = [...group.sessions].sort((sessionA, sessionB) => { + const indexA = sessionIndex.get(sessionA.id) ?? Number.MAX_SAFE_INTEGER + const indexB = sessionIndex.get(sessionB.id) ?? Number.MAX_SAFE_INTEGER + return indexA - indexB + }) + + return { + ...group, + sessions + } + }) +} + +function swapAdjacent(items: T[], index: number, direction: Direction): T[] { + const targetIndex = direction === 'up' ? index - 1 : index + 1 + if (targetIndex < 0 || targetIndex >= items.length) { + return items + } + + const next = [...items] + const current = next[index] + next[index] = next[targetIndex] as T + next[targetIndex] = current as T + return next +} + +export function moveGroup( + manualOrder: SessionManualOrder, + groupKey: string, + direction: Direction +): SessionManualOrder { + const currentIndex = manualOrder.groupOrder.indexOf(groupKey) + if (currentIndex === -1) { + return manualOrder + } + + const nextGroupOrder = swapAdjacent(manualOrder.groupOrder, currentIndex, direction) + if (nextGroupOrder === manualOrder.groupOrder) { + return manualOrder + } + + return { + groupOrder: nextGroupOrder, + sessionOrder: { ...manualOrder.sessionOrder } + } +} + +export function moveSession( + manualOrder: SessionManualOrder, + groupKey: string, + sessionId: string, + direction: Direction +): SessionManualOrder { + const currentOrder = manualOrder.sessionOrder[groupKey] + if (!currentOrder || currentOrder.length === 0) { + return manualOrder + } + + const currentIndex = currentOrder.indexOf(sessionId) + if (currentIndex === -1) { + return manualOrder + } + + const nextOrder = swapAdjacent(currentOrder, currentIndex, direction) + if (nextOrder === currentOrder) { + return manualOrder + } + + return { + groupOrder: [...manualOrder.groupOrder], + sessionOrder: { + ...manualOrder.sessionOrder, + [groupKey]: nextOrder + } + } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 0dd3ad155..36c99ebd7 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Navigate, @@ -12,8 +12,9 @@ import { useParams, } from '@tanstack/react-router' import { App } from '@/App' +import { SortIcon, PinIcon } from '@/components/icons/SortIcons' import { SessionChat } from '@/components/SessionChat' -import { SessionList } from '@/components/SessionList' +import { SessionList, groupSessionsByDirectory } from '@/components/SessionList' import { NewSession } from '@/components/NewSession' import { LoadingState } from '@/components/LoadingState' import { useAppContext } from '@/lib/app-context' @@ -26,10 +27,12 @@ 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' import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message-window-store' +import type { Machine } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -94,6 +97,12 @@ function SettingsIcon(props: { className?: string }) { ) } +function getMachineTitle(machine: Machine): string { + if (machine.metadata?.displayName) return machine.metadata.displayName + if (machine.metadata?.host) return machine.metadata.host + return machine.id.slice(0, 8) +} + function SessionsPage() { const { api } = useAppContext() const navigate = useNavigate() @@ -101,12 +110,26 @@ function SessionsPage() { const matchRoute = useMatchRoute() const { t } = useTranslation() const { sessions, isLoading, error, refetch } = useSessions(api) + const { machines } = useMachines(api, true) const handleRefresh = useCallback(() => { void refetch() }, [refetch]) - const projectCount = new Set(sessions.map(s => s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other')).size + const projectCount = 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) + const machineLabelsById = useMemo(() => { + const labels: Record = {} + for (const machine of machines) { + labels[machine.id] = getMachineTitle(machine) + } + return labels + }, [machines]) const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' @@ -130,6 +153,18 @@ function SessionsPage() { > +
diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 278a49bf2..aff2e54d2 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,8 @@ import type { DecryptedMessage as ProtocolDecryptedMessage, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SessionSummary, SyncEvent as ProtocolSyncEvent, @@ -11,6 +14,9 @@ export type { AttachmentMetadata, ModelMode, PermissionMode, + SessionManualOrder, + SessionSortMode, + SessionSortPreference, Session, SessionSummary, SessionSummaryMetadata, @@ -191,6 +197,20 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type SessionSortPreferenceResponse = { + preference: SessionSortPreference +} + +export type SetSessionSortPreferencePayload = { + sortMode: SessionSortMode + manualOrder: SessionManualOrder + expectedVersion?: number +} + +export type SetSessionSortPreferenceResult = + | { status: 'success'; preference: SessionSortPreference } + | { status: 'version-mismatch'; preference: SessionSortPreference } + export type VisibilityPayload = { subscriptionId: string visibility: 'visible' | 'hidden' diff --git a/web/vite.config.ts b/web/vite.config.ts index 0e328a04b..acca8fca6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -68,7 +68,8 @@ export default defineConfig({ ] }, injectManifest: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'] + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'], + maximumFileSizeToCacheInBytes: 3 * 1024 * 1024 }, devOptions: { enabled: true,