diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c069b2f89..9466da6a0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,7 @@ ## Checklist -- [ ] Code follows project style (no comments, named imports) +- [ ] Code follows project style (doc comments only, named imports) - [ ] TypeScript types are properly defined - [ ] Tests added/updated (80% coverage target) - [ ] `pnpm lint` passes locally diff --git a/AGENTS.md b/AGENTS.md index aae1d66fb..ada7271d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ ## Code Style -- No comments, self-documenting code only +- Doc comments only in new and changed code: JSDoc/TSDoc attached to the declaration they document, in the language's native doc convention. No inline, explanatory, or section comments; code must be self-documenting - No console logs (use Bun's logger or proper error handling) - Strict TypeScript everywhere, proper typing required - Named imports only: `import { Hono } from 'hono'`, `import { useState } from 'react'` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 492335c7d..018b2e90e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,7 +49,7 @@ Each item is tagged with a theme to help you find work in your area of interest: ### Code Style -- Self-documenting code (no comments needed) +- New and changed code uses doc comments only (JSDoc/TSDoc on the declaration they document); no inline or explanatory comments - Strict TypeScript with proper typing - Named imports only - DRY principles - check for existing patterns before adding new ones diff --git a/backend/src/services/opencode-event-routing.ts b/backend/src/services/opencode-event-routing.ts index 877e2b949..bed6f3d70 100644 --- a/backend/src/services/opencode-event-routing.ts +++ b/backend/src/services/opencode-event-routing.ts @@ -52,6 +52,33 @@ export function planSessionStatusReplay( return { idle, busy } } +/** + * Records the activity state an event implies for its session, so that directory-scoped work can be + * limited to the sessions that are actually running. + * + * @param busySessions Sessions currently running on the server, updated in place. + * @param event Event observed on the server stream. + */ +export function applySessionActivity(busySessions: Set, event: V2Event): void { + switch (event.type) { + case 'session.status': { + const { sessionID, status } = event.data + if (status.type === 'idle') busySessions.delete(sessionID) + else busySessions.add(sessionID) + return + } + case 'session.execution.started': + busySessions.add(event.data.sessionID) + return + case 'session.idle': + case 'session.execution.succeeded': + case 'session.execution.failed': + case 'session.execution.interrupted': + case 'session.deleted': + busySessions.delete(event.data.sessionID) + } +} + export function createReplayEventBuilder(idPrefix: string): ReplayEventBuilder { let count = 0 return (type, directory, data) => { diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts index 91dc01e3b..c81fbcf7c 100644 --- a/backend/src/services/remote-server-events.ts +++ b/backend/src/services/remote-server-events.ts @@ -14,6 +14,7 @@ import type { OpenCodeServerRegistry, } from './opencode-server-registry' import { + applySessionActivity, collectPendingReplayEvents, createAuthorizedEventSourceInit, createReplayEventBuilder, @@ -63,6 +64,7 @@ interface RemoteServerStream { versionRequested: boolean versionProbeToken: number sessionDirectories: Map + busySessions: Set subagentSessions: Set } @@ -152,6 +154,9 @@ export class RemoteServerEventHub { const key = buildServerDirectoryKey(serverId, directory) if (!this.aggregator.hasClientDirectory(clientId, key)) return + const stream = this.streams.get(serverId) + if (!stream || !this.hasActiveSession(stream, directory)) return + let api: OpenCodeApi try { api = this.registry.getClient(serverId).api @@ -172,6 +177,13 @@ export class RemoteServerEventHub { this.emitReplayEventsToClient(clientId, serverId, directory, events.forms) } + private hasActiveSession(stream: RemoteServerStream, directory: string): boolean { + for (const sessionID of stream.busySessions) { + if (stream.sessionDirectories.get(sessionID) === directory) return true + } + return false + } + private emitReplayEventsToClient( clientId: string, serverId: string, @@ -281,6 +293,7 @@ export class RemoteServerEventHub { try { const { event, resolvedDirectory, payloadJson } = routed const sessionID = sessionIDFromEvent(event) + if (sessionID) applySessionActivity(stream.busySessions, event) if (sessionID && event.type === 'session.deleted') { stream.subagentSessions.delete(sessionID) } else if (event.type === 'session.created' && event.data.parentID) { @@ -352,12 +365,16 @@ export class RemoteServerEventHub { if (!this.isStreamCurrent(serverId, stream, source)) return + await this.resolveActiveSessionDirectories(api, serverId, stream, Object.keys(active ?? {})) + if (!this.isStreamCurrent(serverId, stream, source)) return + const plan = planSessionStatusReplay(active, stream.sessionDirectories) let replayed = 0 for (const { sessionID, directory } of plan.idle) { const event = this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } }) this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + stream.busySessions.delete(sessionID) replayed++ } @@ -365,6 +382,7 @@ export class RemoteServerEventHub { const directory = stream.sessionDirectories.get(sessionID) ?? null const event = this.buildReplayEvent('session.status', directory ?? '', { sessionID, status: { type: 'busy' } }) this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event)) + stream.busySessions.add(sessionID) replayed++ } @@ -373,6 +391,26 @@ export class RemoteServerEventHub { } } + private async resolveActiveSessionDirectories( + api: OpenCodeApi, + serverId: string, + stream: RemoteServerStream, + sessionIDs: string[], + ): Promise { + const unresolved = sessionIDs.filter((sessionID) => !stream.sessionDirectories.has(sessionID)) + if (unresolved.length === 0) return + + await Promise.allSettled(unresolved.map(async (sessionID) => { + try { + const session = await api.session.get({ sessionID }) + const directory = session.location.directory + if (directory) stream.sessionDirectories.set(sessionID, directory) + } catch (error) { + logger.warn(`remote status replay: failed to resolve the directory of ${sessionID} on ${serverId}: ${String(error)}`) + } + })) + } + private async refreshVersion(serverId: string): Promise { const stream = this.streams.get(serverId) if (!stream || stream.versionRequested) return @@ -413,6 +451,7 @@ export class RemoteServerEventHub { versionRequested: false, versionProbeToken: 0, sessionDirectories: new Map(), + busySessions: new Set(), subagentSessions: new Set(), } this.streams.set(serverId, stream) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index ed36f9c55..d409604e8 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -37,6 +37,9 @@ export class RepoInputError extends Error { } } +const HTTP_AUTH_STATUS_PATTERN = /\b(?:http|status|error):?\s+401\b/ +const HTTP_NOT_FOUND_STATUS_PATTERN = /\b(?:http|status|error):?\s+404\b/ + function notFoundOrDeniedMessage(repoUrl: string): string { return `Repository not found or access denied: ${repoUrl}. Check the URL and add credentials in Settings > Git Credentials if it is private.` } @@ -52,7 +55,26 @@ function enhanceCloneError(repoUrl: string, originalMessage: string): RepoInputE return new RepoInputError(`Repository not reachable: ${repoUrl}. Check the URL and your network connection.`) } - if (message.includes('repository not found') || message.includes('not found') || message.includes('does not exist') || message.includes('404')) { + if ( + message.includes('authentication failed') || + message.includes('could not authenticate') || + message.includes('invalid credentials') || + message.includes('invalid username or password') || + message.includes('could not read username') || + message.includes('could not read password') || + HTTP_AUTH_STATUS_PATTERN.test(message) + ) { + return isGitHubHttpsUrl(repoUrl) + ? new RepoInputError(notFoundOrDeniedMessage(repoUrl)) + : new RepoInputError(`Authentication failed for ${repoUrl}. Check your credentials in Settings > Git Credentials.`) + } + + if ( + message.includes('repository not found') || + message.includes('not found') || + message.includes('does not exist') || + HTTP_NOT_FOUND_STATUS_PATTERN.test(message) + ) { return new RepoInputError(notFoundOrDeniedMessage(repoUrl)) } @@ -62,12 +84,6 @@ function enhanceCloneError(repoUrl: string, originalMessage: string): RepoInputE : new RepoInputError(notFoundOrDeniedMessage(repoUrl)) } - if (message.includes('authentication failed') || message.includes('could not authenticate') || message.includes('invalid credentials') || message.includes('could not read username')) { - return isGitHubHttpsUrl(repoUrl) - ? new RepoInputError(notFoundOrDeniedMessage(repoUrl)) - : new RepoInputError(`Authentication failed for ${repoUrl}. Check your credentials in Settings > Git Credentials.`) - } - if (message.includes('not a git repository') || message.includes('fatal:')) { return new RepoInputError(notFoundOrDeniedMessage(repoUrl)) } diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 7aa36fec2..d09288fc2 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -17,6 +17,15 @@ const spawnMock = vi.hoisted(() => vi.fn(() => ({ const spawnSyncMock = vi.hoisted(() => vi.fn()) +const processKillMock = vi.hoisted(() => vi.fn((_pid: number, signal?: number | NodeJS.Signals) => { + if (signal === 0) { + const error = new Error('kill ESRCH') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + } + return true +})) + const readFileSyncMock = vi.hoisted(() => vi.fn()) vi.mock('bun:sqlite', () => ({ @@ -186,6 +195,16 @@ const routeVersionProbeThroughExecSyncStub = () => { beforeEach(routeVersionProbeThroughExecSyncStub) +const originalProcessKill = process.kill + +beforeEach(() => { + process.kill = processKillMock as unknown as typeof process.kill +}) + +afterEach(() => { + process.kill = originalProcessKill +}) + // Reset singleton before any tests run to clear any polluted state from previous test files beforeAll(async () => { const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -2823,6 +2842,7 @@ describe('OpenCodeServerManager - server auth', () => { await manager.start() await manager.stop() + expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGTERM') const rmMock = fs.rm as unknown as ReturnType expect(rmMock).toHaveBeenCalledWith('/test/workspace/.opencode/state/opencode-server-child.json', { force: true }) } finally { @@ -2869,6 +2889,7 @@ describe('OpenCodeServerManager - server auth', () => { await secondManager.start() expect(spawnMock).toHaveBeenCalledTimes(2) + expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGKILL') } finally { readFileMock.mockReset() Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: originalNodeEnv, configurable: true, writable: true }) diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts index cfe464484..97248a439 100644 --- a/backend/test/services/remote-server-events.test.ts +++ b/backend/test/services/remote-server-events.test.ts @@ -104,6 +104,7 @@ interface ApiFixture { permissions?: Record forms?: Record active?: Record + sessionDirectories?: Record } function makeApi(fixture: ApiFixture = {}): OpenCodeApi { @@ -126,6 +127,9 @@ function makeApi(fixture: ApiFixture = {}): OpenCodeApi { }, session: { active: vi.fn(async () => fixture.active ?? {}), + get: vi.fn(async (input: { sessionID: string }) => ({ + location: { directory: fixture.sessionDirectories?.[input.sessionID] ?? '' }, + })), }, } as unknown as OpenCodeApi } @@ -779,8 +783,13 @@ describe('RemoteServerEventHub pending action replay', () => { sseAggregator.shutdown() }) + function trackSessionStatus(sessionID: string, directory: string, status: string): void { + sources.sources[0]!.message(remoteEvent('session.status', { sessionID, status: { type: status } }, directory)) + } + it('replays pending remote actions to the subscribing client only', async () => { hub.start() + trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() const other = createCapturingClient() sseAggregator.addClient('replay-a', client.callback, client.writeFrame, ['srv_a|/repo']) @@ -802,11 +811,57 @@ describe('RemoteServerEventHub pending action replay', () => { expect(other.frames).toHaveLength(0) }) + it('skips the replay for a linked directory with no active session', async () => { + hub.start() + const client = createCapturingClient() + sseAggregator.addClient('idle', client.callback, client.writeFrame, ['srv_a|/repo']) + + await flushPromises() + + expect(fake.api.permission.request.list).not.toHaveBeenCalled() + expect(fake.api.form.list).not.toHaveBeenCalled() + expect(client.frames).toHaveLength(0) + }) + + it('stops replaying for a linked directory once its session goes idle', async () => { + hub.start() + trackSessionStatus('ses-1', '/repo', 'busy') + trackSessionStatus('ses-1', '/repo', 'idle') + const client = createCapturingClient() + sseAggregator.addClient('settled', client.callback, client.writeFrame, ['srv_a|/repo']) + + await flushPromises() + + expect(fake.api.permission.request.list).not.toHaveBeenCalled() + expect(client.frames).toHaveLength(0) + }) + + it('resolves the directory of an active session before gating the replay', async () => { + fake = createRegistry([SERVER_A], { + active: { 'ses-1': { type: 'running' } }, + sessionDirectories: { 'ses-1': '/repo' }, + permissions: { '/repo': [{ id: 'perm-1', sessionID: 'ses-1', action: 'shell', resources: ['ls'] }] }, + }) + hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory }) + hub.start() + sources.sources[0]!.open() + await flushPromises() + + const client = createCapturingClient() + sseAggregator.addClient('resolved', client.callback, client.writeFrame, ['srv_a|/repo']) + await flushPromises() + + expect(fake.api.session.get).toHaveBeenCalledWith({ sessionID: 'ses-1' }) + expect(fake.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + expect(client.frames.map(parseFrame).map(frame => frame.payload.type)).toEqual(['permission.asked']) + }) + it('does not use the local pending action fetcher for remote keys', async () => { const localApi = makeApi({ permissions: { '/repo': [{ id: 'local-perm' }] } }) sseAggregator.setPendingActionsFetcher({ api: localApi }) hub.start() + trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('mixed', client.callback, client.writeFrame, ['/repo', 'srv_a|/repo']) @@ -820,6 +875,7 @@ describe('RemoteServerEventHub pending action replay', () => { it('replays remote actions when a client subscribes later', async () => { hub.start() + trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('late', client.callback, client.writeFrame, []) @@ -838,6 +894,7 @@ describe('RemoteServerEventHub pending action replay', () => { vi.mocked(fake.api.form.list).mockResolvedValue({ location: { directory: '/repo' }, data: [] }) hub.start() + trackSessionStatus('ses-1', '/repo', 'busy') const client = createCapturingClient() sseAggregator.addClient('unsubscribed', client.callback, client.writeFrame, ['srv_a|/repo']) sseAggregator.removeDirectories('unsubscribed', ['srv_a|/repo']) diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index 5948bad60..93f154dee 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -88,6 +88,14 @@ describe('repo service real git', () => { }) } + function createTlsCloneGitAuth(): GitAuthService { + return createGitAuthService({ + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + }) + } + describe('initLocalRepo', () => { it('initializes a relative repo and marks it ready', async () => { const { initLocalRepo } = await import('../../src/services/repo') @@ -756,7 +764,7 @@ describe('repo service real git', () => { const url = `https://127.0.0.1:${port}/404/repo-missing.git` process.env.GIT_SSL_NO_VERIFY = 'true' try { - await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Repository not found/) + await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Repository not found/) } finally { delete process.env.GIT_SSL_NO_VERIFY } @@ -773,7 +781,24 @@ describe('repo service real git', () => { const url = `https://user:pass@127.0.0.1:${port}/repo-auth.git` process.env.GIT_SSL_NO_VERIFY = 'true' try { - await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Authentication failed/) + await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Authentication failed/) + } finally { + delete process.env.GIT_SSL_NO_VERIFY + } + }) + + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('maps an authentication clone failure whose URL contains a 404 sequence', async () => { + const { cloneRepo } = await import('../../src/services/repo') + const name = uniqueName('clone-auth-404') + + await withTlsServer(401, async (port) => { + const url = `https://user:pass@127.0.0.1:${port}/404/repo-auth.git` + process.env.GIT_SSL_NO_VERIFY = 'true' + try { + await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Authentication failed/) } finally { delete process.env.GIT_SSL_NO_VERIFY } diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts index 03ec96c13..a75180a54 100644 --- a/frontend/src/api/opencode.test.ts +++ b/frontend/src/api/opencode.test.ts @@ -12,6 +12,7 @@ import { createSessionWithPrompt, deleteSession, findFiles, + findRemoteProjectForDirectory, forkSession, getSession, interruptSession, @@ -20,13 +21,16 @@ import { listPendingPermissions, listSessionMessages, listSessionPage, + rankProjectsBySessionActivity, readSessionSnapshot, + remoteProjectName, renameSession, replyForm, replyPermission, runCommand, runShell, sendPrompt, + sortProjectsByUpdatedAt, stageRevert, switchSessionAgent, switchSessionModel, @@ -753,3 +757,134 @@ describe('OpenCode facade', () => { expect(lastRequest().init.method).toBe('DELETE') }) }) + +const project = ( + id: string, + name: string | undefined, + canonical: string, + time: { created: number; updated: number; active?: number }, +) => ({ id, name, canonical, time, sandboxes: [] }) + +const session = (id: string, directory: string, updated: number) => ({ + id, + projectID: 'prj', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: updated - 1000, updated }, + location: { directory }, +}) + +describe('project helpers', () => { + it('orders projects by their update time, most recent first, without mutating the input', () => { + const stale = project('p_stale', 'Stale', '/work/stale', { created: 1, updated: 100 }) + const recent = project('p_recent', 'Recent', '/work/recent', { created: 1, updated: 900 }) + const middle = project('p_middle', 'Middle', '/work/middle', { created: 1, updated: 700 }) + const projects = [stale, recent, middle] + + expect(sortProjectsByUpdatedAt(projects).map((entry) => entry.id)).toEqual([ + 'p_recent', + 'p_middle', + 'p_stale', + ]) + expect(projects.map((entry) => entry.id)).toEqual(['p_stale', 'p_recent', 'p_middle']) + }) + + it('labels a project with its newest session time and ignores the project activity time', () => { + const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10, active: 900 })] + + const [entry] = rankProjectsBySessionActivity(projects, [ + session('ses_old', '/work/alpha', 300), + session('ses_new', '/work/alpha', 500), + ]) + + expect(entry.project.id).toBe('p1') + expect(entry.activityAt).toBe(500) + }) + + it('credits a session in a nested directory to the project that owns it', () => { + const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10 })] + + const [entry] = rankProjectsBySessionActivity(projects, [ + session('ses_nested', '/work/alpha/packages/web', 400), + ]) + + expect(entry.activityAt).toBe(400) + }) + + it('orders session-backed projects first, then the rest by update time', () => { + const projects = [ + project('p_idle', 'Idle', '/work/idle', { created: 1, updated: 950, active: 999 }), + project('p_worked', 'Worked', '/work/worked', { created: 1, updated: 20, active: 999 }), + project('p_quiet', 'Quiet', '/work/quiet', { created: 1, updated: 600 }), + ] + + const ranked = rankProjectsBySessionActivity(projects, [session('ses_1', '/work/worked', 300)]) + + expect(ranked.map((entry) => entry.project.id)).toEqual(['p_worked', 'p_idle', 'p_quiet']) + expect(ranked.map((entry) => entry.activityAt)).toEqual([300, 950, 600]) + }) + + it('ignores sessions that belong to no listed project', () => { + const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10 })] + + const [entry] = rankProjectsBySessionActivity(projects, [session('ses_1', '/srv/other', 900)]) + + expect(entry.activityAt).toBe(10) + }) + + it('names a project from its trimmed title', () => { + expect(remoteProjectName(project('p1', ' Alpha ', '/work/alpha', { created: 1, updated: 2 }))).toBe('Alpha') + }) + + it('names a project from its directory when it has no title', () => { + expect(remoteProjectName(project('p1', undefined, '/work/beta', { created: 1, updated: 2 }))).toBe('beta') + expect(remoteProjectName(project('p1', ' ', '/work/gamma', { created: 1, updated: 2 }))).toBe('gamma') + }) + + it('finds the project that owns a directory or one nested inside it', () => { + const projects = [ + project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 2 }), + project('p2', 'Beta', '/work/beta', { created: 1, updated: 2 }), + ] + + expect(findRemoteProjectForDirectory(projects, '/work/beta')?.id).toBe('p2') + expect(findRemoteProjectForDirectory(projects, '/work/beta/packages/web')?.id).toBe('p2') + }) + + it('picks the deepest project when one canonical directory contains another', () => { + const projects = [ + project('p_parent', 'Repo', '/work/repo', { created: 1, updated: 2 }), + project('p_child', 'App', '/work/repo/packages/app', { created: 1, updated: 3 }), + ] + + expect(findRemoteProjectForDirectory(projects, '/work/repo')?.id).toBe('p_parent') + expect(findRemoteProjectForDirectory(projects, '/work/repo/packages/app')?.id).toBe('p_child') + expect(findRemoteProjectForDirectory(projects, '/work/repo/packages/app/src')?.id).toBe('p_child') + }) + + it('credits a session in a nested project to the deepest project', () => { + const projects = [ + project('p_parent', 'Repo', '/work/repo', { created: 1, updated: 20 }), + project('p_child', 'App', '/work/repo/packages/app', { created: 1, updated: 10 }), + ] + + const ranked = rankProjectsBySessionActivity(projects, [ + session('ses_nested', '/work/repo/packages/app/src', 400), + ]) + + expect(ranked.find((entry) => entry.project.id === 'p_child')?.activityAt).toBe(400) + expect(ranked.find((entry) => entry.project.id === 'p_parent')?.activityAt).toBe(20) + }) + + it('returns no project when no canonical directory contains the directory', () => { + const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 2 })] + + expect(findRemoteProjectForDirectory(projects, '/srv/elsewhere')).toBeUndefined() + }) + + it('does not match a directory that only shares a prefix with a canonical directory', () => { + const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 2 })] + + expect(findRemoteProjectForDirectory(projects, '/work/alphabet')).toBeUndefined() + }) +}) diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index b38aa8afb..40bbabf9f 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -1,6 +1,7 @@ import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' import { MANAGER_PROMPT_CLIENT_METADATA_KEY } from '@opencode-manager/shared/schemas' import { isMobileViewport } from '@/hooks/useMobile' +import { getDirectoryName } from '@/lib/utils' import { callOpenCode } from './opencodeApi' import type { AgentInfo, @@ -37,6 +38,100 @@ export type OpenCodeLocationInfo = Awaited>[number] +/** A project paired with the timestamp that represents its most recent activity. */ +export interface RemoteProjectActivity { + project: OpenCodeProject + activityAt: number +} + +/** + * Orders projects from the most to the least recently updated. + * + * A project's own update time covers metadata edits and registration, not work, so this is the + * ordering used when no session evidence is available. + * + * @param projects Projects to order. + * @returns A new array with the projects sorted by update time; the input is left untouched. + */ +export function sortProjectsByUpdatedAt(projects: OpenCodeProject[]): OpenCodeProject[] { + return [...projects].sort((a, b) => b.time.updated - a.time.updated) +} + +/** + * Derives each project's activity time from the newest session of the server that belongs to it and + * orders projects by that time. + * + * Session update times record work; a project's own update time only records metadata edits and + * registration, so projects without a session in the given page fall back to it and sort after the + * session-backed ones. `time.active` is deliberately unused: OpenCode stamps it on every location + * boot, including boots caused by other clients and idle evictions. + * + * @param projects Projects of one server. + * @param sessions Sessions of that server, as returned by a first session page. + * @returns The projects with their activity time, most recently active first. + */ +export function rankProjectsBySessionActivity( + projects: OpenCodeProject[], + sessions: SessionInfo[], +): RemoteProjectActivity[] { + const sessionActivityByProjectId = new Map() + for (const session of sessions) { + const project = findRemoteProjectForDirectory(projects, session.location.directory) + if (!project) continue + const updated = session.time.updated + const current = sessionActivityByProjectId.get(project.id) + if (current === undefined || updated > current) { + sessionActivityByProjectId.set(project.id, updated) + } + } + + const sessionActivity = (project: OpenCodeProject) => sessionActivityByProjectId.get(project.id) + + return projects + .map((project) => ({ + project, + activityAt: sessionActivity(project) ?? project.time.updated, + })) + .sort((a, b) => { + const aHasSession = sessionActivity(a.project) !== undefined + const bHasSession = sessionActivity(b.project) !== undefined + if (aHasSession !== bHasSession) return aHasSession ? -1 : 1 + return b.activityAt - a.activityAt + }) +} + +/** + * Returns the display name of a project, falling back to the name of its directory. + * + * @param project Project whose name is resolved. + * @returns The trimmed project name, or the last segment of its canonical directory. + */ +export function remoteProjectName(project: OpenCodeProject): string { + return project.name?.trim() || getDirectoryName(project.canonical) +} + +/** + * Finds the project that owns a directory, matching its canonical directory and anything nested + * inside it. Nested projects share a canonical prefix, so the deepest match owns the directory. + * + * @param projects Projects of the server that owns the directory. + * @param directory Directory whose owning project is resolved. + * @returns The owning project, or undefined when the directory belongs to none of them. + */ +export function findRemoteProjectForDirectory( + projects: OpenCodeProject[], + directory: string, +): OpenCodeProject | undefined { + let owner: OpenCodeProject | undefined + for (const project of projects) { + if (directory !== project.canonical && !directory.startsWith(`${project.canonical}/`)) continue + if (!owner || project.canonical.length > owner.canonical.length) { + owner = project + } + } + return owner +} + export type VcsInfo = Awaited>['data'] export type VcsFileStatus = Awaited>['data'][number] diff --git a/frontend/src/components/message/ComposerModelPill.tsx b/frontend/src/components/message/ComposerModelPill.tsx new file mode 100644 index 000000000..89133d90d --- /dev/null +++ b/frontend/src/components/message/ComposerModelPill.tsx @@ -0,0 +1,103 @@ +import { memo, useMemo } from 'react' +import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' +import { ContextUsageMeter } from '@/components/session/ContextUsageMeter' +import { useSession } from '@/hooks/useOpenCode' +import { useContextUsage } from '@/hooks/useContextUsage' +import type { ModelSelectionSession } from '@/hooks/useModelSelection' +import { + CONTEXT_USAGE_TEXT_CLASS, + contextUsageAccessibleLabel, + contextUsageTone, + formatContextUsage, +} from '@/lib/contextUsage' +import { cn } from '@/lib/utils' + +interface ComposerModelPillProps { + directory?: string + sessionID: string + modelName: string + variant: string | null + modelSelectionSession?: ModelSelectionSession + open: boolean + onOpenChange: (open: boolean) => void + isCompacting: boolean + onCompact: () => void +} + +/** + * Model selector trigger for the composer. + * + * Owns the context usage observer so transcript streaming flushes re-render only this pill instead of the + * whole composer subtree. + */ +export const ComposerModelPill = memo(function ComposerModelPill({ + directory, + sessionID, + modelName, + variant, + modelSelectionSession, + open, + onOpenChange, + isCompacting, + onCompact, +}: ComposerModelPillProps) { + const { data: session } = useSession(sessionID, directory) + const contextUsage = useContextUsage(sessionID, directory, session?.revert?.messageID) + const pillUsage = contextUsage ? formatContextUsage(contextUsage) : null + const pillUsageTone = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(contextUsage?.usagePercentage ?? null)] + const pillLabel = [ + modelName || 'Select model', + variant ? `${variant} variant` : null, + contextUsage ? contextUsageAccessibleLabel(contextUsage) : null, + ].filter((part): part is string => part !== null).join(', ') + + const trigger = useMemo( + () => ( + + ), + [contextUsage, modelName, pillLabel, pillUsage, pillUsageTone, variant], + ) + + return ( + + {trigger} + + ) +}) diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index eb07be053..226567b53 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -56,6 +56,7 @@ vi.mock('@/hooks/useOpenCode', async () => { ...actual, useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index f9fd93b6c..e7080deed 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useSyncExternalStore } from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' import { useComposerDraftStore } from '@/stores/composerDraftStore' import { useSessionStatus } from '@/stores/sessionStatusStore' import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' +import type { ContextUsage } from '@/lib/contextUsage' const mocks = vi.hoisted(() => ({ useSTT: vi.fn(), @@ -21,6 +23,11 @@ const mocks = vi.hoisted(() => ({ useSendErrorStore: vi.fn(), useHasBackgroundActivity: vi.fn(), useSendPrompt: vi.fn(), + useContextUsage: vi.fn(), + contextUsageListeners: new Set<() => void>(), + contextUsageValue: null as ContextUsage | null, + agentQuickSelectRenders: { current: 0 }, + sessionData: undefined as { revert?: { messageID: string } } | undefined, hasPendingPermission: vi.fn(() => false), setShowDialog: vi.fn(), })) @@ -54,9 +61,11 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: mocks.sessionData }), })) vi.mock('@/hooks/useProviders', () => ({ useProviders: () => ({ data: undefined }) })) +vi.mock('@/hooks/useContextUsage', () => ({ useContextUsage: mocks.useContextUsage })) vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ @@ -66,7 +75,10 @@ vi.mock('@/contexts/EventContext', () => ({ })) vi.mock('@/components/agent/AgentQuickSelect', () => ({ - AgentQuickSelect: () =>
, + AgentQuickSelect: () => { + mocks.agentQuickSelectRenders.current += 1 + return
+ }, })) vi.mock('@/components/model/ModelQuickSelect', () => ({ @@ -87,6 +99,22 @@ vi.mock('@/hooks/useSessionGoals', () => ({ })) vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: () => ({ data: undefined }) })) +function subscribeContextUsage(listener: () => void) { + mocks.contextUsageListeners.add(listener) + return () => { + mocks.contextUsageListeners.delete(listener) + } +} + +function getContextUsageSnapshot() { + return mocks.contextUsageValue +} + +function setContextUsage(usage: ContextUsage | null) { + mocks.contextUsageValue = usage + mocks.contextUsageListeners.forEach((listener) => listener()) +} + const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) @@ -153,6 +181,11 @@ describe('PromptInput composer layout', () => { mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) mocks.useHasBackgroundActivity.mockReturnValue(false) + mocks.useContextUsage.mockReturnValue(null) + mocks.contextUsageListeners.clear() + mocks.contextUsageValue = null + mocks.agentQuickSelectRenders.current = 0 + mocks.sessionData = undefined }) it('anchors the activity bar in the gap above the controls row', () => { @@ -242,6 +275,89 @@ describe('PromptInput composer layout', () => { }) }) + describe('model pill context usage', () => { + const setUsage = (usage: ContextUsage) => { + mocks.useContextUsage.mockReturnValue(usage) + } + + it('shows the percentage with its tone and the meter on the model pill', () => { + setUsage({ totalTokens: 84000, usagePercentage: 42, contextLimit: 200000 }) + renderComponent() + + const pill = screen.getByRole('button', { name: /test-model, context 42% used/ }) + const usage = screen.getByTestId('model-pill-context-usage') + + expect(pill).toContainElement(usage) + expect(usage).toHaveTextContent('42%') + expect(usage).toHaveClass('text-success') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '42%' }) + }) + + it('tints the warning tone from 50 percent', () => { + setUsage({ totalTokens: 120000, usagePercentage: 50, contextLimit: 240000 }) + renderComponent() + + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-warning') + }) + + it('tints the destructive tone from 80 percent', () => { + setUsage({ totalTokens: 176000, usagePercentage: 88, contextLimit: 200000 }) + renderComponent() + + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-destructive') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '88%' }) + }) + + it('caps the meter at full width when usage exceeds the context window', () => { + setUsage({ totalTokens: 260000, usagePercentage: 130, contextLimit: 200000 }) + renderComponent() + + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '100%' }) + }) + + it('falls back to a compact token count without a known context limit', () => { + setUsage({ totalTokens: 12400, usagePercentage: null, contextLimit: null }) + renderComponent() + + expect(screen.getByRole('button', { name: /test-model, context 12k tokens used/ })).toBeInTheDocument() + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('12k') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '0%' }) + }) + + it('hides the usage without context data', () => { + renderComponent() + + expect(screen.getByRole('button', { name: 'test-model' })).toBeInTheDocument() + expect(screen.queryByTestId('model-pill-context-usage')).not.toBeInTheDocument() + expect(screen.queryByTestId('context-usage-meter')).not.toBeInTheDocument() + }) + + it('re-renders only the pill when the context usage changes', () => { + mocks.useContextUsage.mockImplementation(() => useSyncExternalStore(subscribeContextUsage, getContextUsageSnapshot)) + setContextUsage({ totalTokens: 84000, usagePercentage: 42, contextLimit: 200000 }) + renderComponent() + + const siblingRenders = mocks.agentQuickSelectRenders.current + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('42%') + expect(siblingRenders).toBeGreaterThan(0) + + act(() => { + setContextUsage({ totalTokens: 100000, usagePercentage: 50, contextLimit: 200000 }) + }) + + expect(screen.getByTestId('model-pill-context-usage')).toHaveTextContent('50%') + expect(screen.getByTestId('model-pill-context-usage')).toHaveClass('text-warning') + expect(mocks.agentQuickSelectRenders.current).toBe(siblingRenders) + }) + + it('measures usage up to the staged revert boundary', () => { + mocks.sessionData = { revert: { messageID: 'msg_2' } } + renderComponent() + + expect(mocks.useContextUsage).toHaveBeenCalledWith('test-session', '/test', 'msg_2') + }) + }) + describe('mobile key bar', () => { beforeEach(() => { mocks.useMobile.mockReturnValue(true) diff --git a/frontend/src/components/message/PromptInput.draft.test.tsx b/frontend/src/components/message/PromptInput.draft.test.tsx index 207880829..087bf0d39 100644 --- a/frontend/src/components/message/PromptInput.draft.test.tsx +++ b/frontend/src/components/message/PromptInput.draft.test.tsx @@ -54,6 +54,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: mocks.useSendShell, mutateAsync: mocks.useSendShell, isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: undefined }), })) vi.mock('@/hooks/useProviders', () => ({ useProviders: () => ({ data: undefined }) })) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index 2f63e25f3..011673e91 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -47,6 +47,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { useInterruptSession: () => ({ mutate: mocks.interrupt }), useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index 2721ecac2..aaf38ed15 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -44,6 +44,7 @@ vi.mock('@/hooks/useOpenCode', async () => { ...actual, useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), + useSession: () => ({ data: undefined }), } }) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 22f4f146a..c09a9656f 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -59,6 +59,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), + useSession: () => ({ data: undefined }), })) vi.mock('@/hooks/useCommands', () => ({ diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 624964824..2be610038 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -13,7 +13,7 @@ import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useSendErrorStore } from '@/stores/sendErrorStore' import { useComposerDraftStore, type ComposerDraft } from '@/stores/composerDraftStore' import { useRecentCommandsStore } from '@/stores/recentCommandsStore' -import { useHasBackgroundActivity } from '@/stores/sessionStatusStore' +import { useHasBackgroundActivity, useSessionStatusForSession } from '@/stores/sessionStatusStore' import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' import { useMobile } from '@/hooks/useMobile' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -27,11 +27,11 @@ import { IconToggleButton } from '@/components/ui/icon-toggle-button' import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { ComposerActivityBar } from './ComposerActivityBar' -import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { VoiceConversation, type VoiceConversationHandle } from './VoiceConversation' import { PermissionModeToggle } from '@/components/session/PermissionModeToggle' import { SessionSandboxMenu } from '@/components/session/SessionSandboxMenu' +import { ComposerModelPill } from './ComposerModelPill' import { ComposerToolsMenu } from './ComposerToolsMenu' import { useSessionGoal, useStartSessionGoal } from '@/hooks/useSessionGoals' import { useSessionPermissionMode } from '@/hooks/useSessionPermissionMode' @@ -1012,6 +1012,9 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions() const hasPendingPermissionForSession = hasPermissionsForSession(sessionID) const { hasVariants, currentVariant, cycleVariant } = useVariants(directory, modelSelectionSession) + const isCompacting = useSessionStatusForSession(sessionID).type === 'compact' + const modelPillVariant = hasVariants && currentVariant ? currentVariant : null + const handleCompact = useCallback(() => commandActions.compact(''), [commandActions]) const handleAgentChange = useCallback((agentId: string) => { setLocalMode(agentId) @@ -1340,21 +1343,17 @@ return ( ) : null} - - - + isCompacting={isCompacting} + onCompact={handleCompact} + /> )}
diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx index 56a1867d1..a179016f0 100644 --- a/frontend/src/components/model/ModelQuickSelect.test.tsx +++ b/frontend/src/components/model/ModelQuickSelect.test.tsx @@ -563,3 +563,140 @@ describe('ModelQuickSelect keyboard navigation', () => { } }) }) + +describe('ModelQuickSelect context strip', () => { + const session = { id: 'ses_1' } + const usage = { totalTokens: 12000, usagePercentage: 6, contextLimit: 200000 } + + it('shows the used and limit tokens with the percentage and meter', () => { + render() + + const summary = screen.getByTestId('context-usage-summary') + + expect(summary).toHaveTextContent('12k / 200k · 6%') + expect(summary).toHaveClass('text-success') + expect(summary).toHaveAttribute('aria-label', 'context 6% used') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '6%' }) + }) + + it('tints the summary with the warning and destructive tones', () => { + const { rerender } = render( + , + ) + expect(screen.getByTestId('context-usage-summary')).toHaveClass('text-warning') + + rerender() + expect(screen.getByTestId('context-usage-summary')).toHaveClass('text-destructive') + }) + + it('compacts the session from the strip', async () => { + const user = userEvent.setup() + const onCompact = vi.fn() + + render() + + await user.click(screen.getByRole('button', { name: 'Compact session' })) + + expect(onCompact).toHaveBeenCalledTimes(1) + }) + + it('disables compaction while compacting or without a session', () => { + const onCompact = vi.fn() + const { rerender } = render( + , + ) + + expect(screen.getByRole('button', { name: 'Compact session' })).toBeDisabled() + + rerender() + + expect(screen.getByRole('button', { name: 'Compact session' })).toBeDisabled() + expect(onCompact).not.toHaveBeenCalled() + }) + + it('falls back to tokens and an empty meter without a known limit', () => { + render( + , + ) + + expect(screen.getByTestId('context-usage-summary')).toHaveTextContent('12k tokens') + expect(screen.getByTestId('context-usage-meter-fill')).toHaveStyle({ width: '0%' }) + }) + + it('omits the strip without context usage', () => { + render() + + expect(screen.queryByTestId('context-usage-strip')).not.toBeInTheDocument() + }) +}) + +describe('ModelQuickSelect model descriptions', () => { + const limitedProviders: Provider[] = [ + { + id: 'anthropic', + name: 'Anthropic', + models: [ + { + id: 'claude-sonnet-4', + key: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + released: 0, + free: false, + limit: { context: 200000, output: 8192 }, + }, + { + id: 'claude-opus-5-5', + key: 'claude-opus-5-5', + name: 'Claude Opus 5.5', + released: 0, + free: false, + limit: { context: 1000000, output: 8192 }, + }, + ], + }, + ] + + function selectModel(modelID: string) { + const modelState = { favorite: [], recent: [], variant: {} } + mocks.useModelSelection.mockReturnValue({ + model: { providerID: 'anthropic', modelID }, + modelString: `anthropic/${modelID}`, + info: undefined, + activeAgent: null, + recentModels: [], + favoriteModels: [], + configured: null, + modelState, + setModel: vi.fn(), + setActiveAgent: vi.fn(), + toggleFavorite: vi.fn(), + removeRecentModel: vi.fn(), + isModelReady: true, + }) + mocks.useModelSections.mockReturnValue({ + providers: limitedProviders, + sections: buildModelSections(limitedProviders, modelState, null), + defaultModel: null, + modelState, + isLoading: false, + }) + } + + it('formats the context limit like the context strip', () => { + selectModel('claude-sonnet-4') + render() + + expect(screen.getByText('Anthropic · 200k context', { selector: 'p' })).toBeInTheDocument() + }) + + it('formats million-token limits without a trailing decimal', () => { + selectModel('claude-opus-5-5') + render() + + expect(screen.getByText('Anthropic · 1M context', { selector: 'p' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/model/ModelQuickSelect.tsx b/frontend/src/components/model/ModelQuickSelect.tsx index 9d2d69c44..24e9dc94a 100644 --- a/frontend/src/components/model/ModelQuickSelect.tsx +++ b/frontend/src/components/model/ModelQuickSelect.tsx @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ChevronLeft, ChevronRight, Search, Star, Trash2, X, type LucideIcon } from 'lucide-react' import { useModelSelection, type ModelSelectionSession } from '@/hooks/useModelSelection' import { useMobile } from '@/hooks/useMobile' @@ -14,8 +14,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { ContextUsageMeter } from '@/components/session/ContextUsageMeter' import { buildModelSections, filterModelSections, type ModelOption } from '@/lib/modelSections' import { isTextEntryElement } from '@/lib/domTargets' +import { + CONTEXT_USAGE_TEXT_CLASS, + contextUsageAccessibleLabel, + contextUsageTone, + formatCompactTokens, + type ContextUsage, +} from '@/lib/contextUsage' interface ModelQuickSelectProps { directory?: string @@ -24,6 +32,9 @@ interface ModelQuickSelectProps { open?: boolean onOpenChange?: (open: boolean) => void session?: ModelSelectionSession + contextUsage?: ContextUsage | null + isCompacting?: boolean + onCompact?: () => void } interface ModelListItem { @@ -70,6 +81,46 @@ function formatModelCount(count: number) { return `${count} ${count === 1 ? 'model' : 'models'}` } +interface ContextUsageStripProps { + usage: ContextUsage + onCompact?: () => void + compactDisabled: boolean +} + +function ContextUsageStrip({ usage, onCompact, compactDisabled }: ContextUsageStripProps) { + const percentage = usage.usagePercentage + const toneClass = CONTEXT_USAGE_TEXT_CLASS[contextUsageTone(percentage)] + const summary = usage.contextLimit === null || percentage === null + ? `${formatCompactTokens(usage.totalTokens)} tokens` + : `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.contextLimit)} · ${percentage}%` + + return ( +
+ +
+ + {summary} + + {onCompact && ( + + )} +
+
+ ) +} + function VirtualizedList({ items, itemHeight, @@ -169,13 +220,16 @@ function VirtualizedList({ ) } -export function ModelQuickSelect({ +export const ModelQuickSelect = memo(function ModelQuickSelect({ directory, disabled, children, open, onOpenChange, session, + contextUsage, + isCompacting = false, + onCompact, }: ModelQuickSelectProps) { const [internalIsOpen, setInternalIsOpen] = useState(false) const isOpen = open ?? internalIsOpen @@ -410,11 +464,15 @@ export function ModelQuickSelect({ setSearchQuery('') } + const handleCompact = () => { + onCompact?.() + handleOpenChange(false) + } + const getDescription = (item: ModelListItem) => { const context = item.model?.limit?.context if (context) { - const formattedContext = context >= 1000000 ? `${(context / 1000000).toFixed(1)}M` : context.toLocaleString() - return `${item.providerName} · ${formattedContext} context` + return `${item.providerName} · ${formatCompactTokens(context)} context` } return item.providerName @@ -576,6 +634,13 @@ export function ModelQuickSelect({ className="z-[300] border-border bg-popover text-foreground shadow-2xl md:mx-auto md:max-w-lg" ariaLabel="Select model" > + {contextUsage && ( + + )}
{showAllModels ? ( <> @@ -736,4 +801,4 @@ export function ModelQuickSelect({ ) -} +}) diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx index a31d6bf94..05ca0cf67 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -11,7 +11,7 @@ import { useSessionPins } from '@/hooks/useSessionPins' import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { - NewSessionButton, + LocalRepoNewSessionButton, RepoNavGroup, RepoSessionNavList, SearchClearButton, @@ -25,7 +25,7 @@ import { isCurrentSessionItem, isRepoReady, } from '@/components/navigation/sidebar-session-tree' -import { getRepoPath, getSessionPath } from '@/lib/navigation' +import { getRepoPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' import { buildPinnedSessionKeys } from '@/lib/sessionKey' @@ -124,12 +124,7 @@ function SessionSearchResults({ onOpenRepo={() => navigate(getRepoPath(group.repo.id))} indicator={} actions={ - getSessionPath(group.repo.id, sessionId)} - onOpenSession={navigate} - /> + } > {group.items.map((item) => ( @@ -199,12 +194,7 @@ function LocalSessionTree() { onOpenRepo={(repoId) => navigate(getRepoPath(repoId))} onSelectSession={navigate} renderActions={(repo) => ( - getSessionPath(repo.id, sessionId)} - onOpenSession={navigate} - /> + )} /> )} diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index a52b6d721..e1c29c285 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -11,6 +11,9 @@ import * as useAuthModule from '@/hooks/useAuth' vi.mock('@/hooks/useDesktop') vi.mock('@/hooks/useSidebarCollapsed') vi.mock('@/hooks/useAuth') +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), +})) vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServers: () => ({ servers: [], isLoading: false }), useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), @@ -18,6 +21,10 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ vi.mock('@/components/navigation/DesktopSessionTree', () => ({ DesktopSessionTree: () =>
, })) +vi.mock('@/components/session/SessionPickerDialog', () => ({ + SessionPickerDialog: ({ open }: { open: boolean }) => + open ?
: null, +})) function LocationDisplay() { const location = useLocation() @@ -166,7 +173,7 @@ describe('DesktopSidebar', () => { for (const label of ['Home', 'Settings', 'Logout']) { expect(screen.getByText(label)).toBeInTheDocument() } - for (const label of ['Menu', 'Repos', 'Files', 'MCP', 'Skills', 'Schedules', 'All Schedules', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) { + for (const label of ['Menu', 'Files', 'MCP', 'Skills', 'Schedules', 'All Schedules', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) { expect(screen.queryByText(label)).toBeNull() } }) @@ -263,7 +270,7 @@ describe('DesktopSidebar', () => { expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() }) - it('renders home, repos and account items and no session tree when collapsed', () => { + it('renders home, sessions and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -276,8 +283,26 @@ describe('DesktopSidebar', () => { expect(screen.queryByTestId('session-tree')).toBeNull() expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.queryByText('Files')).toBeNull() }) + + it('opens the sessions picker dialog from the collapsed Sessions item', () => { + vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) + vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) + vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper(['/repos/5']) }) + + expect(screen.queryByTestId('sessions-picker-dialog')).toBeNull() + + fireEvent.click(screen.getByText('Sessions')) + + expect(screen.getByTestId('sessions-picker-dialog')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 2303c5e34..62b19cfb4 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -6,8 +6,10 @@ import { useSidebarWidth } from '@/hooks/useSidebarWidth' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { useOpenNavItem } from '@/hooks/useOpenNavItem' +import { useSessionPickerRouteScope } from '@/hooks/useSessionPickerRouteScope' +import { useSessionPickerNavigation } from '@/hooks/useSessionPickerNavigation' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' -import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { SessionPickerDialog } from '@/components/session/SessionPickerDialog' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' import { @@ -15,10 +17,28 @@ import { SidebarSection, SidebarItem, } from '@/components/ui/sidebar' -import { FolderGit2 } from 'lucide-react' +import { List } from 'lucide-react' const FOOTER_ITEM_KEYS = new Set(['home', 'settings', 'logout']) +function DesktopSessionsPicker({ onDismiss }: { onDismiss: () => void }) { + const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(true) + const pickerNavigation = useSessionPickerNavigation({ mode: 'push', onClose: onDismiss }) + + return ( + { + if (!nextOpen) onDismiss() + }} + currentRepo={currentRepo} + activeSessionID={activeSessionID} + {...pickerNavigation} + remote={remote} + /> + ) +} + export function DesktopSidebar() { const location = useLocation() const navigate = useNavigate() @@ -26,7 +46,7 @@ export function DesktopSidebar() { const openNavItem = useOpenNavItem() const [collapsed, toggle] = useSidebarCollapsed() const [sidebarWidth, setSidebarWidth] = useSidebarWidth() - const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) + const [sessionsOpen, setSessionsOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() const isDesktop = useDesktop() @@ -51,15 +71,15 @@ export function DesktopSidebar() { p.set('settingsTab', 'account') p.delete('mobileTab') }, 'push') - } else if (item.key === 'repos') { - setRepoSwitcherOpen(true) + } else if (item.key === 'sessions') { + setSessionsOpen(true) } } const footerItems = items.filter((item) => FOOTER_ITEM_KEYS.has(item.key)) const collapsedItems: MoreDrawerItem[] = [ footerItems[0], - { key: 'repos', label: 'Repos', icon: FolderGit2 }, + { key: 'sessions', label: 'Sessions', icon: List }, ...footerItems.slice(1), ] @@ -133,10 +153,7 @@ export function DesktopSidebar() { )} - setRepoSwitcherOpen(false)} - /> + {sessionsOpen && setSessionsOpen(false)} />} ) } diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index f6be0c2f0..c9aebae57 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -52,7 +52,7 @@ function SidebarServerRouteSync() { return null } -function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { +function renderNav(pathname = '/', variant?: 'sidebar' | 'rail') { return render( @@ -253,18 +253,4 @@ describe('LinkedServersNav', () => { expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() }) - - it('renders a thin trigger row in the sheet variant', async () => { - const user = userEvent.setup() - setServers([kitchen]) - - renderNav('/', 'sheet') - - expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - - expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() - expect(screen.getByTestId('location')).toHaveTextContent('/') - }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 6ff7ba7e6..809732f34 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -20,10 +20,10 @@ import { cn } from '@/lib/utils' const LOCAL_SERVER_LABEL = 'OC Manager' -export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' | 'header' +type LinkedServersVariant = 'sidebar' | 'rail' | 'header' interface LinkedServersNavProps { - /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `sheet` the mobile sheet row, `header` a compact page-header trigger. */ + /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `header` a compact page-header trigger. */ variant?: LinkedServersVariant } @@ -220,7 +220,7 @@ export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', isRail && 'mx-auto h-9 w-9 justify-center', variant === 'header' && 'h-8 min-w-0 max-w-[45vw] gap-2 px-2 sm:max-w-xs', - (variant === 'sidebar' || variant === 'sheet') && 'h-9 w-full gap-2 px-2', + variant === 'sidebar' && 'h-9 w-full gap-2 px-2', )} > diff --git a/frontend/src/components/navigation/MobileSheetHost.test.tsx b/frontend/src/components/navigation/MobileSheetHost.test.tsx index 1d425d251..be8194c27 100644 --- a/frontend/src/components/navigation/MobileSheetHost.test.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.test.tsx @@ -8,9 +8,9 @@ vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: ({ isOpen, basePath, repoName }: { isOpen: boolean; basePath: string; repoName: string }) => isOpen ?
FileBrowserSheet
: null, })) -vi.mock('@/components/navigation/RepoQuickSwitchSheet', () => ({ - RepoQuickSwitchSheet: ({ isOpen }: { isOpen: boolean }) => - isOpen ?
RepoQuickSwitchSheet
: null, +vi.mock('@/components/session/SessionPickerSheet', () => ({ + SessionPickerSheet: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
SessionPickerSheet
: null, })) vi.mock('@/components/navigation/NotificationsSheet', () => ({ NotificationsSheet: ({ isOpen }: { isOpen: boolean }) => @@ -58,18 +58,18 @@ describe('MobileSheetHost', () => { expect(container.firstChild).toBeNull() }) - it('renders RepoQuickSwitchSheet when mobileTab=repos', () => { + it('renders SessionPickerSheet when mobileTab=sessions', () => { vi.mocked(useMobileTabBar).mockReturnValue({ - openSheet: 'repos', + openSheet: 'sessions', open: vi.fn(), close: vi.fn(), }) render( - + , ) - expect(screen.getByTestId('repo-quick-switch-sheet')).toBeInTheDocument() + expect(screen.getByTestId('session-picker-sheet')).toBeInTheDocument() }) it('renders FileBrowserSheet with correct props when mobileTab=files', () => { @@ -134,15 +134,15 @@ describe('MobileSheetHost', () => { it('closes sheet when onClose is called', () => { const mockClose = vi.fn() vi.mocked(useMobileTabBar).mockReturnValue({ - openSheet: 'repos', + openSheet: 'sessions', open: vi.fn(), close: mockClose, }) render( - + , ) - expect(screen.getByTestId('repo-quick-switch-sheet')).toBeInTheDocument() + expect(screen.getByTestId('session-picker-sheet')).toBeInTheDocument() }) }) diff --git a/frontend/src/components/navigation/MobileSheetHost.tsx b/frontend/src/components/navigation/MobileSheetHost.tsx index 4134cd7f0..37499fa88 100644 --- a/frontend/src/components/navigation/MobileSheetHost.tsx +++ b/frontend/src/components/navigation/MobileSheetHost.tsx @@ -1,7 +1,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useMobile } from '@/hooks/useMobile' import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' -import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { SessionPickerSheet } from '@/components/session/SessionPickerSheet' import { NotificationsSheet } from '@/components/navigation/NotificationsSheet' import { MoreDrawer } from '@/components/navigation/MoreDrawer' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' @@ -15,7 +15,7 @@ export function MobileSheetHost() { return ( <> - + {openSheet === 'files' && !isRemoteRoute && ( { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Git')).toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Files', 'Git', 'Assistant', 'More']) + expect(labels).toEqual(['Sessions', 'Files', 'Git', 'Assistant', 'More']) }) it('opens the source control dialog when Git is clicked on repo detail path', async () => { @@ -109,7 +109,7 @@ describe('MobileTabBar', () => { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Assistant')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() }) @@ -226,7 +226,7 @@ describe('MobileTabBar', () => { , ) const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Git', 'Assistant', 'More']) + expect(labels).toEqual(['Sessions', 'Git', 'Assistant', 'More']) expect(screen.queryByText('Files')).not.toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() }) @@ -255,7 +255,7 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') }) - it('opens the repos sheet from the Repos tab on a linked server project page', async () => { + it('opens the sessions sheet from the Sessions tab on a linked server project page', async () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() const user = userEvent.setup() @@ -273,8 +273,8 @@ describe('MobileTabBar', () => { , ) - await user.click(screen.getByRole('button', { name: 'Repos' })) - expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=repos') + await user.click(screen.getByRole('button', { name: 'Sessions' })) + expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=sessions') }) it('does not render tab bar on a linked server session path', () => { @@ -316,7 +316,7 @@ describe('MobileTabBar', () => {
, ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Files')).toBeInTheDocument() expect(screen.getByText('Assistant')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() @@ -336,7 +336,7 @@ describe('MobileTabBar', () => { , ) const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) + expect(labels).toEqual(['Sessions', 'Assistant', 'Schedules', 'More']) expect(screen.queryByText('Files')).not.toBeInTheDocument() }) @@ -350,11 +350,11 @@ describe('MobileTabBar', () => { , ) - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.getByText('Schedules')).toBeInTheDocument() }) - it('Repos tab is active when pathname is / and no sheet is open', () => { + it('Sessions tab is active when pathname is / and no sheet is open', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() render( @@ -364,24 +364,24 @@ describe('MobileTabBar', () => { , ) - const reposButton = screen.getByText('Repos').closest('button') - expect(reposButton).toHaveClass('text-primary') - expect(reposButton).toHaveClass('border-primary') + const sessionsButton = screen.getByText('Sessions').closest('button') + expect(sessionsButton).toHaveClass('text-primary') + expect(sessionsButton).toHaveClass('border-primary') }) - it('Repos tab is active when openSheet is repos regardless of pathname', () => { + it('Sessions tab is active when openSheet is sessions regardless of pathname', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() render( - + , ) - const reposButton = screen.getByText('Repos').closest('button') - expect(reposButton).toHaveClass('text-primary') - expect(reposButton).toHaveClass('border-primary') + const sessionsButton = screen.getByText('Sessions').closest('button') + expect(sessionsButton).toHaveClass('text-primary') + expect(sessionsButton).toHaveClass('border-primary') }) it('maintains stable callbacks when search changes but mobileTab does not', () => { @@ -396,8 +396,8 @@ describe('MobileTabBar', () => { , ) - const firstReposButton = screen.getByText('Repos').closest('button') - expect(firstReposButton).toBeInTheDocument() + const firstSessionsButton = screen.getByText('Sessions').closest('button') + expect(firstSessionsButton).toBeInTheDocument() rerender( @@ -407,8 +407,8 @@ describe('MobileTabBar', () => { , ) - const secondReposButton = screen.getByText('Repos').closest('button') - expect(secondReposButton).toBeInTheDocument() + const secondSessionsButton = screen.getByText('Sessions').closest('button') + expect(secondSessionsButton).toBeInTheDocument() }) it('does not render tab bar on SessionDetail path /repos/:id/sessions/:sid', () => { diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 7fba4cf33..ccc97af36 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { FolderGit2, FolderOpen, CalendarClock, Menu, Info, History, Bot, GitCommitHorizontal } from 'lucide-react' +import { List, FolderOpen, CalendarClock, Menu, Info, History, Bot, GitCommitHorizontal } from 'lucide-react' import { cn } from '@/lib/utils' import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' @@ -112,11 +112,11 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR return [ { - key: 'repos', - label: 'Repos', - icon: FolderGit2, - onClick: () => open('repos'), - active: openSheet === 'repos' || (pathname === '/' && !openSheet), + key: 'sessions', + label: 'Sessions', + icon: List, + onClick: () => open('sessions'), + active: openSheet === 'sessions' || (pathname === '/' && !openSheet), }, ...(showsFiles ? [{ diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index 60f1bc641..04903a1b2 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -323,28 +323,28 @@ describe('MoreDrawer', () => { expect(navigateMock).toHaveBeenCalledWith('/repos/1/schedules?returnTo=%2Frepos%2F1%2Fsessions%2Fsession-1%3Fassistant%3D1') }) - it('swaps the menu for the Repos sheet from a session', () => { + it('swaps the menu for the Sessions sheet from a session', () => { const navigateMock = vi.fn() vi.mocked(useNavigate).mockReturnValue(navigateMock) mockAuth() mockServerHealth() renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1?mobileTab=more', routePath: '/repos/:id/sessions/:sessionId' }) - fireEvent.click(screen.getByText('Repos')) + fireEvent.click(screen.getByText('Sessions')) expect(navigateMock).toHaveBeenCalledWith( - { search: 'mobileTab=repos' }, + { search: 'mobileTab=sessions' }, { state: { urlParamHistory: { previousSearch: '?mobileTab=more' } } }, ) }) - it('keeps Home and hides the Repos row outside a session', () => { + it('keeps Home and hides the Sessions row outside a session', () => { mockAuth() mockServerHealth() renderMoreDrawer({ initialEntry: '/repos/1', routePath: '/repos/:id' }) expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.queryByText('Repos')).not.toBeInTheDocument() + expect(screen.queryByText('Sessions')).not.toBeInTheDocument() }) it('offers the Assistant from a repo session', () => { @@ -420,7 +420,7 @@ describe('MoreDrawer', () => { expect(screen.getByText('Logout')).toBeInTheDocument() expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument() - expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Sessions')).toBeInTheDocument() expect(screen.queryByText('Files')).not.toBeInTheDocument() expect(screen.getByText('Source Control')).toBeInTheDocument() expect(screen.queryByText('Preview')).not.toBeInTheDocument() diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index eb865eba9..e8f4da899 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -1,6 +1,6 @@ import { useNavigate, useLocation, useParams } from 'react-router-dom' import { useRef, useEffect, type ReactNode } from 'react' -import { ChevronRight, FolderGit2, X, GitBranch, type LucideIcon } from 'lucide-react' +import { ChevronRight, List, X, GitBranch, type LucideIcon } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' import { useUrlParams } from '@/hooks/useUrlParams' @@ -211,7 +211,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { )} {(isSessionDetail || isRemoteSessionDetail) && ( - openMobileSheet('repos')} trailing /> + openMobileSheet('sessions')} trailing /> )} {assistantCta?.to && ( navigate(assistantCta.to!)} /> diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx index 33b0181b2..1e12c8d68 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { OpenCodeProject } from '@/api/opencode' @@ -21,7 +21,10 @@ const mocks = vi.hoisted(() => ({ useOpenCodeServerStatuses: vi.fn(), useSessionsAcrossDirectories: vi.fn(), useCreateSession: vi.fn(), - sessionsCalls: [] as Array<{ directories: string[]; options?: { serverId?: string } }>, + sessionsCalls: [] as Array<{ + directories: string[] + options?: { serverId?: string; allDirectories?: boolean; limit?: number } + }>, createCalls: [] as Array<{ directory?: string; serverId?: string }>, sessions: [] as SessionFixture[], hasNextPage: { current: false }, @@ -43,11 +46,13 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { ...actual, useSessionsAcrossDirectories: ( directories: string[], - options?: { serverId?: string }, + options?: { serverId?: string; allDirectories?: boolean; limit?: number }, ) => { mocks.sessionsCalls.push({ directories, options }) return { - data: mocks.sessions.filter((session) => directories.includes(session.location.directory)), + data: options?.allDirectories + ? mocks.sessions + : mocks.sessions.filter((session) => directories.includes(session.location.directory)), isLoading: false, isError: false, hasNextPage: mocks.hasNextPage.current, @@ -87,14 +92,19 @@ const kitchen = { updatedAt: 2, } -function createProject(id: string, name: string | undefined, canonical: string): OpenCodeProject { +function createProject( + id: string, + name: string | undefined, + canonical: string, + time: OpenCodeProject['time'] = { created: 1, updated: 2, active: 0 }, +): OpenCodeProject { return { id, canonical, name, - time: { created: 1, updated: 2 }, + time, sandboxes: [], - } as OpenCodeProject + } } function createSession(id: string, directory: string, updated = Date.now()): SessionFixture { @@ -117,10 +127,12 @@ function LocationSpy() { return
{`${pathname}${search}`}
} -function createWrapper(initialEntry: string) { - const queryClient = new QueryClient({ +function createWrapper( + initialEntry: string, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }) + }), +) { return ({ children }: { children: React.ReactNode }) => ( @@ -187,6 +199,28 @@ describe('RemoteProjectNavList', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) + it('orders projects by their newest session and ignores the project activity time', async () => { + mocks.listProjects.mockResolvedValue([ + createProject('p1', 'Alpha', '/projects/alpha', { created: 1, updated: 900, active: 999 }), + createProject('p2', 'Beta', '/projects/beta', { created: 1, updated: 100, active: 1 }), + ]) + mocks.sessions = [createSession('b1', '/projects/beta', 500)] + + render(, { wrapper: createWrapper('/') }) + + await screen.findByText('Beta') + const toggles = screen + .getAllByRole('button', { name: /^Show sessions in / }) + .map((button) => button.getAttribute('aria-label')) + expect(toggles).toEqual(['Show sessions in Beta', 'Show sessions in Alpha']) + expect( + mocks.sessionsCalls.filter((call) => call.options?.allDirectories === true), + ).toHaveLength(1) + expect( + mocks.sessionsCalls.find((call) => call.options?.allDirectories === true)?.options, + ).toMatchObject({ serverId: 'srv_kitchen', limit: 100 }) + }) + it('expands the project of the current project route and loads its sessions', async () => { render(, { wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), @@ -287,6 +321,28 @@ describe('RemoteProjectNavList', () => { expect(await screen.findByText('Could not load projects')).toBeInTheDocument() }) + it('keeps the cached project list when a refetch fails', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + mocks.listProjects.mockResolvedValueOnce([alpha, beta]).mockRejectedValueOnce(new Error('offline')) + + render(, { + wrapper: createWrapper('/', queryClient), + }) + + await screen.findByText('Alpha') + await act(async () => { + await queryClient.refetchQueries() + }) + + await waitFor(() => { + expect(mocks.listProjects).toHaveBeenCalledTimes(2) + }) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('Could not load projects')).not.toBeInTheDocument() + }) + it('reports an unreachable server on a failed project load', async () => { mocks.listProjects.mockRejectedValue(new Error('offline')) mocks.useOpenCodeServerStatuses.mockReturnValue({ diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx index d3b733911..e1c8f764e 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -1,21 +1,18 @@ import { useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { listProjects, type OpenCodeProject } from '@/api/opencode' +import { remoteProjectName, type OpenCodeProject } from '@/api/opencode' import type { Session } from '@/api/types' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' -import { useSessionsAcrossDirectories, sessionQueryOptions } from '@/hooks/useOpenCode' +import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode' +import { useActiveRemoteDirectory } from '@/hooks/useActiveRemoteDirectory' +import { useRemoteProjectsByActivity } from '@/hooks/useRemoteProjects' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' import { useProjectActivityState } from '@/hooks/useActiveWork' import { useSessionSearch } from '@/hooks/useSessionSearch' -import { serverScopedKey } from '@/lib/serverScope' import { getServerProjectPath, getServerSessionPath, - parseServerProjectRoute, - parseServerSessionRoute, } from '@/lib/navigation' -import { getDirectoryName } from '@/lib/utils' import { getSessionKey } from '@/lib/sessionKey' import { NewSessionButton, @@ -31,10 +28,6 @@ import { type SidebarSessionItem, } from '@/components/navigation/sidebar-session-tree' -function projectName(project: OpenCodeProject): string { - return project.name?.trim() || getDirectoryName(project.canonical) -} - function buildRemoteItem(session: Session, serverId: string, projectLabel: string): SidebarSessionItem { return { key: getSessionKey(session), @@ -55,20 +48,6 @@ function RemoteProjectActivityIndicator({ serverId, directory }: { serverId: str return } -function useActiveRemoteDirectory(serverId: string): string | null { - const { pathname, search } = useLocation() - const projectRoute = parseServerProjectRoute(pathname, search) - const sessionRoute = parseServerSessionRoute(pathname) - const sessionID = sessionRoute?.serverId === serverId ? sessionRoute.sessionId : undefined - const { data: session } = useQuery({ - ...sessionQueryOptions(sessionID, undefined, serverId), - enabled: Boolean(sessionID), - }) - - if (projectRoute?.serverId === serverId) return projectRoute.directory - return session?.location.directory ?? null -} - interface RemoteSessionRowsProps { serverId: string projectLabel: string @@ -135,7 +114,7 @@ function RemoteProjectSessions({ serverId, project, onSelectSession }: RemotePro return ( projects.filter( (project) => - projectName(project).toLowerCase().includes(needle) || + remoteProjectName(project).toLowerCase().includes(needle) || project.canonical.toLowerCase().includes(needle), ), [projects, needle], @@ -342,7 +321,7 @@ function RemoteProjectSearchResults({ {matchedSessions ? ( candidate.id === serverId) - const activeDirectory = useActiveRemoteDirectory(serverId) - - const projectsQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'projects'], serverId), - queryFn: () => listProjects(serverId), - enabled: server?.enabled === true, - retry: false, - }) + const { directory: activeDirectory } = useActiveRemoteDirectory(serverId) - const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + const { projects, isLoading: projectsLoading, isError: projectsError, hasData: hasProjects } = + useRemoteProjectsByActivity(serverId) const trimmedSearch = search.trim() if (isServersLoading) return Loading projects... if (!server) return Server not found if (!server.enabled) return Server is disabled - if (projectsQuery.isLoading) return Loading projects... - if (projectsQuery.isError) { + if (projectsLoading) return Loading projects... + if (projectsError && !hasProjects) { return ( {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx deleted file mode 100644 index 6d2260c13..000000000 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ /dev/null @@ -1,1128 +0,0 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { beforeEach, describe, it, expect, vi } from 'vitest' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' -import { getRepoSiblings, listRepos } from '@/api/repos' -import { stubMatchMedia } from '@/test/test-utils' -import { listProjects, type OpenCodeProject } from '@/api/opencode' -import { useMobileTabBar } from '@/hooks/useMobileTabBar' -import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { useSidebarServerStore } from '@/stores/sidebarServerStore' -import type { ActiveWorkItem } from '@/lib/activeWork' - -type SessionFixture = { - id: string - projectID: string - title: string - time: { created: number; updated: number } - location: { directory: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } -} - -const { sessionsData, sessionsHookCalls, permissionSessions, createSessionDirectories } = vi.hoisted(() => ({ - sessionsData: [] as Array<{ - id: string - projectID: string - title: string - time: { created: number; updated: number } - location: { directory: string } - cost: number - tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - }>, - sessionsHookCalls: [] as string[][], - permissionSessions: { current: new Set() }, - createSessionDirectories: [] as string[], -})) - -const activityMocks = vi.hoisted(() => ({ - useActiveWork: vi.fn(), - useActivityWindowMinutes: vi.fn(), -})) - -const serverMocks = vi.hoisted(() => ({ - useOpenCodeServers: vi.fn(), - useOpenCodeServerStatuses: vi.fn(), -})) - -vi.mock('@/hooks/useOpenCodeServers', () => ({ - useOpenCodeServers: serverMocks.useOpenCodeServers, - useOpenCodeServerStatuses: serverMocks.useOpenCodeServerStatuses, -})) - -vi.mock('@/hooks/useActiveWork', () => ({ - useActiveWork: activityMocks.useActiveWork, - useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, - useRepoActivityState: () => null, - useServerActivityState: () => null, - useLocalActivityState: () => null, - useProjectActivityState: () => null, -})) - -vi.mock('@/api/repos') - -vi.mock('@/api/opencode', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, listProjects: vi.fn() } -}) - -vi.mock('@/contexts/EventContext', () => ({ - usePermissions: () => ({ - hasForSession: (id: string) => permissionSessions.current.has(id), - }), - useForms: () => ({ hasForSession: () => false }), -})) - -vi.mock('@/hooks/useOpenCode', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useSessionsAcrossDirectories: (directories: string[]) => { - sessionsHookCalls.push(directories) - return { data: sessionsData, isLoading: false, isError: false } - }, - useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ - mutate: () => { - createSessionDirectories.push(directory) - onSuccess?.({ id: 'new-session' }) - }, - }), - } -}) - -vi.mock('@/hooks/useSessionPins', () => ({ - useSessionPins: () => ({ data: [] }), -})) - -function createSession(id: string, updated: number, directory: string): SessionFixture { - return { - id, - projectID: 'proj-1', - title: `Session ${id}`, - time: { created: updated - 10000, updated }, - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } -} - -function createRepo(id: number, name: string, fullPath: string) { - return { - id, - name, - repoUrl: `https://github.com/test/${name}.git`, - localPath: fullPath, - fullPath, - sourcePath: null, - currentBranch: 'main', - defaultBranch: 'main', - cloneStatus: 'ready' as const, - clonedAt: 0, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } -} - -function createActiveWorkItem(): ActiveWorkItem { - const session = createSession('s1', Date.now(), '/path/to/repo1') - return { - key: '1:s1', - state: 'recent', - session, - repo: createRepo(1, 'repo1', '/path/to/repo1'), - updatedAt: session.time.updated, - path: '/repos/1/sessions/s1', - } -} - -function setActivityGroups(groups: { - needsInput?: ActiveWorkItem[] - running?: ActiveWorkItem[] - recent?: ActiveWorkItem[] -}) { - activityMocks.useActiveWork.mockReturnValue({ - groups: { - needsInput: groups.needsInput ?? [], - running: groups.running ?? [], - recent: groups.recent ?? [], - }, - isLoading: false, - isError: false, - }) -} - -function createLinkedServer(id: string, name: string, enabled = true) { - return { - id, - name, - baseUrl: `https://${id}.example.com`, - username: 'opencode', - enabled, - hasPassword: true, - createdAt: 1, - updatedAt: 2, - } -} - -function createProject(id: string, name: string, canonical: string): OpenCodeProject { - return { - id, - canonical, - name, - time: { created: 1, updated: 2 }, - sandboxes: [], - } as OpenCodeProject -} - -function setLinkedServers(servers: Array>) { - serverMocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) - serverMocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: Object.fromEntries( - servers.map((server) => [ - server.id, - { serverId: server.id, connected: true, version: null, error: null }, - ]), - ), - isLoading: false, - }) -} - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }) - return ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ) -} - -function LocationSpy() { - const { pathname, search } = useLocation() - return
{`${pathname}${search}`}
-} - -function MobileSheetHarness() { - const { close } = useMobileTabBar() - return ( - <> - - - - ) -} - -describe('RepoQuickSwitchSheet', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.clear() - useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) - vi.mocked(getRepoSiblings).mockResolvedValue([]) - vi.mocked(listProjects).mockResolvedValue([]) - sessionsData.splice(0, sessionsData.length) - sessionsHookCalls.splice(0, sessionsHookCalls.length) - permissionSessions.current.clear() - activityMocks.useActivityWindowMinutes.mockReturnValue(30) - setActivityGroups({}) - setLinkedServers([]) - stubMatchMedia(true) - }) - - it('renders with loading state', async () => { - vi.mocked(listRepos).mockImplementation(() => new Promise(() => {})) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByPlaceholderText('Search repos...')).toBeInTheDocument() - }) - }) - - it('renders empty state when no repos', async () => { - vi.mocked(listRepos).mockResolvedValue([]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('No repos found')).toBeInTheDocument() - }) - }) - - it('renders repo list and filters on search', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 2, - repoUrl: 'https://github.com/test/repo2.git', - localPath: '/path/to/repo2', - sourcePath: null, - currentBranch: 'develop', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - const input = screen.getByPlaceholderText('Search repos...') - fireEvent.change(input, { target: { value: 'repo1' } }) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.queryByText('repo2')).not.toBeInTheDocument() - }) - }) - - it('distinguishes worktrees of the same repo by branch', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 2, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1/.worktrees/feature-a', - sourcePath: null, - currentBranch: 'feature-a', - isWorktree: true, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 3, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1/.worktrees/feature-b', - sourcePath: null, - currentBranch: 'feature-b', - isWorktree: true, - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getAllByText('repo1')).toHaveLength(3) - expect(screen.getByText('feature-a')).toBeInTheDocument() - expect(screen.getByText('feature-b')).toBeInTheDocument() - }) - }) - - it('shows assistant as the first repo option', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: ASSISTANT_REPO_ID, - repoUrl: 'Assistant', - localPath: '/assistant', - sourcePath: null, - currentBranch: null, - isLocal: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - const repoNames = screen.getAllByText(/^(Assistant|repo1)$/).map((node) => node.textContent) - expect(repoNames).toEqual(['Assistant', 'repo1']) - }) - - it('navigates on repo click and closes sheet', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - , - { wrapper: createWrapper() }, - ) - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - fireEvent.click(screen.getByText('repo1')) - expect(handleClose).toHaveBeenCalled() - }) - - it('navigates to assistant when mobileTabAction is assistant', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/assistant') - expect(handleClose).not.toHaveBeenCalled() - }) - - it('navigates from assistant to repo detail when clicking repo on canonical assistant route', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') - expect(handleClose).not.toHaveBeenCalled() - }) - - it('navigates from legacy assistant route to repo detail when clicking repo', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') - expect(handleClose).not.toHaveBeenCalled() - }) - - it('does not mark any repo active on /assistant', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 2, - repoUrl: 'https://github.com/test/repo2.git', - localPath: '/path/to/repo2', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - expect(screen.queryByRole('button', { current: 'page' })).toBeNull() - }) - - it('does not mark any repo active on legacy /repos/1/assistant', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 2, - repoUrl: 'https://github.com/test/repo2.git', - localPath: '/path/to/repo2', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - const handleClose = vi.fn() - render( - - - - - - - - } - /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - expect(screen.queryByRole('button', { current: 'page' })).toBeNull() - }) - - it('switches repos from the mobile repos sheet without navigating back to the previous repo', async () => { - vi.mocked(listRepos).mockResolvedValue([ - { - id: 1, - repoUrl: 'https://github.com/test/repo1.git', - localPath: '/path/to/repo1', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: 2, - repoUrl: 'https://github.com/test/repo2.git', - localPath: '/path/to/repo2', - sourcePath: null, - currentBranch: 'main', - isLocal: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]) - - render( - - - - } /> - - - , - ) - - await waitFor(() => { - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByText('repo2')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/2') - }) - - it('does not render sessions for collapsed repos and does not fetch them', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - expect(screen.getByText('repo2')).toBeInTheDocument() - }) - - expect(screen.queryByText('Session s1')).not.toBeInTheDocument() - expect(sessionsHookCalls.some((directories) => directories.includes('/path/to/repo1'))).toBe(false) - }) - - it('expands a repo to show its sessions', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - const toggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - - fireEvent.click(toggle) - - expect(await screen.findByText('Session s1')).toBeInTheDocument() - expect(toggle).toHaveAttribute('aria-expanded', 'true') - expect(sessionsHookCalls.some((directories) => directories.includes('/path/to/repo1'))).toBe(true) - }) - - it('navigates to a session when it is tapped', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - - - - } /> - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - fireEvent.click(await screen.findByText('Session s1')) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/s1') - }) - - it('expands the active repo by default and shows its sessions', async () => { - const now = Date.now() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', now, '/path/to/repo1')) - - render( - - - - } /> - - - , - ) - - expect(await screen.findByText('Session s1')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Show sessions in repo1' })).toHaveAttribute( - 'aria-expanded', - 'true', - ) - }) - - it('clears the search with the clear button', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - await waitFor(() => expect(screen.getByText('repo2')).toBeInTheDocument()) - expect(screen.queryByLabelText('Clear search')).toBeNull() - - fireEvent.change(input, { target: { value: 'repo1' } }) - expect(screen.queryByText('repo2')).toBeNull() - - fireEvent.click(screen.getByLabelText('Clear search')) - - expect(input).toHaveValue('') - expect(screen.getByText('repo2')).toBeInTheDocument() - expect(screen.queryByLabelText('Clear search')).toBeNull() - }) - - it('keeps only one repo expanded at a time', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render( - - - - } /> - - - , - ) - - const repo1Toggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - const repo2Toggle = screen.getByRole('button', { name: 'Show sessions in repo2' }) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'true') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('HERE')).toBeNull() - expect(screen.getByText('repo1').closest('button')).toHaveAttribute('aria-current', 'page') - - fireEvent.click(repo2Toggle) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'false') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'true') - - fireEvent.click(repo2Toggle) - - expect(repo1Toggle).toHaveAttribute('aria-expanded', 'false') - expect(repo2Toggle).toHaveAttribute('aria-expanded', 'false') - }) - - it('makes the expanded repo header sticky and leaves collapsed headers static', async () => { - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - - render( - - - - } /> - - - , - ) - - const expandedToggle = await screen.findByRole('button', { name: 'Show sessions in repo1' }) - const collapsedToggle = screen.getByRole('button', { name: 'Show sessions in repo2' }) - - expect(expandedToggle.parentElement).toHaveClass('sticky') - expect(collapsedToggle.parentElement).not.toHaveClass('sticky') - }) - - it('renders a pending permission indicator on a session row', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', Date.now(), '/path/to/repo1')) - permissionSessions.current.add('s1') - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - - expect(await screen.findByLabelText('Pending permission')).toBeInTheDocument() - }) - - it('renders the relative time on a session row', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - sessionsData.push(createSession('s1', Date.now() - 5 * 60_000, '/path/to/repo1')) - - render( - , - { wrapper: createWrapper() }, - ) - - fireEvent.click(await screen.findByRole('button', { name: 'Show sessions in repo1' })) - - expect(await screen.findByText('5m ago')).toBeInTheDocument() - }) - - it('renders a closing spacer at the end of the scroll area', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - - render( - , - { wrapper: createWrapper() }, - ) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(document.body.querySelector('[aria-hidden="true"].h-16')).not.toBeNull() - }) - - it('renders the Activity section above the repo list when there are items', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - const heading = screen.getByRole('heading', { name: 'Activity' }) - const repo = screen.getByText('repo1') - expect(screen.getByText('Session s1')).toBeInTheDocument() - expect(heading.compareDocumentPosition(repo) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() - }) - - it('hides the Activity section when there are no items', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({}) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() - expect(screen.queryByText('Session s1')).toBeNull() - }) - - it('hides the Activity section while a search query is entered', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - - render(, { wrapper: createWrapper() }) - - expect(await screen.findByRole('heading', { name: 'Activity' })).toBeInTheDocument() - - fireEvent.change(screen.getByPlaceholderText('Search repos...'), { target: { value: 'repo' } }) - - expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() - expect(screen.queryByText('Session s1')).toBeNull() - }) - - it('navigates to the item path and closes the sheet when an activity row is clicked', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setActivityGroups({ recent: [createActiveWorkItem()] }) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: /Session s1/ })) - - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/sessions/s1') - expect(handleClose).toHaveBeenCalled() - }) - - it('starts a new session in a repo, opens it and closes the sheet', async () => { - createSessionDirectories.splice(0, createSessionDirectories.length) - vi.mocked(listRepos).mockResolvedValue([ - createRepo(1, 'repo1', '/path/to/repo1'), - createRepo(2, 'repo2', '/path/to/repo2'), - ]) - const handleClose = vi.fn() - let pathname = '' - function LocationProbe() { - pathname = useLocation().pathname - return null - } - - render( - - - - - - , - ) - - fireEvent.click(await screen.findByRole('button', { name: 'New session in repo2' })) - - expect(createSessionDirectories).toEqual(['/path/to/repo2']) - expect(pathname).toBe('/repos/2/sessions/new-session') - expect(handleClose).toHaveBeenCalled() - }) - - it('renders the server switcher when enabled linked servers exist', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - setLinkedServers([ - createLinkedServer('srv_kitchen', 'Kitchen'), - createLinkedServer('srv_office', 'Office', false), - ]) - - render(, { wrapper: createWrapper() }) - - const trigger = await screen.findByRole('button', { name: 'Switch server, current: OC Manager' }) - expect(trigger).toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() - - await user.click(trigger) - - expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() - }) - - it('hides the server switcher when no linked servers exist', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - - render(, { wrapper: createWrapper() }) - - await waitFor(() => { - expect(screen.getByText('repo1')).toBeInTheDocument() - }) - - expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() - }) - - it('switches to a linked server without navigating or closing the sheet', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - - expect(await screen.findByText('Alpha')).toBeInTheDocument() - expect(screen.queryByText('repo1')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Add repository/ })).not.toBeInTheDocument() - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') - expect(handleClose).not.toHaveBeenCalled() - }) - - it('opens a remote project from the sheet and closes it', async () => { - const user = userEvent.setup() - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) - const handleClose = vi.fn() - - render( - - - - - - - - } - /> - - - , - ) - - await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) - await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) - await user.click(await screen.findByRole('button', { name: /^Alpha/ })) - - expect(screen.getByTestId('location')).toHaveTextContent( - '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', - ) - expect(handleClose).toHaveBeenCalled() - }) - - it('leaves the search input unfocused on touch screens so the keyboard stays closed', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - stubMatchMedia(false) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - expect(input).not.toHaveFocus() - }) - - it('focuses the search input when a fine pointer is available', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - stubMatchMedia(true) - - render(, { wrapper: createWrapper() }) - - const input = await screen.findByPlaceholderText('Search repos...') - await waitFor(() => expect(input).toHaveFocus()) - }) - - it('uses project wording for the search placeholder when a linked server is selected', async () => { - vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) - vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) - useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) - - render(, { wrapper: createWrapper() }) - - expect(await screen.findByPlaceholderText('Search projects...')).toBeInTheDocument() - }) -}) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx deleted file mode 100644 index 94d9c827d..000000000 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useState, useMemo } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' -import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' -import { Input } from '@/components/ui/input' -import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' -import { Button } from '@/components/ui/button' -import { CompactActivityList } from '@/components/activity/CompactActivityList' -import { getRepoDisplayName } from '@/lib/utils' -import { AddRepoDialog } from '@/components/repo/AddRepoDialog' -import { FolderGit2, Plus } from 'lucide-react' -import { useUrlParams } from '@/hooks/useUrlParams' -import { getAssistantPath, getRepoPath, getSessionPath } from '@/lib/navigation' -import { NewSessionButton, RepoSessionNavList, SearchClearButton } from '@/components/navigation/RepoSessionNav' -import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' -import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' -import { getActiveRepoId } from '@/components/navigation/sidebar-session-tree' -import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' -import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' - -interface RepoQuickSwitchSheetProps { - isOpen: boolean - onClose: () => void -} - -export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetProps) { - const navigate = useNavigate() - const location = useLocation() - const { searchParams } = useUrlParams() - const [searchQuery, setSearchQuery] = useState('') - const [addRepoOpen, setAddRepoOpen] = useState(false) - const [selectedServerId] = useSidebarServerSelection() - const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID - const activeRepoId = getActiveRepoId(location.pathname) - const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) - - const { repos, isLoading, refetch } = useNavigableRepos(isOpen) - - useRefreshOnOpen(isOpen, refetch) - - const filteredRepos = useMemo(() => { - if (!searchQuery.trim()) return repos - const query = searchQuery.toLowerCase() - return repos.filter((repo) => - getRepoDisplayName(repo).toLowerCase().includes(query) - ) - }, [repos, searchQuery]) - - const isUrlControlledSheet = searchParams.get('mobileTab') === 'repos' - - const navigateAndClose = (path: string, options?: { replace?: boolean }) => { - navigate(path, options) - if (!isUrlControlledSheet) { - onClose() - } - } - - const handleClick = (id: number) => { - if (searchParams.get('mobileTabAction') === 'assistant') { - navigateAndClose(getAssistantPath(), { replace: true }) - return - } - - if (id === activeRepoId) { - onClose() - return - } - - navigateAndClose(getRepoPath(id), { replace: true }) - } - - return ( - <> - - -
-
- setSearchQuery(e.target.value)} - autoFocus={hasFinePointer} - className="pr-9" - autoComplete="off" - name="repo-quick-switch" - /> - {searchQuery.length > 0 && setSearchQuery('')} />} -
- {!isRemote && ( - - )} -
-
- - {isOpen && ( -
- -
- )} - {isOpen && !searchQuery.trim() && ( - navigateAndClose(path)} /> - )} - {isRemote ? ( - navigateAndClose(path, { replace: true })} - onSelectSession={(path) => navigateAndClose(path)} - /> - ) : isLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ) : filteredRepos.length === 0 ? ( -
- -

No repos found

-
- ) : ( - navigateAndClose(path)} - renderActions={(repo) => ( - getSessionPath(repo.id, sessionId)} - onOpenSession={(path) => navigateAndClose(path)} - /> - )} - /> - )} - ) }) diff --git a/frontend/src/components/session/ReconnectingBanner.test.tsx b/frontend/src/components/session/ReconnectingBanner.test.tsx new file mode 100644 index 000000000..ee0baf2a3 --- /dev/null +++ b/frontend/src/components/session/ReconnectingBanner.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, act } from '@testing-library/react' +import { afterEach, describe, it, expect, vi } from 'vitest' +import { ReconnectingBanner } from './ReconnectingBanner' + +const GRACE_PERIOD_MS = 5000 + +describe('ReconnectingBanner', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('stays hidden during the initial grace period', () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(GRACE_PERIOD_MS - 1) + }) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('reports a stream that never connects once the grace period elapses', () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(GRACE_PERIOD_MS) + }) + + const banner = screen.getByRole('status') + expect(banner).toHaveTextContent('Not connected — live updates unavailable') + expect(banner).toHaveAttribute('aria-live', 'polite') + }) + + it('hides the not-connected banner as soon as the stream connects', () => { + vi.useFakeTimers() + const { rerender } = render() + + act(() => { + vi.advanceTimersByTime(GRACE_PERIOD_MS) + }) + expect(screen.getByRole('status')).toBeInTheDocument() + + rerender() + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('stays hidden while the stream is connected', () => { + vi.useFakeTimers() + render() + + act(() => { + vi.advanceTimersByTime(GRACE_PERIOD_MS * 2) + }) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('reports a dropped stream once it connected', () => { + vi.useFakeTimers() + const { rerender } = render() + + rerender() + + const banner = screen.getByRole('status') + expect(banner).toHaveTextContent('Reconnecting… live updates paused') + expect(banner).toHaveAttribute('aria-live', 'polite') + }) + + it('keeps the reconnect message when the drop outlasts the grace period', () => { + vi.useFakeTimers() + const { rerender } = render() + + rerender() + act(() => { + vi.advanceTimersByTime(GRACE_PERIOD_MS * 2) + }) + + expect(screen.getByRole('status')).toHaveTextContent('Reconnecting… live updates paused') + }) + + it('hides again when the stream reconnects', () => { + vi.useFakeTimers() + const { rerender } = render() + + rerender() + rerender() + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('clears the grace timer on unmount', () => { + vi.useFakeTimers() + const { unmount } = render() + + unmount() + + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/frontend/src/components/session/ReconnectingBanner.tsx b/frontend/src/components/session/ReconnectingBanner.tsx new file mode 100644 index 000000000..18c1a8e93 --- /dev/null +++ b/frontend/src/components/session/ReconnectingBanner.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' + +const NOT_CONNECTED_GRACE_PERIOD_MS = 5000 + +export interface ReconnectingBannerProps { + connected: boolean +} + +function StatusBanner({ message }: { message: string }) { + return ( +
+ {message} +
+ ) +} + +export function ReconnectingBanner({ connected }: ReconnectingBannerProps) { + const [hasConnected, setHasConnected] = useState(false) + const [graceElapsed, setGraceElapsed] = useState(false) + + useEffect(() => { + if (connected) setHasConnected(true) + }, [connected]) + + useEffect(() => { + if (connected) { + setGraceElapsed(false) + return + } + const timer = setTimeout(() => setGraceElapsed(true), NOT_CONNECTED_GRACE_PERIOD_MS) + return () => clearTimeout(timer) + }, [connected]) + + if (connected) return null + if (hasConnected) return + if (graceElapsed) return + return null +} diff --git a/frontend/src/components/session/SessionDetailsSheet.test.tsx b/frontend/src/components/session/SessionDetailsSheet.test.tsx new file mode 100644 index 000000000..dabb6a342 --- /dev/null +++ b/frontend/src/components/session/SessionDetailsSheet.test.tsx @@ -0,0 +1,253 @@ +import { useState } from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SessionDetailsSheet, type SessionDetailsSheetProps } from './SessionDetailsSheet' + +const mocks = vi.hoisted(() => ({ + useMobile: vi.fn(), +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: mocks.useMobile, + useSwipeBack: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), + useSwipeDismiss: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), +})) + +const baseProps: SessionDetailsSheetProps = { + open: true, + onOpenChange: vi.fn(), + title: 'Fix the header', + remote: false, + project: 'oc-manager', + onRename: vi.fn(() => Promise.resolve()), +} + +function createDeferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function failingRename(error = new Error('nope')) { + return vi.fn(() => Promise.reject(error)) +} + +function renderSheet(overrides: Partial = {}) { + return render() +} + +describe('SessionDetailsSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useMobile.mockReturnValue(false) + }) + + it('lists the local session details', () => { + renderSheet({ branch: 'feat/header', directory: '/abs/repos/oc-manager' }) + + expect(screen.getByText('Session details')).toBeInTheDocument() + expect(screen.getByText('Fix the header')).toBeInTheDocument() + expect(screen.getByText('Local')).toBeInTheDocument() + expect(screen.getByText('oc-manager')).toBeInTheDocument() + expect(screen.getByText('feat/header')).toBeInTheDocument() + expect(screen.getByText('/abs/repos/oc-manager')).toBeInTheDocument() + }) + + it('shows the linked server name and marks the session as remote', () => { + renderSheet({ remote: true, serverName: 'Kitchen' }) + + expect(screen.getByText('Kitchen')).toBeInTheDocument() + }) + + it('falls back to a remote server label without a server name', () => { + renderSheet({ remote: true }) + + expect(screen.getByText('Remote server')).toBeInTheDocument() + }) + + it('omits branch and directory rows when they are unknown', () => { + renderSheet({ branch: null }) + + expect(screen.queryByText('Branch')).not.toBeInTheDocument() + expect(screen.queryByText('Directory')).not.toBeInTheDocument() + }) + + it('falls back to an untitled session label', () => { + renderSheet({ title: ' ' }) + + expect(screen.getByText('Untitled Session')).toBeInTheDocument() + }) + + it('prefills the rename field with the raw title', () => { + renderSheet({ title: ' Raw title ' }) + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + + expect(screen.getByLabelText('Session title')).toHaveValue(' Raw title ') + }) + + it('leaves the rename field empty when the session has no title', () => { + renderSheet({ title: undefined }) + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + + expect(screen.getByLabelText('Session title')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('disables Save while the trimmed draft is empty', () => { + renderSheet() + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Session title'), { target: { value: ' ' } }) + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('saves the trimmed title and leaves edit mode on success', async () => { + const user = userEvent.setup() + const onRename = vi.fn(() => Promise.resolve()) + renderSheet({ onRename }) + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.clear(screen.getByLabelText('Session title')) + await user.type(screen.getByLabelText('Session title'), ' New name ') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(onRename).toHaveBeenCalledWith('New name') + expect(screen.queryByLabelText('Session title')).not.toBeInTheDocument() + expect(screen.getByText('Fix the header')).toBeInTheDocument() + }) + + it('saves on Enter', async () => { + const onRename = vi.fn(() => Promise.resolve()) + renderSheet({ onRename }) + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Session title'), { target: { value: 'Entered' } }) + await act(async () => { + fireEvent.keyDown(screen.getByLabelText('Session title'), { key: 'Enter' }) + }) + + expect(onRename).toHaveBeenCalledWith('Entered') + }) + + it('ignores Enter while an IME composition is active', () => { + const onRename = vi.fn(() => Promise.resolve()) + renderSheet({ onRename }) + + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + const input = screen.getByLabelText('Session title') + fireEvent.change(input, { target: { value: 'にほんご' } }) + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + + expect(onRename).not.toHaveBeenCalled() + }) + + it('shows the rename failure inline', async () => { + const user = userEvent.setup() + renderSheet({ onRename: failingRename() }) + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByText('Rename failed')).toBeInTheDocument() + expect(screen.getByLabelText('Session title')).toBeInTheDocument() + }) + + it('cancels editing and clears the rename error', async () => { + const user = userEvent.setup() + renderSheet({ onRename: failingRename() }) + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect(await screen.findByText('Rename failed')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByText('Rename failed')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Session title')).not.toBeInTheDocument() + }) + + it('disables the title field while a rename is pending', async () => { + const user = userEvent.setup() + const deferred = createDeferred() + renderSheet({ onRename: () => deferred.promise }) + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(screen.getByLabelText('Session title')).toBeDisabled() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + + await act(async () => { + deferred.resolve() + }) + }) + + it('does not dismiss while a rename is pending', async () => { + const user = userEvent.setup() + const deferred = createDeferred() + const onOpenChange = vi.fn() + renderSheet({ onOpenChange, onRename: () => deferred.promise }) + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'Close' })) + + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await act(async () => { + deferred.resolve() + }) + }) + + it('resets to view mode and clears the error when reopened', async () => { + const user = userEvent.setup() + + function Host() { + const [open, setOpen] = useState(true) + return ( + <> + + + + ) + } + + render() + + await user.click(screen.getByRole('button', { name: 'Rename' })) + await user.click(screen.getByRole('button', { name: 'Save' })) + expect(await screen.findByText('Rename failed')).toBeInTheDocument() + + fireEvent.click(screen.getByText('toggle')) + fireEvent.click(screen.getByText('toggle')) + + expect(screen.queryByLabelText('Session title')).not.toBeInTheDocument() + expect(screen.queryByText('Rename failed')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Rename' })).toBeInTheDocument() + }) + + it('renders as a bottom sheet on mobile', () => { + mocks.useMobile.mockReturnValue(true) + renderSheet({ branch: 'feat/header' }) + + expect(screen.getByRole('dialog')).toHaveAccessibleName('Session details') + expect(screen.getByText('feat/header')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/session/SessionDetailsSheet.tsx b/frontend/src/components/session/SessionDetailsSheet.tsx new file mode 100644 index 000000000..85d699d09 --- /dev/null +++ b/frontend/src/components/session/SessionDetailsSheet.tsx @@ -0,0 +1,205 @@ +import { useState, type ReactNode } from 'react' +import { Server } from 'lucide-react' +import { BottomSheet, BottomSheetContent, BottomSheetHeader } from '@/components/ui/bottom-sheet' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useMobile } from '@/hooks/useMobile' +import { cn } from '@/lib/utils' + +export interface SessionDetailsSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + title?: string + serverName?: string + remote: boolean + project: string + branch?: string | null + directory?: string + onRename: (title: string) => Promise +} + +interface InfoRowProps { + label: string + value: string + mono?: boolean + leading?: ReactNode +} + +function InfoRow({ label, value, mono, leading }: InfoRowProps) { + return ( +
+ {label} +
+ {leading} + + {value} + +
+
+ ) +} + +export function SessionDetailsSheet({ + open, + onOpenChange, + title, + serverName, + remote, + project, + branch, + directory, + onRename, +}: SessionDetailsSheetProps) { + const isMobile = useMobile() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [saving, setSaving] = useState(false) + const [renameFailed, setRenameFailed] = useState(false) + const [lastOpen, setLastOpen] = useState(open) + + if (open !== lastOpen) { + setLastOpen(open) + if (open) { + setEditing(false) + setRenameFailed(false) + } + } + + const displayTitle = title?.trim() ? title : 'Untitled Session' + const trimmedDraft = draft.trim() + const canSave = trimmedDraft.length > 0 && !saving + + const startEditing = () => { + setDraft(title ?? '') + setEditing(true) + } + + const cancelEditing = () => { + setEditing(false) + setRenameFailed(false) + } + + const save = async () => { + if (!canSave) return + setSaving(true) + setRenameFailed(false) + try { + await onRename(trimmedDraft) + setEditing(false) + } catch { + setRenameFailed(true) + } finally { + setSaving(false) + } + } + + const handleClose = () => { + if (saving) return + onOpenChange(false) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + handleClose() + return + } + onOpenChange(true) + } + + const body = ( +
+
+ Title + {editing ? ( + <> + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return + if (event.key !== 'Enter') return + event.preventDefault() + save() + }} + disabled={saving} + aria-label="Session title" + placeholder="Session title" + autoFocus + className="w-full min-w-0 rounded border border-border bg-background px-3 py-2 text-sm font-semibold outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:opacity-60" + /> + {renameFailed && ( + Rename failed + )} +
+ + +
+ + ) : ( + <> + {displayTitle} + + + )} +
+ : undefined} + /> + + {branch ? : null} + {directory ? : null} +
+ ) + + if (isMobile) { + return ( + + + {body} + + ) + } + + return ( + + + + Session details + + {body} + + + ) +} diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 0e08e4037..eedba48f8 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -7,6 +7,7 @@ import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Repo, Session } from '@/api/types' import type { ActiveWorkGroups, ActiveWorkItem, ActiveWorkState } from '@/lib/activeWork' import { stubMatchMedia } from '@/test/test-utils' +import { makeRepo, makeSession } from '@/test/fixtures/session-picker' import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' @@ -21,9 +22,11 @@ const mocks = vi.hoisted(() => ({ | { directory: string; project: { id: string; directory: string; canonical: string } } | undefined, sessionsHook: vi.fn(), + lastSessionServerId: null as string | null, fetchNextPage: vi.fn(), deleteSessionMock: vi.fn(), deletedSessionServerId: null as string | null, + createSessionDirectories: [] as string[], togglePinMock: vi.fn(), getOpenCodeLocation: vi.fn(), sessionPins: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>, @@ -44,6 +47,12 @@ vi.mock('@/hooks/useSettings', () => ({ useSettings: () => ({ preferences: mocks.preferences }), })) +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), + createRepo: vi.fn(), + discoverRepos: vi.fn(), +})) + vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getOpenCodeLocation: mocks.getOpenCodeLocation } @@ -59,6 +68,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { options?: { search?: string; allDirectories?: boolean; project?: { id: string; subpath: string } }, ) => { mocks.sessionsHook() + mocks.lastSessionServerId = useOpenCodeServerId() mocks.lastDirectories = directories mocks.lastAllDirectories = options?.allDirectories ?? false mocks.lastProject = options?.project @@ -87,6 +97,13 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { isPending: false, } }, + useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ + mutate: () => { + mocks.createSessionDirectories.push(directory) + onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }), } }) @@ -95,6 +112,12 @@ vi.mock('@/hooks/useSessionPins', () => ({ useToggleSessionPin: () => ({ mutate: mocks.togglePinMock }), })) +vi.mock('@/components/navigation/RemoteProjectNav', () => ({ + RemoteProjectNavList: ({ serverId }: { serverId: string }) => ( +
+ ), +})) + vi.mock('@/hooks/useSidebarRepoGroups', () => ({ useNavigableRepos: () => ({ repos: mocks.repos, isLoading: false, refetch: vi.fn() }), })) @@ -113,30 +136,6 @@ vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { return { ...actual, useSessionStatusForSession: () => ({ type: 'idle' }) } }) -function session(id: string, title: string, directory: string, updated = Date.now()): Session { - return { - id, - projectID: 'proj', - title, - time: { created: updated, updated }, - location: { directory }, - cost: 0, - tokens: {}, - } as unknown as Session -} - -function repo(id: number, name: string, fullPath: string): Repo { - return { - id, - name, - localPath: fullPath, - fullPath, - defaultBranch: 'main', - cloneStatus: 'ready', - clonedAt: 0, - } -} - function activeWorkItem( id: string, title: string, @@ -144,7 +143,7 @@ function activeWorkItem( state: ActiveWorkState = 'recent', ): ActiveWorkItem { const itemRepo = mocks.repos.find((candidate) => candidate.fullPath === directory) ?? mocks.repos[0] - const itemSession = session(id, title, directory) + const itemSession = makeSession(id, title, directory) return { key: `${directory}:${id}`, session: itemSession, @@ -202,7 +201,7 @@ const openProjectsTab = () => { } const openActiveTab = () => { - fireEvent.mouseDown(screen.getByRole('tab', { name: 'Active' })) + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) } describe('SessionPickerDialog', () => { @@ -212,14 +211,16 @@ describe('SessionPickerDialog', () => { localStorage.clear() mocks.deleteSessionMock.mockResolvedValue(undefined) mocks.deletedSessionServerId = null + mocks.createSessionDirectories.length = 0 mocks.locationData = { directory: '/w/a', project: { id: 'global', directory: '/w/a', canonical: '/w/a' }, } mocks.getOpenCodeLocation.mockResolvedValue(mocks.locationData) mocks.lastProject = undefined + mocks.lastSessionServerId = null mocks.sessionPins = [] - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.sessionsData = [] mocks.activeWorkGroups = { needsInput: [], running: [], recent: [] } mocks.activeWorkWindowMs = 0 @@ -232,8 +233,8 @@ describe('SessionPickerDialog', () => { it('opens with the active session as the cursor and marks it with a dot', () => { mocks.sessionsData = [ - session('ses_active', 'Active session', '/w/a', 3000), - session('ses_other', 'Other session', '/w/a', 2000), + makeSession('ses_active', 'Active session', '/w/a', 3000), + makeSession('ses_other', 'Other session', '/w/a', 2000), ] renderPicker({ activeSessionID: 'ses_active' }) @@ -247,8 +248,8 @@ describe('SessionPickerDialog', () => { it('filters rows immediately as the query is typed', () => { mocks.sessionsData = [ - session('ses_alpha', 'alpha task', '/w/a', 2000), - session('ses_beta', 'beta task', '/w/a', 1000), + makeSession('ses_alpha', 'alpha task', '/w/a', 2000), + makeSession('ses_beta', 'beta task', '/w/a', 1000), ] renderPicker() @@ -261,9 +262,9 @@ describe('SessionPickerDialog', () => { it('moves the cursor with arrow keys and Ctrl+N/Ctrl+P, wrapping, and opens the row on Enter', () => { mocks.sessionsData = [ - session('ses_1', 'First', '/w/a', 3000), - session('ses_2', 'Second', '/w/a', 2000), - session('ses_3', 'Third', '/w/a', 1000), + makeSession('ses_1', 'First', '/w/a', 3000), + makeSession('ses_2', 'Second', '/w/a', 2000), + makeSession('ses_3', 'Third', '/w/a', 1000), ] const { onSelectSession } = renderPicker() @@ -297,14 +298,15 @@ describe('SessionPickerDialog', () => { it('toggles scope with Ctrl+A, persists it, queries every repo directory and shows repo labels', () => { mocks.sessionsData = [ - session('ses_a', 'Alpha session', '/w/a', 2000), - session('ses_b', 'Beta session', '/w/b', 1000), + makeSession('ses_a', 'Alpha session', '/w/a', 2000), + makeSession('ses_b', 'Beta session', '/w/b', 1000), ] renderPicker() expect(mocks.lastDirectories).toEqual(['/w/a']) expect(mocks.lastAllDirectories).toBe(false) + expect(screen.getByRole('tab', { name: 'This project' })).toBeInTheDocument() expect(screen.getByText('Alpha session')).toBeInTheDocument() expect(screen.queryByText('Beta session')).not.toBeInTheDocument() @@ -313,6 +315,7 @@ describe('SessionPickerDialog', () => { expect(mocks.lastDirectories).toEqual(['/w/a', '/w/b']) expect(mocks.lastAllDirectories).toBe(true) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') + expect(screen.getByRole('tab', { name: 'All sessions' })).toBeInTheDocument() expect(screen.getByText('Beta session')).toBeInTheDocument() expect(screen.getByText('alpha')).toBeInTheDocument() expect(screen.getByText('beta')).toBeInTheDocument() @@ -321,18 +324,127 @@ describe('SessionPickerDialog', () => { expect(mocks.lastDirectories).toEqual(['/w/a']) expect(mocks.lastAllDirectories).toBe(false) expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + expect(screen.getByRole('tab', { name: 'This project' })).toBeInTheDocument() + }) + + it('toggles the scope when the selected sessions tab is clicked again', async () => { + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'This project' })) + + expect(screen.getByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') + + await user.click(screen.getByRole('tab', { name: 'All sessions' })) + + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + }) + + it('selects the sessions tab without toggling the scope when it was not selected', async () => { + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'Activity' })) + await user.click(screen.getByRole('tab', { name: 'This project' })) + + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBeNull() + }) + + it('toggles the scope with Enter on the focused selected sessions tab', async () => { + const user = userEvent.setup() + renderPicker() + + await waitFor(() => expect(combobox()).toHaveFocus()) + + const sessionsTab = screen.getByRole('tab', { name: 'This project' }) + act(() => { + sessionsTab.focus() + }) + expect(sessionsTab).toHaveFocus() + + await user.keyboard('{Enter}') + + expect(await screen.findByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true') + }) + + it('labels the sessions tab Sessions and lists every project without a current repo', () => { + mocks.sessionsData = [ + makeSession('ses_a', 'Alpha session', '/w/a', 2000), + makeSession('ses_b', 'Beta session', '/w/b', 1000), + ] + + renderPicker({ currentRepo: undefined }) + + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(mocks.lastDirectories).toEqual(['/w/a', '/w/b']) + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByText('Beta session')).toBeInTheDocument() + expect(localStorage.getItem('oc:session-picker:all-projects')).toBeNull() + }) + + it('leaves the persisted scope preference unchanged on Ctrl+A without a current repo', () => { + localStorage.setItem('oc:session-picker:all-projects', 'false') + + renderPicker({ currentRepo: undefined }) + + fireEvent.keyDown(combobox(), { key: 'a', ctrlKey: true }) + + expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('false') + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + }) + + it('starts a new session in the current repo from the sessions tab header action', () => { + const onNavigate = vi.fn() + renderPicker({ onNavigate }) + + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(onNavigate).toHaveBeenCalledWith('/repos/1/sessions/ses_new') + }) + + it('opens the add repository dialog from the projects tab header action', async () => { + renderPicker() + + openProjectsTab() + fireEvent.click(await screen.findByRole('button', { name: 'Add repository' })) + + expect(await screen.findByRole('dialog', { name: 'Add Repository' })).toBeInTheDocument() + }) + + it('starts a new session in a project row and marks a repo that is not ready', async () => { + const onNavigate = vi.fn() + mocks.repos = [ + makeRepo(1, 'alpha', '/w/a'), + { ...makeRepo(2, 'beta', '/w/b'), cloneStatus: 'cloning' }, + ] + renderPicker({ onNavigate }) + + openProjectsTab() + + fireEvent.click(await screen.findByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(onNavigate).toHaveBeenCalledWith('/repos/1/sessions/ses_new') + expect(screen.queryByRole('button', { name: 'New session in beta' })).not.toBeInTheDocument() + expect(screen.getByText('Not ready')).toBeInTheDocument() }) it('lists project sessions across worktrees and labels them with the folder name', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a')] mocks.locationData = { directory: '/w/a', project: { id: 'proj_x', directory: '/w/a', canonical: '/w/a' }, } mocks.sessionsData = [ - session('ses_root', 'Root session', '/w/a', 3000), - session('ses_wt', 'Worktree session', '/w/.opencode/state/opencode/worktree/proj_x/test', 2000), - session('ses_schedule', 'Schedule session', '/w/schedule-worktrees/job-11-run-361', 1000), + makeSession('ses_root', 'Root session', '/w/a', 3000), + makeSession('ses_wt', 'Worktree session', '/w/.opencode/state/opencode/worktree/proj_x/test', 2000), + makeSession('ses_schedule', 'Schedule session', '/w/schedule-worktrees/job-11-run-361', 1000), ] renderPicker() @@ -351,10 +463,10 @@ describe('SessionPickerDialog', () => { }) it('falls back to the repo directory listing when the location project is global', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.sessionsData = [ - session('ses_a', 'Alpha session', '/w/a', 2000), - session('ses_b', 'Beta session', '/w/b', 1000), + makeSession('ses_a', 'Alpha session', '/w/a', 2000), + makeSession('ses_b', 'Beta session', '/w/b', 1000), ] renderPicker() @@ -366,12 +478,12 @@ describe('SessionPickerDialog', () => { }) it('falls back to the repo directory listing when the location lookup fails', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.locationData = undefined mocks.getOpenCodeLocation.mockRejectedValue(new Error('location unavailable')) mocks.sessionsData = [ - session('ses_a', 'Alpha session', '/w/a', 2000), - session('ses_b', 'Beta session', '/w/b', 1000), + makeSession('ses_a', 'Alpha session', '/w/a', 2000), + makeSession('ses_b', 'Beta session', '/w/b', 1000), ] renderPicker() @@ -387,7 +499,7 @@ describe('SessionPickerDialog', () => { | undefined mocks.locationData = undefined mocks.getOpenCodeLocation.mockReturnValue(new Promise((resolve) => { resolveLocation = resolve })) - mocks.sessionsData = [session('ses_a', 'Alpha session', '/w/a', 2000)] + mocks.sessionsData = [makeSession('ses_a', 'Alpha session', '/w/a', 2000)] renderPicker() @@ -406,7 +518,7 @@ describe('SessionPickerDialog', () => { }) it('does not query sessions while closed and queries after opening', async () => { - mocks.sessionsData = [session('ses_1', 'First', '/w/a', 3000)] + mocks.sessionsData = [makeSession('ses_1', 'First', '/w/a', 3000)] function Host() { const [open, setOpen] = useState(false) @@ -436,8 +548,8 @@ describe('SessionPickerDialog', () => { it('deletes on a second Ctrl+D and clears the pending state on a move', async () => { mocks.sessionsData = [ - session('ses_1', 'First', '/w/a', 3000), - session('ses_2', 'Second', '/w/a', 2000), + makeSession('ses_1', 'First', '/w/a', 3000), + makeSession('ses_2', 'Second', '/w/a', 2000), ] const { onDeleteActiveSession } = renderPicker() @@ -461,7 +573,7 @@ describe('SessionPickerDialog', () => { }) it('notifies when the active session is deleted before the delete completes', async () => { - mocks.sessionsData = [session('ses_active', 'Active', '/w/a', 3000)] + mocks.sessionsData = [makeSession('ses_active', 'Active', '/w/a', 3000)] let resolveDelete: () => void = () => undefined mocks.deleteSessionMock.mockReturnValue( new Promise((resolve) => { @@ -483,8 +595,8 @@ describe('SessionPickerDialog', () => { it('moves the cursor when a mouse pointer moves over a row', () => { mocks.sessionsData = [ - session('ses_1', 'First', '/w/a', 3000), - session('ses_2', 'Second', '/w/a', 2000), + makeSession('ses_1', 'First', '/w/a', 3000), + makeSession('ses_2', 'Second', '/w/a', 2000), ] renderPicker() @@ -497,7 +609,7 @@ describe('SessionPickerDialog', () => { }) it('opens the delete confirmation from the row menu', async () => { - mocks.sessionsData = [session('ses_1', 'First', '/w/a', 3000)] + mocks.sessionsData = [makeSession('ses_1', 'First', '/w/a', 3000)] renderPicker() const user = userEvent.setup() @@ -509,8 +621,8 @@ describe('SessionPickerDialog', () => { it('groups older sessions under per-day headings', () => { mocks.sessionsData = [ - session('ses_jan2', 'Jan two', '/w/a', new Date(2020, 0, 2, 12).getTime()), - session('ses_jan1', 'Jan one', '/w/a', new Date(2020, 0, 1, 12).getTime()), + makeSession('ses_jan2', 'Jan two', '/w/a', new Date(2020, 0, 2, 12).getTime()), + makeSession('ses_jan1', 'Jan one', '/w/a', new Date(2020, 0, 1, 12).getTime()), ] renderPicker() @@ -521,8 +633,8 @@ describe('SessionPickerDialog', () => { it('resets the query and cursor on reopen', async () => { mocks.sessionsData = [ - session('ses_active', 'Active session', '/w/a', 3000), - session('ses_other', 'Other session', '/w/a', 2000), + makeSession('ses_active', 'Active session', '/w/a', 3000), + makeSession('ses_other', 'Other session', '/w/a', 2000), ] function Host() { @@ -556,11 +668,11 @@ describe('SessionPickerDialog', () => { expect(screen.getByRole('option', { name: /Active session/ })).toHaveAttribute('aria-selected', 'true') }) - it('renders an Active/Sessions/Projects tab switcher and defaults to the sessions tab', () => { + it('renders an Activity/Sessions/Projects tab switcher and defaults to the sessions tab', () => { renderPicker() - expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true') - expect(screen.getByRole('tab', { name: 'Active' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tab', { name: 'This project' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'false') expect(screen.getByRole('tab', { name: 'Projects' })).toHaveAttribute('aria-selected', 'false') expect(combobox()).toHaveAttribute('placeholder', 'Search sessions...') }) @@ -568,7 +680,7 @@ describe('SessionPickerDialog', () => { it('exposes a tabpanel labelled by the active tab and swaps panels on tab switch', async () => { renderPicker() - const sessionsPanel = screen.getByRole('tabpanel', { name: 'Sessions' }) + const sessionsPanel = screen.getByRole('tabpanel', { name: 'This project' }) expect(sessionsPanel).toHaveAttribute('data-state', 'active') expect(screen.queryByRole('tabpanel', { name: 'Projects' })).not.toBeInTheDocument() @@ -576,11 +688,11 @@ describe('SessionPickerDialog', () => { const projectsPanel = await screen.findByRole('tabpanel', { name: 'Projects' }) expect(projectsPanel).toHaveAttribute('data-state', 'active') - expect(screen.queryByRole('tabpanel', { name: 'Sessions' })).not.toBeInTheDocument() + expect(screen.queryByRole('tabpanel', { name: 'This project' })).not.toBeInTheDocument() }) it('focuses the projects search input after clicking the Projects tab and filters on typing', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const user = userEvent.setup() renderPicker() @@ -595,7 +707,7 @@ describe('SessionPickerDialog', () => { }) it('focuses the projects search input after switching tabs with the shortcut', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const user = userEvent.setup() renderPicker() @@ -607,7 +719,7 @@ describe('SessionPickerDialog', () => { it('focuses the projects search input after the switch shortcut on a coarse pointer device', async () => { stubMatchMedia(false) - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const user = userEvent.setup() renderPicker() @@ -619,7 +731,7 @@ describe('SessionPickerDialog', () => { it('does not focus the projects search input when switching tabs by pointer on a coarse pointer device', async () => { stubMatchMedia(false) - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const user = userEvent.setup() renderPicker() @@ -629,7 +741,7 @@ describe('SessionPickerDialog', () => { }) it('lists repos in the projects tab and opens the cursor repo on Enter', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const { onSelectRepo } = renderPicker() openProjectsTab() @@ -645,7 +757,7 @@ describe('SessionPickerDialog', () => { it('navigates the projects tab with wrapping arrows, PageUp/PageDown and Enter', async () => { mocks.repos = Array.from({ length: 12 }, (_, index) => - repo(index + 1, `repo-${index + 1}`, `/w/r${index + 1}`), + makeRepo(index + 1, `repo-${index + 1}`, `/w/r${index + 1}`), ) const { onSelectRepo } = renderPicker() @@ -673,7 +785,7 @@ describe('SessionPickerDialog', () => { }) it('opens a repo on click in the projects tab', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] const { onSelectRepo } = renderPicker() openProjectsTab() @@ -685,8 +797,8 @@ describe('SessionPickerDialog', () => { it('filters projects by name and branch', async () => { mocks.repos = [ - { ...repo(1, 'alpha', '/w/a'), currentBranch: 'feature/x' }, - repo(2, 'beta', '/w/b'), + { ...makeRepo(1, 'alpha', '/w/a'), currentBranch: 'feature/x' }, + makeRepo(2, 'beta', '/w/b'), ] renderPicker() @@ -713,19 +825,19 @@ describe('SessionPickerDialog', () => { expect(screen.getByText('No projects found')).toBeInTheDocument() }) - it('cycles Active, Sessions and Projects with the default Ctrl+S binding', async () => { + it('cycles Activity, Sessions and Projects with the default Ctrl+S binding', async () => { renderPicker() - expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) - expect(await screen.findByRole('tab', { name: 'Active', selected: true })).toBeInTheDocument() + expect(await screen.findByRole('tab', { name: 'Activity', selected: true })).toBeInTheDocument() fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) - expect(await screen.findByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(await screen.findByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() }) it('honors a configured switch binding and shows it in the footer', async () => { @@ -756,7 +868,7 @@ describe('SessionPickerDialog', () => { fireEvent.keyDown(combobox(), { key: 'o', ctrlKey: true }) - expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() }) it('resets to the sessions tab when the dialog reopens', async () => { @@ -787,12 +899,12 @@ describe('SessionPickerDialog', () => { fireEvent.click(screen.getByText('open-picker')) await waitFor(() => - expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true'), + expect(screen.getByRole('tab', { name: 'This project' })).toHaveAttribute('aria-selected', 'true'), ) }) it('groups active work into needs input, running and recent sections with repo labels', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.activeWorkGroups = { needsInput: [activeWorkItem('ses_ni', 'Needs input task', '/w/a', 'needs-input')], running: [activeWorkItem('ses_run', 'Running task', '/w/b', 'running')], @@ -838,7 +950,7 @@ describe('SessionPickerDialog', () => { }) it('opens the cursor active row with Enter and selects its session and repo', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.activeWorkGroups = { needsInput: [], running: [activeWorkItem('ses_run', 'Running task', '/w/b', 'running')], @@ -859,7 +971,7 @@ describe('SessionPickerDialog', () => { }) it('filters active work rows by title and drops sections left empty', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.activeWorkGroups = { needsInput: [activeWorkItem('ses_ni', 'Needs input task', '/w/a', 'needs-input')], running: [], @@ -879,7 +991,7 @@ describe('SessionPickerDialog', () => { }) it('pins an active work row from the shared row menu', async () => { - mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] mocks.activeWorkGroups = { needsInput: [], running: [activeWorkItem('ses_run', 'Running task', '/w/b', 'running')], @@ -902,11 +1014,11 @@ describe('SessionPickerDialog', () => { describe('linked-server mode', () => { beforeEach(() => { - mocks.repos = [repo(1, 'alpha', '/w/a')] - mocks.sessionsData = [session('ses_remote', 'Remote session', '/home/me/app')] + mocks.repos = [makeRepo(1, 'alpha', '/w/a')] + mocks.sessionsData = [makeSession('ses_remote', 'Remote session', '/home/me/app')] }) - it('lists the project sessions without Manager-only tabs and reports the chosen server session', async () => { + it('lists the project sessions with the linked server projects tab and reports the chosen server session', async () => { const onSelectRemoteSession = vi.fn() renderPicker({ remote: { @@ -917,8 +1029,8 @@ describe('SessionPickerDialog', () => { onSelectRemoteSession, }) - expect(screen.queryByRole('tab', { name: 'Projects' })).not.toBeInTheDocument() - expect(screen.getByRole('tab', { name: 'Active' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Projects' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument() const row = await screen.findByRole('option', { name: /Remote session/ }) expect(row).toHaveTextContent('Kitchen') @@ -931,13 +1043,33 @@ describe('SessionPickerDialog', () => { ) }) + it('renders the linked server projects tab through the shared activity-ordered project list', async () => { + renderPicker({ + remote: { serverId: 'srv_kitchen', serverName: 'Kitchen', projectDirectory: '/home/me/app' }, + }) + + openProjectsTab() + + const nav = await screen.findByTestId('remote-project-nav') + expect(nav).toHaveAttribute('data-server', 'srv_kitchen') + }) + + it('queries the linked server sessions without a host-supplied server provider', async () => { + renderPicker({ + remote: { serverId: 'srv-1', serverName: 'Kitchen', projectDirectory: '/home/me/app' }, + }) + + expect(await screen.findByRole('option', { name: /Remote session/ })).toBeInTheDocument() + expect(mocks.lastSessionServerId).toBe('srv-1') + }) + it('navigates a remote active work row through the owning server', async () => { const onSelectRemoteSession = vi.fn() mocks.activeWorkGroups = { needsInput: [ { key: '/home/me/app/ses_remote', - session: session('ses_remote', 'Remote pending', '/home/me/app'), + session: makeSession('ses_remote', 'Remote pending', '/home/me/app'), repo: null, serverId: 'srv_kitchen', serverName: 'Kitchen', diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 9f772d7d0..9e428d226 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -1,11 +1,10 @@ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { format, startOfDay } from 'date-fns' -import { MoreHorizontal, Pin, PinOff } from 'lucide-react' +import { ChevronsUpDown, MoreHorizontal, Pin, PinOff, Plus } from 'lucide-react' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { getOpenCodeLocation } from '@/api/opencode' import { OpenCodeServerProvider, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { serverScopedKey } from '@/lib/serverScope' +import { openCodeLocationQueryOptions } from '@/lib/openCodeLocation' import type { Repo, Session } from '@/api/types' import { ACTIVE_WORK_GROUPS, MS_PER_MINUTE, formatActivityEmptyMessage } from '@/lib/activeWork' import { Button } from '@/components/ui/button' @@ -20,6 +19,9 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' import { ProjectPickerContent } from '@/components/session/ProjectPickerContent' +import { AddRepoDialog } from '@/components/repo/AddRepoDialog' +import { LocalRepoNewSessionButton } from '@/components/navigation/RepoSessionNav' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { PickerSearchBar, optionId, @@ -27,7 +29,7 @@ import { usePickerKeyNav, } from '@/components/session/usePickerKeyboardNav' import { partitionSessions } from '@/components/session/session-partition' -import { buildRepoByDirectory } from '@/components/navigation/sidebar-session-tree' +import { buildRepoByDirectory, isRepoReady } from '@/components/navigation/sidebar-session-tree' import { useDeleteSession } from '@/hooks/useOpenCode' import { useActiveWork, useActivityWindowMinutes } from '@/hooks/useActiveWork' import { useSessionSearch } from '@/hooks/useSessionSearch' @@ -49,10 +51,9 @@ const NEAR_BOTTOM_PX = 240 type SessionPickerTab = 'active' | 'sessions' | 'projects' const SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'projects'] -const REMOTE_SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions'] const SESSION_PICKER_TAB_LABELS: Record = { - active: 'Active', + active: 'Activity', sessions: 'Sessions', projects: 'Projects', } @@ -62,23 +63,25 @@ function nextSessionPickerTab(tab: SessionPickerTab, tabs: SessionPickerTab[]): return tabs[(index + 1) % tabs.length] } -interface RemotePickerScope { +function sessionsTabLabel(scopeEnabled: boolean, allProjects: boolean): string { + if (!scopeEnabled) return SESSION_PICKER_TAB_LABELS.sessions + return allProjects ? 'All sessions' : 'This project' +} + +export interface RemotePickerScope { serverId: string serverName?: string + /** Canonical project directory in view; omitted when the route scopes to the whole server. */ projectDirectory?: string + /** Whether the route's project directory is still resolving, which holds the all-sessions search. */ + isPending?: boolean } -interface SessionPickerDialogProps { - open: boolean +type SessionPickerDialogProps = Omit< + SessionPickerPanelProps, + 'searchTrailing' | 'onAddRepo' | 'renderTitle' +> & { onOpenChange: (open: boolean) => void - currentRepo: Repo | undefined - activeSessionID?: string - onSelectSession: (session: Session, repo: Repo) => void - onSelectRemoteSession?: (session: Session, serverId: string) => void - onDeleteActiveSession: () => void - onSelectRepo: (repo: Repo) => void - /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ - remote?: RemotePickerScope } interface SessionPickerRow { @@ -556,9 +559,12 @@ interface SessionPickerContentProps { onSelectSession: (session: Session, repo: Repo) => void onSelectRemoteSession?: (session: Session, serverId: string) => void onDeleteActiveSession: () => void + allProjects: boolean + scopeEnabled: boolean + onToggleAllProjects?: () => void switchHint: string searchInputRef: React.RefObject - hasFinePointer: boolean + searchTrailing?: React.ReactNode } function SessionPickerContent({ @@ -568,11 +574,13 @@ function SessionPickerContent({ onSelectSession, onSelectRemoteSession, onDeleteActiveSession, + allProjects, + scopeEnabled, + onToggleAllProjects, switchHint, searchInputRef, - hasFinePointer, + searchTrailing, }: SessionPickerContentProps) { - const [allProjects, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) const { repos } = useNavigableRepos() const listRef = useRef(null) const listId = useId() @@ -586,10 +594,8 @@ function SessionPickerContent({ const serverId = useOpenCodeServerId() const locationQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'location', currentRepo?.fullPath], serverId), - queryFn: () => getOpenCodeLocation(currentRepo!.fullPath, serverId), + ...openCodeLocationQueryOptions(currentRepo?.fullPath ?? '', serverId), enabled: !allProjects && Boolean(currentRepo?.fullPath), - staleTime: 5 * 60 * 1000, }) const location = locationQuery.data @@ -696,7 +702,7 @@ function SessionPickerContent({ moveCursor, onOpenRow: openRow, onDeleteRow: deleteRow, - onToggleAllProjects: toggleAllProjects, + onToggleAllProjects, }) const handleListScroll = useCallback( @@ -739,23 +745,7 @@ function SessionPickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="session-picker-search" - trailing={ - - } + trailing={searchTrailing} />
- ↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} + ↑↓ move · Enter open · Ctrl+D delete + {scopeEnabled ? ` · Ctrl+A ${allProjects ? 'this project' : 'all sessions'}` : ''} {switchHint ? ` · ${switchHint} projects` : ''}
@@ -827,6 +818,7 @@ interface ActivePickerContentProps { onDeleteActiveSession: () => void switchHint: string searchInputRef: React.RefObject + searchTrailing?: React.ReactNode } function ActivePickerContent({ @@ -837,6 +829,7 @@ function ActivePickerContent({ onDeleteActiveSession, switchHint, searchInputRef, + searchTrailing, }: ActivePickerContentProps) { const windowMinutes = useActivityWindowMinutes() const { groups, isLoading, isError } = useActiveWork(windowMinutes * MS_PER_MINUTE) @@ -950,6 +943,7 @@ function ActivePickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="active-picker-search" + trailing={searchTrailing} /> void switchHint: string searchInputRef: React.RefObject + searchTrailing?: React.ReactNode } function RemoteSessionPickerContent({ open, serverName, projectDirectory, + isPending = false, activeSessionID, onSelectSession, switchHint, searchInputRef, + searchTrailing, }: RemoteSessionPickerContentProps) { const listRef = useRef(null) const listId = useId() @@ -1019,7 +1017,9 @@ function RemoteSessionPickerContent({ isFetchingNextPage, isFetchNextPageError, canFetchNextPage, - } = useSessionSearch(projectDirectory ? [projectDirectory] : []) + } = useSessionSearch(projectDirectory ? [projectDirectory] : [], { + allDirectories: projectDirectory === undefined && !isPending, + }) const sections = useMemo( () => buildSections(filteredSessions, EMPTY_PINNED_KEYS, getSessionKey), @@ -1085,6 +1085,7 @@ function RemoteSessionPickerContent({ onKeyDown={handleSearchKeyDown} placeholder="Search sessions..." name="remote-session-picker-search" + trailing={searchTrailing} /> + searchTrailing?: React.ReactNode + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectPickerContent({ + serverId, + open, + searchInputRef, + searchTrailing, + onOpenProject, + onSelectSession, +}: RemoteProjectPickerContentProps) { + const listId = useId() + const [query, setQuery] = useState('') + + return ( +
+ undefined} + placeholder="Search projects..." + name="remote-project-picker-search" + trailing={searchTrailing} + /> + +
+ +
+
+ ) +} + +export interface SessionPickerPanelProps { + open: boolean + currentRepo: Repo | undefined + activeSessionID?: string + onSelectSession: (session: Session, repo: Repo) => void + onSelectRemoteSession?: (session: Session, serverId: string) => void + onDeleteActiveSession: () => void + onSelectRepo: (repo: Repo) => void + onNavigate: (path: string) => void + /** Set on a linked-server route to list that server's sessions instead of Manager repos. */ + remote?: RemotePickerScope + searchTrailing?: React.ReactNode + onAddRepo?: () => void + renderTitle?: (label: string) => React.ReactNode +} + +/** + * Picker content shared by the desktop dialog and the mobile sheet. A remote scope supplies its own + * server, so every host reads the linked server's sessions without wrapping the panel itself. + */ +export function SessionPickerPanel(props: SessionPickerPanelProps) { + if (!props.remote) return + return ( + + + + ) +} + +function SessionPickerPanelBody({ open, - onOpenChange, currentRepo, activeSessionID, onSelectSession, onSelectRemoteSession, onDeleteActiveSession, onSelectRepo, + onNavigate, remote, -}: SessionPickerDialogProps) { + searchTrailing, + onAddRepo, + renderTitle, +}: SessionPickerPanelProps) { const searchInputRef = useRef(null) const activeSearchInputRef = useRef(null) const projectSearchInputRef = useRef(null) const focusSearchAfterSwitchRef = useRef(false) + const scopePressRef = useRef(false) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + const [addRepoOpen, setAddRepoOpen] = useState(false) + const [allProjectsPreference, toggleAllProjects] = usePersistentBoolean(ALL_PROJECTS_STORAGE_KEY, false) const { preferences } = useSettings() const switchHint = useMemo( () => { @@ -1175,21 +1259,22 @@ export function SessionPickerDialog({ [preferences], ) const isRemote = remote !== undefined - const pickerTabs = isRemote ? REMOTE_SESSION_PICKER_TABS : SESSION_PICKER_TABS - const [tab, setTab] = useState('sessions') + const [activeTab, setActiveTab] = useState('sessions') const [prevOpen, setPrevOpen] = useState(open) if (prevOpen !== open) { setPrevOpen(open) - if (open) setTab('sessions') + if (open) setActiveTab('sessions') } - const activeTab: SessionPickerTab = isRemote && tab === 'projects' ? 'sessions' : tab + const scopeEnabled = !isRemote && Boolean(currentRepo) + const allProjects = scopeEnabled ? allProjectsPreference : true + const readyRepo = scopeEnabled && currentRepo && isRepoReady(currentRepo) ? currentRepo : undefined const toggleTab = useCallback(() => { focusSearchAfterSwitchRef.current = true - setTab((current) => nextSessionPickerTab(current, pickerTabs)) - }, [pickerTabs]) + setActiveTab((current) => nextSessionPickerTab(current, SESSION_PICKER_TABS)) + }, []) useShortcutActions({ switchPickerTab: open ? toggleTab : undefined }) @@ -1208,6 +1293,186 @@ export function SessionPickerDialog({ return () => cancelAnimationFrame(frame) }, [open, activeTab, hasFinePointer]) + const addRepoButton = isRemote ? null : ( + + ) + + const newSessionButton = readyRepo ? ( + + ) : ( + addRepoButton + ) + + const withTrailing = (action: React.ReactNode): React.ReactNode => { + if (!searchTrailing && !action) return undefined + return ( +
+ {searchTrailing} + {action} +
+ ) + } + + const sessionsTrailing = withTrailing(newSessionButton) + const projectsTrailing = withTrailing(addRepoButton) + + const canToggleScope = scopeEnabled && activeTab === 'sessions' + + /** + * The sessions tab re-activates itself to toggle the scope. Pointer activation records whether the + * tab was already selected before Radix activates it, so the click that follows only toggles a + * tab that was already current. Keyboard activation toggles on Enter/Space directly and suppresses + * the click Radix would otherwise synthesize for the same key press. + */ + const handleSessionsPointerDown = () => { + scopePressRef.current = canToggleScope + } + + const handleSessionsClick = () => { + if (!canToggleScope || !scopePressRef.current) return + scopePressRef.current = false + toggleAllProjects() + } + + const handleSessionsKeyDown = (event: React.KeyboardEvent) => { + if (!canToggleScope) return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + toggleAllProjects() + } + + return ( + <> + setActiveTab(value as SessionPickerTab)} + className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" + > +
+ {renderTitle?.(SESSION_PICKER_TAB_LABELS[activeTab])} + + {SESSION_PICKER_TAB_LABELS.active} + + {sessionsTabLabel(scopeEnabled, allProjects)} + {scopeEnabled && ( + + {SESSION_PICKER_TAB_LABELS.projects} + +
+ + + + + + + {remote ? ( + onSelectRemoteSession?.(session, remote.serverId)} + switchHint={switchHint} + searchInputRef={searchInputRef} + searchTrailing={sessionsTrailing} + /> + ) : ( + + )} + + + {remote ? ( + + ) : ( + + )} + +
+ {addRepoOpen && !onAddRepo && } + + ) +} + +export function SessionPickerDialog({ + open, + onOpenChange, + currentRepo, + activeSessionID, + onSelectSession, + onSelectRemoteSession, + onDeleteActiveSession, + onSelectRepo, + onNavigate, + remote, +}: SessionPickerDialogProps) { + const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + return ( onOpenChange(false)} onOpenAutoFocus={(event) => { event.preventDefault() - if (hasFinePointer) { - searchInputRef.current?.focus() - } else { + if (!hasFinePointer) { ;(event.currentTarget as HTMLElement).focus() } }} > - setTab(value as SessionPickerTab)} - className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" - > -
- {SESSION_PICKER_TAB_LABELS[activeTab]} - - Active - Sessions - {isRemote ? null : Projects} - -
- - - - - - - {remote ? ( - onSelectRemoteSession?.(session, remote.serverId)} - switchHint={switchHint} - searchInputRef={searchInputRef} - /> - ) : ( - - )} - - {isRemote ? null : ( - - - - )} -
+ {label}} + />
) diff --git a/frontend/src/components/session/SessionPickerSheet.test.tsx b/frontend/src/components/session/SessionPickerSheet.test.tsx new file mode 100644 index 000000000..33ec181d9 --- /dev/null +++ b/frontend/src/components/session/SessionPickerSheet.test.tsx @@ -0,0 +1,488 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import type { Repo, Session } from '@/api/types' +import type { ActiveWorkGroups, ActiveWorkItem } from '@/lib/activeWork' +import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' +import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' +import { LocationCatcher, stubMatchMedia } from '@/test/test-utils' +import { makeRepo, makeSession } from '@/test/fixtures/session-picker' +import { SessionPickerSheet } from './SessionPickerSheet' + +const mocks = vi.hoisted(() => ({ + sessionsData: [] as Session[], + lastDirectories: [] as string[], + lastAllDirectories: false, + createSessionDirectories: [] as string[], + deleteSessionMock: vi.fn(), + locationData: undefined as + | { directory: string; project: { id: string; directory: string; canonical: string } } + | undefined, + getOpenCodeLocation: vi.fn(), + listProjects: vi.fn(), + getSession: vi.fn(), + repos: [] as Repo[], + activeWorkGroups: { needsInput: [], running: [], recent: [] } as ActiveWorkGroups, +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: () => ({ preferences: undefined }), +})) + +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getOpenCodeLocation: mocks.getOpenCodeLocation, + listProjects: mocks.listProjects, + getSession: mocks.getSession, + } +}) + +vi.mock('@/api/repos', () => ({ + listRepos: vi.fn().mockResolvedValue([]), + createRepo: vi.fn(), + discoverRepos: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: ( + directories: string[], + options?: { search?: string; allDirectories?: boolean }, + ) => { + mocks.lastDirectories = directories + mocks.lastAllDirectories = options?.allDirectories ?? false + const search = options?.search?.toLowerCase() ?? '' + const base = directories.length === 0 && !options?.allDirectories ? [] : mocks.sessionsData + const data = search + ? base.filter((session) => (session.title ?? '').toLowerCase().includes(search)) + : base + return { + data, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isFetchNextPageError: false, + } + }, + useDeleteSession: () => ({ + mutateAsync: mocks.deleteSessionMock, + isPending: false, + }), + useCreateSession: (directory: string, onSuccess?: (session: { id: string }) => void) => ({ + mutate: () => { + mocks.createSessionDirectories.push(directory) + onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }), + } +}) + +vi.mock('@/hooks/useSessionPins', () => ({ + useSessionPins: () => ({ data: [] }), + useToggleSessionPin: () => ({ mutate: vi.fn() }), +})) + +vi.mock('@/hooks/useSidebarRepoGroups', () => ({ + useNavigableRepos: () => ({ repos: mocks.repos, isLoading: false, refetch: vi.fn() }), +})) + +vi.mock('@/hooks/useActiveWork', () => ({ + useActiveWork: () => ({ groups: mocks.activeWorkGroups, isLoading: false, isError: false }), + useActivityWindowMinutes: () => DEFAULT_ACTIVITY_WINDOW_MINUTES, + useProjectActivityState: () => null, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: () => ({ + servers: [{ id: 'srv_kitchen', name: 'Kitchen', enabled: true }], + isLoading: false, + }), + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + +vi.mock('@/components/navigation/LinkedServersNav', () => ({ + LinkedServersNav: () =>
, +})) + +vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useSessionStatusForSession: () => ({ type: 'idle' }) } +}) + +function activeWorkItem( + id: string, + directory: string, + server?: { id: string; name: string }, +): ActiveWorkItem { + const now = Date.now() + return { + key: `${server?.id ?? LOCAL_OPENCODE_SERVER_ID}:${id}`, + state: 'recent', + session: makeSession(id, `Active ${id}`, directory, now), + repo: server ? null : makeRepo(1, 'alpha', directory), + serverId: server?.id ?? null, + serverName: server?.name ?? null, + updatedAt: now, + path: `/repos/1/sessions/${id}`, + } +} + +function renderSheet( + initialEntries: string[] = ['/repos/1?mobileTab=sessions'], + options: { isOpen?: boolean; cachedRepos?: Repo[] } = {}, +) { + const onClose = vi.fn() + const capturedPathname: { current: string } = { current: '' } + const capturedSearch: { current: string } = { current: '' } + const capturedNavigationType: { current: string } = { current: '' } + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + if (options.cachedRepos) queryClient.setQueryData(['repos'], options.cachedRepos) + render( + + + + + + + + + } + /> + + + + , + ) + return { + onClose, + location: () => + `${capturedPathname.current}${capturedSearch.current} ${capturedNavigationType.current}`, + } +} + +describe('SessionPickerSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + stubMatchMedia(true) + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.repos = [makeRepo(1, 'alpha', '/w/a'), makeRepo(2, 'beta', '/w/b')] + mocks.sessionsData = [] + mocks.lastDirectories = [] + mocks.lastAllDirectories = false + mocks.createSessionDirectories = [] + mocks.activeWorkGroups = { needsInput: [], running: [], recent: [] } + mocks.listProjects.mockResolvedValue([]) + mocks.locationData = { + directory: '/w/a', + project: { id: 'global', directory: '/w/a', canonical: '/w/a' }, + } + mocks.getOpenCodeLocation.mockResolvedValue(mocks.locationData) + mocks.getSession.mockResolvedValue(null) + }) + + afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + }) + + it('renders the picker in a bottom sheet with Activity, Sessions and Projects tabs', () => { + renderSheet(['/']) + + expect(screen.getByRole('dialog', { name: 'Sessions' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Projects' })).toBeInTheDocument() + expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() + }) + + it('navigates to the chosen session, replacing the sheet history entry', async () => { + mocks.sessionsData = [makeSession('ses_one', 'Solo session', '/w/a')] + + const { location } = renderSheet() + + const row = await screen.findByRole('option', { name: /Solo session/ }) + fireEvent.click(row) + + expect(location()).toBe('/repos/1/sessions/ses_one REPLACE') + }) + + it('closes instead of navigating when the chosen session is already open', async () => { + mocks.sessionsData = [makeSession('ses_one', 'Solo session', '/w/a')] + + const { onClose, location } = renderSheet(['/repos/1/sessions/ses_one?mobileTab=sessions']) + + const row = await screen.findByRole('option', { name: /Solo session/ }) + fireEvent.click(row) + + expect(onClose).toHaveBeenCalled() + expect(location()).toBe('/repos/1/sessions/ses_one?mobileTab=sessions POP') + }) + + it('creates a session in the current repo from the header action and opens it', async () => { + const { location } = renderSheet() + + fireEvent.click(await screen.findByRole('button', { name: 'New session in alpha' })) + + expect(mocks.createSessionDirectories).toEqual(['/w/a']) + expect(location()).toBe('/repos/1/sessions/ses_new REPLACE') + }) + + it('opens the add repository dialog from the projects tab header action', async () => { + const { onClose } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Projects' })) + fireEvent.click(await screen.findByRole('button', { name: 'Add repository' })) + + expect(await screen.findByRole('dialog', { name: 'Add Repository' })).toBeInTheDocument() + expect(onClose).toHaveBeenCalled() + }) + + it('lists and opens the linked server sessions when a linked server is selected', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.sessionsData = [makeSession('ses_remote', 'Remote session', '/home/me/app')] + + const { location } = renderSheet() + + const row = await screen.findByRole('option', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + expect(mocks.lastDirectories).toEqual([]) + expect(mocks.lastAllDirectories).toBe(true) + expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'This project' })).not.toBeInTheDocument() + + fireEvent.click(row) + + expect(location()).toBe('/servers/srv_kitchen/sessions/ses_remote REPLACE') + }) + + it('lists the linked server projects on the projects tab and opens one', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.listProjects.mockResolvedValue([ + { + id: 'p1', + name: 'Alpha', + canonical: '/projects/alpha', + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + + const { location } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Projects' })) + fireEvent.click(await screen.findByText('Alpha')) + + expect(location()).toBe('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha REPLACE') + }) + + it('follows the sidebar server selection to list that server', async () => { + mocks.sessionsData = [makeSession('ses_remote', 'Remote session', '/home/me/app')] + + renderSheet() + + act(() => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + }) + + const row = await screen.findByRole('option', { name: /Remote session/ }) + expect(row).toHaveTextContent('Kitchen') + expect(screen.queryByRole('tab', { name: 'This project' })).not.toBeInTheDocument() + }) + + it('lists activity sessions and opens one', async () => { + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_active', '/w/a')], + } + + const { location } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_active/ })) + + expect(location()).toBe('/repos/1/sessions/ses_active REPLACE') + }) + + it('opens a remote activity row on its own server while the local server is selected', async () => { + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_kitchen', '/home/me/app', { id: 'srv_kitchen', name: 'Kitchen' })], + } + + const { location } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_kitchen/ })) + + expect(location()).toBe('/servers/srv_kitchen/sessions/ses_kitchen REPLACE') + }) + + it('opens a remote activity row of the same session id that is open on another server', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_shared', '/home/me/hall', { id: 'srv_hall', name: 'Hall' })], + } + + const { onClose, location } = renderSheet([ + '/servers/srv_kitchen/sessions/ses_shared?mobileTab=sessions', + ]) + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_shared/ })) + + expect(onClose).not.toHaveBeenCalled() + expect(location()).toBe('/servers/srv_hall/sessions/ses_shared REPLACE') + }) + + it('opens a local session row that shares the id of the open remote session', async () => { + mocks.sessionsData = [makeSession('ses_shared', 'Shared session', '/w/a')] + + const { onClose, location } = renderSheet([ + '/servers/srv_kitchen/sessions/ses_shared?mobileTab=sessions', + ]) + + fireEvent.click(await screen.findByRole('option', { name: /Shared session/ })) + + expect(onClose).not.toHaveBeenCalled() + expect(location()).toBe('/repos/1/sessions/ses_shared REPLACE') + }) + + it('opens a session row of another linked server than the selected one', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.activeWorkGroups = { + needsInput: [], + running: [], + recent: [activeWorkItem('ses_hall', '/home/me/hall', { id: 'srv_hall', name: 'Hall' })], + } + + const { location } = renderSheet() + + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) + fireEvent.click(await screen.findByRole('option', { name: /Active ses_hall/ })) + + expect(location()).toBe('/servers/srv_hall/sessions/ses_hall REPLACE') + }) + + it('keeps the assistant session flag when choosing another session of the same repo', async () => { + mocks.sessionsData = [makeSession('ses_b', 'Second assistant session', '/w/a')] + + const { location } = renderSheet(['/repos/1/sessions/ses_a?assistant=1&mobileTab=sessions']) + + fireEvent.click(await screen.findByRole('option', { name: /Second assistant session/ })) + + expect(location()).toBe('/repos/1/sessions/ses_b?assistant=1 REPLACE') + }) + + it('drops the assistant session flag when choosing a session of another repo', async () => { + localStorage.setItem('oc:session-picker:all-projects', 'true') + mocks.sessionsData = [ + makeSession('ses_a', 'Assistant session', '/w/a'), + makeSession('ses_b', 'Beta session', '/w/b'), + ] + + const { location } = renderSheet(['/repos/1/sessions/ses_a?assistant=1&mobileTab=sessions']) + + fireEvent.click(await screen.findByRole('option', { name: /Beta session/ })) + + expect(location()).toBe('/repos/2/sessions/ses_b REPLACE') + }) + + it('scopes the sheet to the open remote session directory on a linked server session route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.getSession.mockResolvedValue(makeSession('ses_remote', 'Remote session', '/home/me/app')) + + renderSheet(['/servers/srv_kitchen/sessions/ses_remote?mobileTab=sessions']) + + await waitFor(() => expect(mocks.lastDirectories).toEqual(['/home/me/app'])) + expect(mocks.lastAllDirectories).toBe(false) + }) + + it('scopes the sheet to the canonical project directory of a remote session subdirectory', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.getSession.mockResolvedValue(makeSession('ses_remote', 'Remote session', '/home/me/app/packages/web')) + mocks.listProjects.mockResolvedValue([ + { + id: 'p1', + name: 'App', + canonical: '/home/me/app', + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + + renderSheet(['/servers/srv_kitchen/sessions/ses_remote?mobileTab=sessions']) + + await waitFor(() => expect(mocks.lastDirectories).toEqual(['/home/me/app'])) + expect(mocks.lastAllDirectories).toBe(false) + }) + + it('holds the all-directories search while the remote session directory resolves', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.getSession.mockReturnValue(new Promise(() => undefined)) + + renderSheet(['/servers/srv_kitchen/sessions/ses_remote?mobileTab=sessions']) + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalled()) + expect(mocks.lastDirectories).toEqual([]) + expect(mocks.lastAllDirectories).toBe(false) + }) + + it('returns to the repo session list keeping its tab when the open session is deleted', async () => { + mocks.sessionsData = [makeSession('ses_active', 'Active session', '/w/a')] + mocks.deleteSessionMock.mockResolvedValue(undefined) + + const { location } = renderSheet(['/repos/1/sessions/ses_active?mobileTab=sessions&repoTab=changes']) + + const combobox = await screen.findByRole('combobox') + await screen.findByRole('option', { name: /Active session/ }) + fireEvent.keyDown(combobox, { key: 'd', ctrlKey: true }) + expect(screen.getByText('Press Ctrl+D again to confirm')).toBeInTheDocument() + fireEvent.keyDown(combobox, { key: 'd', ctrlKey: true }) + + await waitFor(() => expect(mocks.deleteSessionMock).toHaveBeenCalled()) + expect(location()).toBe('/repos/1?repoTab=changes REPLACE') + }) + + it('does not resolve the remote session directory while the sheet is closed', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderSheet(['/servers/srv_kitchen/sessions/ses_remote'], { isOpen: false }) + + await waitFor(() => expect(mocks.getSession).not.toHaveBeenCalled()) + }) + + it('prefetches the project location on a repo route while the sheet is closed', async () => { + renderSheet(['/repos/1'], { isOpen: false, cachedRepos: mocks.repos }) + + await waitFor(() => + expect(mocks.getOpenCodeLocation).toHaveBeenCalledWith('/w/a', LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('does not prefetch the project location outside a repo route', async () => { + renderSheet(['/'], { isOpen: false, cachedRepos: mocks.repos }) + + await waitFor(() => expect(mocks.getOpenCodeLocation).not.toHaveBeenCalled()) + }) +}) diff --git a/frontend/src/components/session/SessionPickerSheet.tsx b/frontend/src/components/session/SessionPickerSheet.tsx new file mode 100644 index 000000000..7d6bc4ceb --- /dev/null +++ b/frontend/src/components/session/SessionPickerSheet.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' +import { BottomSheet } from '@/components/ui/bottom-sheet' +import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' +import { AddRepoDialog } from '@/components/repo/AddRepoDialog' +import { SessionPickerPanel } from '@/components/session/SessionPickerDialog' +import { useSessionPickerRouteScope } from '@/hooks/useSessionPickerRouteScope' +import { useSessionPickerLocationPrefetch } from '@/hooks/useSessionPickerLocationPrefetch' +import { useSessionPickerNavigation } from '@/hooks/useSessionPickerNavigation' + +interface SessionPickerSheetProps { + isOpen: boolean + onClose: () => void +} + +export function SessionPickerSheet({ isOpen, onClose }: SessionPickerSheetProps) { + const [addRepoOpen, setAddRepoOpen] = useState(false) + const { currentRepo, activeSessionID, remote } = useSessionPickerRouteScope(isOpen) + const pickerNavigation = useSessionPickerNavigation({ mode: 'replace', onClose }) + useSessionPickerLocationPrefetch() + + const panel = isOpen ? ( + } + onAddRepo={() => { + onClose() + setAddRepoOpen(true) + }} + /> + ) : null + + return ( + <> + + {panel} + + + + ) +} diff --git a/frontend/src/components/source-control/BranchesTab.test.tsx b/frontend/src/components/source-control/BranchesTab.test.tsx index d1fa416ed..971b1682e 100644 --- a/frontend/src/components/source-control/BranchesTab.test.tsx +++ b/frontend/src/components/source-control/BranchesTab.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BranchesTab } from './BranchesTab' @@ -39,7 +39,8 @@ vi.mock('@/components/repo/CreateWorktreeDialog', () => ({ CreateWorktreeDialog: () => null, })) -import { listBranches, getRepo } from '@/api/repos' +import { listBranches, getRepo, switchBranch } from '@/api/repos' +import { fetchGitStatus } from '@/api/git' const createWrapper = () => { const queryClient = new QueryClient({ @@ -89,4 +90,35 @@ describe('BranchesTab', () => { expect(await screen.findByText('origin/feature')).toBeInTheDocument() }) + + it('invalidates the vcs info of the switched repository', async () => { + vi.mocked(switchBranch).mockResolvedValue({ + id: 1, + fullPath: '/abs/repos/my-repo', + currentBranch: 'feature', + } as never) + vi.mocked(fetchGitStatus).mockResolvedValue({ + branch: 'feature', + ahead: 0, + behind: 0, + files: [], + hasChanges: false, + operation: null, + }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + const user = userEvent.setup() + render(, { + wrapper: ({ children }) => {children}, + }) + + await user.click(await screen.findByRole('button', { name: /^feature/ })) + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['opencode', 'vcs', '/abs/repos/my-repo', 'info'], + exact: true, + })) + }) }) diff --git a/frontend/src/components/source-control/BranchesTab.tsx b/frontend/src/components/source-control/BranchesTab.tsx index 1a1853577..73370345d 100644 --- a/frontend/src/components/source-control/BranchesTab.tsx +++ b/frontend/src/components/source-control/BranchesTab.tsx @@ -14,7 +14,7 @@ import { useGit } from '@/hooks/useGit' import { GIT_UI_COLORS } from '@/lib/git-status-styles' import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog' import { IntegrateBranchDialog } from '@/components/source-control/IntegrateBranchDialog' -import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation' +import { invalidateRepoGitCaches, invalidateVcsInfoCache, setRepoGitStatusCaches } from '@/lib/queryInvalidation' interface BranchesTabProps { repoId: number @@ -72,6 +72,7 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) { queryClient.setQueryData(['repo', repoId], updatedRepo) setRepoGitStatusCaches(queryClient, repoId, status) invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false }) + invalidateVcsInfoCache(queryClient, updatedRepo.fullPath) refetch() showToast.success(`Switched to branch: ${updatedRepo.currentBranch}`) }, diff --git a/frontend/src/components/source-control/RemoteSourceControlContent.tsx b/frontend/src/components/source-control/RemoteSourceControlContent.tsx index 645fa10c4..a577c2431 100644 --- a/frontend/src/components/source-control/RemoteSourceControlContent.tsx +++ b/frontend/src/components/source-control/RemoteSourceControlContent.tsx @@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react' import { getVcsDiff, - getVcsInfo, listVcsStatus, type VcsFileDiff, type VcsFileStatus, @@ -16,6 +15,7 @@ import { cn } from '@/lib/utils' import { serverScopedKey } from '@/lib/serverScope' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { useMobile } from '@/hooks/useMobile' +import { useVcsInfo } from '@/hooks/useVcsInfo' import { GIT_UI_COLORS } from '@/lib/git-status-styles' import type { GitFileStatus } from '@/types/git' @@ -60,11 +60,7 @@ export function RemoteSourceControlContent({ [directory, serverId], ) - const infoQuery = useQuery({ - queryKey: [...vcsKey, 'info'], - queryFn: () => getVcsInfo(directory, serverId), - retry: false, - }) + const infoQuery = useVcsInfo(directory, serverId) const statusQuery = useQuery({ queryKey: [...vcsKey, 'status'], diff --git a/frontend/src/components/ui/bottom-sheet.test.tsx b/frontend/src/components/ui/bottom-sheet.test.tsx index 77e94f03e..307743b24 100644 --- a/frontend/src/components/ui/bottom-sheet.test.tsx +++ b/frontend/src/components/ui/bottom-sheet.test.tsx @@ -1,8 +1,15 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { useState } from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { stubMatchMedia } from '@/test/test-utils' import { BottomSheet, BottomSheetHeader, BottomSheetContent } from './bottom-sheet' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { useSwipeDismiss } from '@/hooks/useMobile' vi.mock('@/hooks/useMobile', () => ({ @@ -36,6 +43,34 @@ function AutoFocusSheetHost() { ) } +function OpenSheetHost({ onClose }: { onClose: () => void }) { + const [open, setOpen] = useState(false) + return ( + <> + + { onClose(); setOpen(false) }} ariaLabel="Test sheet"> + + + + + ) +} + +function SheetWithMenu({ onClose }: { onClose: () => void }) { + return ( + + + + + + + Delete session + + + + ) +} + afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') }) @@ -43,6 +78,11 @@ afterEach(() => { describe('BottomSheet', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(useSwipeDismiss).mockReturnValue({ + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: {}, + }) }) it('renders when isOpen is true', () => { @@ -55,17 +95,56 @@ describe('BottomSheet', () => { }) it('does not render when isOpen is false', async () => { - const { container } = render( + render( {}} ariaLabel="Test sheet">
Test content
, ) await waitFor(() => { - expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) - it('calls onClose when backdrop is clicked', () => { + it('exposes the sheet as a labelled modal dialog', () => { + render( + {}} ariaLabel="Test sheet"> +
Test content
+
, + ) + + const dialog = screen.getByRole('dialog', { name: 'Test sheet' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(dialog).toHaveAttribute('data-state', 'open') + }) + + it('moves focus into the sheet panel on open', () => { + render( + {}} ariaLabel="Test sheet"> + + , + ) + + expect(screen.getByRole('dialog', { name: 'Test sheet' })).toHaveFocus() + }) + + it('keeps tab focus inside the sheet', () => { + render( + {}} ariaLabel="Test sheet"> + + + , + ) + + const first = screen.getByRole('button', { name: 'first' }) + const last = screen.getByRole('button', { name: 'last' }) + last.focus() + + fireEvent.keyDown(last, { key: 'Tab' }) + + expect(first).toHaveFocus() + }) + + it('calls onClose when the backdrop is clicked', async () => { const handleClose = vi.fn() render( @@ -73,10 +152,12 @@ describe('BottomSheet', () => { , ) const backdrop = document.querySelector('.bg-black\\/40') - if (backdrop) { - fireEvent.click(backdrop) - } - expect(handleClose).toHaveBeenCalled() + if (!backdrop) throw new Error('Missing sheet backdrop') + + await new Promise((resolve) => setTimeout(resolve, 0)) + fireEvent.pointerDown(backdrop) + + await waitFor(() => expect(handleClose).toHaveBeenCalledTimes(1)) }) it('calls onClose when Escape key is pressed', () => { @@ -87,7 +168,26 @@ describe('BottomSheet', () => { , ) fireEvent.keyDown(document, { key: 'Escape' }) - expect(handleClose).toHaveBeenCalled() + expect(handleClose).toHaveBeenCalledTimes(1) + }) + + it('closes only the nested menu when Escape is pressed inside it', async () => { + const user = userEvent.setup() + const handleClose = vi.fn() + render() + + await user.click(screen.getByRole('button', { name: 'row actions' })) + expect(await screen.findByRole('menu')).toBeInTheDocument() + + await user.keyboard('{Escape}') + + await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument()) + expect(handleClose).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: 'Test sheet' })).toBeInTheDocument() + + await user.keyboard('{Escape}') + + expect(handleClose).toHaveBeenCalledTimes(1) }) it('applies pb-safe class', () => { @@ -96,7 +196,7 @@ describe('BottomSheet', () => {
Test content
, ) - const dialog = document.querySelector('[role="dialog"]') + const dialog = screen.getByRole('dialog', { name: 'Test sheet' }) expect(dialog).toBeInTheDocument() }) @@ -118,7 +218,7 @@ describe('BottomSheet', () => {
Test content
, ) - const dialog = document.querySelector('[role="dialog"]') + const dialog = screen.getByRole('dialog', { name: 'Test sheet' }) expect(dialog).toHaveClass('h-[50dvh]') }) @@ -142,6 +242,19 @@ describe('BottomSheet', () => { }) }) + it('disables the swipe gesture when the sheet is not dismissible', () => { + render( + {}} ariaLabel="Test sheet"> +
Test content
+
, + ) + + expect(useSwipeDismiss).toHaveBeenCalledWith(expect.any(Function), { + enabled: false, + threshold: 60, + }) + }) + it('binds gesture to the panel element with aria-modal', () => { const mockBind = vi.fn() vi.mocked(useSwipeDismiss).mockReturnValue({ @@ -159,10 +272,10 @@ describe('BottomSheet', () => { const panelElement = document.querySelector('[aria-modal="true"]') expect(panelElement).toBeInTheDocument() - expect(mockBind).toHaveBeenCalled() + expect(mockBind).toHaveBeenCalledWith(panelElement) }) - it('moves focus to the chat prompt on desktop when it closes', () => { + it('moves focus to the chat prompt on desktop when it closes', async () => { stubMatchMedia(true) const { rerender } = renderSheet(false) @@ -185,10 +298,10 @@ describe('BottomSheet', () => { , ) - expect(screen.getByLabelText('prompt')).toHaveFocus() + await waitFor(() => expect(screen.getByLabelText('prompt')).toHaveFocus()) }) - it('does not refocus the chat prompt on touch when it closes', () => { + it('does not refocus the chat prompt on touch when it closes', async () => { stubMatchMedia(false) const { rerender } = renderSheet(false) @@ -211,6 +324,7 @@ describe('BottomSheet', () => { , ) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) expect(screen.getByLabelText('prompt')).not.toHaveFocus() }) @@ -228,7 +342,7 @@ describe('BottomSheet', () => { await waitFor(() => expect(opener).toHaveFocus()) }) - it('captures the opener when mounted already open and restores it on close', () => { + it('captures the opener when mounted already open and restores it on close', async () => { stubMatchMedia(true) const { rerender } = render( , @@ -254,7 +368,22 @@ describe('BottomSheet', () => { , ) - expect(opener).toHaveFocus() + await waitFor(() => expect(opener).toHaveFocus()) + }) + + it('returns focus to the opener after the sheet closes through the keyboard', async () => { + const user = userEvent.setup() + const handleClose = vi.fn() + render() + + const opener = screen.getByRole('button', { name: 'open sheet' }) + await user.click(opener) + await waitFor(() => expect(screen.getByRole('dialog', { name: 'Test sheet' })).toHaveFocus()) + + await user.keyboard('{Escape}') + + expect(handleClose).toHaveBeenCalledTimes(1) + await waitFor(() => expect(opener).toHaveFocus()) }) }) diff --git a/frontend/src/components/ui/bottom-sheet.tsx b/frontend/src/components/ui/bottom-sheet.tsx index 14b7fa6fc..c37007fb5 100644 --- a/frontend/src/components/ui/bottom-sheet.tsx +++ b/frontend/src/components/ui/bottom-sheet.tsx @@ -1,9 +1,15 @@ -import { useEffect, useState, useRef } from 'react' -import { createPortal } from 'react-dom' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + Content as DialogContent, + Overlay as DialogOverlay, + Portal as DialogPortal, + Root as DialogRoot, + Title as DialogTitle, +} from '@radix-ui/react-dialog' import { cn } from '@/lib/utils' import { MODAL_TRANSITION_MS } from '@/lib/utils' import { useSwipeDismiss } from '@/hooks/useMobile' -import { useRestoreFocusOnClose } from '@/hooks/useRestoreFocusOnClose' +import { useOverlayReturnFocus } from '@/hooks/useOverlayReturnFocus' export interface BottomSheetProps { isOpen: boolean @@ -12,6 +18,7 @@ export interface BottomSheetProps { className?: string children: React.ReactNode ariaLabel?: string + dismissible?: boolean } export function BottomSheet({ @@ -21,20 +28,25 @@ export function BottomSheet({ className, children, ariaLabel, + dismissible = true, }: BottomSheetProps) { const [shouldRender, setShouldRender] = useState(false) + const { handleCloseAutoFocus } = useOverlayReturnFocus(isOpen) const panelRef = useRef(null) - - useRestoreFocusOnClose(isOpen, panelRef) + const [panelElement, setPanelElement] = useState(null) + const setPanelRef = useCallback((node: HTMLDivElement | null) => { + panelRef.current = node + setPanelElement(node) + }, []) const { bind, swipeStyles } = useSwipeDismiss(onClose, { - enabled: isOpen, + enabled: isOpen && dismissible, threshold: 60, }) useEffect(() => { - return bind(panelRef.current) - }, [bind]) + return bind(panelElement) + }, [bind, panelElement]) useEffect(() => { if (isOpen) { @@ -45,64 +57,59 @@ export function BottomSheet({ } }, [isOpen]) - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen) { - onClose() - } - } + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) onClose() + } - if (isOpen) { - document.addEventListener('keydown', handleEscape) - document.body.style.overflow = 'hidden' - } - - return () => { - document.removeEventListener('keydown', handleEscape) - document.body.style.overflow = 'unset' - } - }, [isOpen, onClose]) + const handleOpenAutoFocus = (event: Event) => { + event.preventDefault() + panelRef.current?.focus({ preventScroll: true }) + } if (!isOpen && !shouldRender) return null const sheet = ( -
-
@@ -1034,29 +1049,27 @@ export function SessionDetail() { activeSessionID={sessionId || undefined} remote={ isRemoteServer - ? { serverId, serverName: linkedServer?.name, projectDirectory: remoteProjectDirectory } + ? { + serverId, + serverName: linkedServer?.name, + projectDirectory: remoteProjectDirectory, + isPending: sessionLoading, + } : undefined } - onSelectSession={(session, sessionRepo) => { - if (!isRemoteServer && sessionRepo.id === repoId) { - navigate(`/repos/${repoId}/sessions/${session.id}${sessionRouteSuffix}`) - } else { - navigate(getSessionPath(sessionRepo.id, session.id)) - } - setSessionsDialogOpen(false) - }} - onSelectRemoteSession={(session, targetServerId) => { - navigate(getServerSessionPath(targetServerId, session.id)) - setSessionsDialogOpen(false) - }} - onDeleteActiveSession={() => { - setSessionsDialogOpen(false) - handleCloseSession() - }} - onSelectRepo={(selectedRepo) => { - navigate(getRepoPath(selectedRepo.id)) - setSessionsDialogOpen(false) - }} + {...pickerNavigation} + /> + + {sideQuestion && sessionId && ( @@ -1090,7 +1103,7 @@ export function SessionDetail() { isOpen={!docked && fileBrowserOpen} onClose={handleFileBrowserClose} basePath={workspaceBasePath} - repoName={workspaceDisplayName} + repoName={projectDisplayName} repoId={repoId} initialSelectedFile={selectedFilePath} /> @@ -1148,7 +1161,7 @@ export function SessionDetail() { isOpen={!docked && sourceControlOpen} onClose={() => setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} - repoName={workspaceDisplayName} + repoName={projectDisplayName} /> )} diff --git a/frontend/src/pages/__tests__/RemoteProject.test.tsx b/frontend/src/pages/__tests__/RemoteProject.test.tsx index 1c4053a2f..324677ca0 100644 --- a/frontend/src/pages/__tests__/RemoteProject.test.tsx +++ b/frontend/src/pages/__tests__/RemoteProject.test.tsx @@ -22,10 +22,10 @@ const mocks = vi.hoisted(() => ({ openTab: vi.fn(), })) -vi.mock('@/api/opencode', () => ({ - listProjects: mocks.listProjects, - getVcsInfo: mocks.getVcsInfo, -})) +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: mocks.listProjects, getVcsInfo: mocks.getVcsInfo } +}) vi.mock('@/hooks/useOpenCode', () => ({ useCreateSession: mocks.useCreateSession, diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index 75efb2a8e..5d0b29fa5 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -124,6 +124,7 @@ vi.mock('@/hooks/useSettingsDialog', () => ({ vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), + useSwipeDismiss: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), })) vi.mock('@/hooks/useVisualViewport', () => ({ @@ -476,6 +477,37 @@ describe('SessionDetail command actions', () => { }) }) + it('opens the sessions picker dialog from the sessions command on desktop', async () => { + const { SessionPickerDialog } = await import('@/components/session/SessionPickerDialog') + + renderSessionDetail() + const showSessions = await commandHandler('showSessions') + + act(() => { + showSessions() + }) + + const props = vi.mocked(SessionPickerDialog).mock.calls.at(-1)?.[0] as Record + expect(props).toMatchObject({ open: true }) + }) + + it('opens the mobile sessions sheet instead of the dialog from the sessions command', async () => { + const { SessionPickerDialog } = await import('@/components/session/SessionPickerDialog') + mocks.useMobile.mockReturnValue(true) + const captured = { search: { current: '' }, pathname: { current: '' } } + + renderSessionDetail(captured) + const showSessions = await commandHandler('showSessions') + + act(() => { + showSessions() + }) + + await waitFor(() => expect(captured.search.current).toBe('?mobileTab=sessions')) + const props = vi.mocked(SessionPickerDialog).mock.calls.at(-1)?.[0] as Record + expect(props).toMatchObject({ open: false }) + }) + it('shows the error toast when the transcript fetch fails', async () => { mocks.listSessionMessages.mockRejectedValueOnce(new Error('page fetch failed')) mocks.copyTextToClipboard.mockImplementation(async (content: string | Promise) => { diff --git a/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx b/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx index e8eea957c..b1b55e60d 100644 --- a/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.not-found.test.tsx @@ -53,7 +53,7 @@ vi.mock('@/hooks/useModelSelection', () => ({ vi.mock('@/hooks/useTTS', () => ({ useTTS: vi.fn(() => ({ isEnabled: false })) })) vi.mock('@/hooks/useSettings', () => ({ useSettings: mocks.useSettings })) vi.mock('@/hooks/useSettingsDialog', () => ({ useSettingsDialog: mocks.useSettingsDialog })) -vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) +vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })) })) vi.mock('@/hooks/useVisualViewport', () => ({ useVisualViewport: mocks.useVisualViewport })) vi.mock('@/contexts/KeyboardShortcutsContext', () => ({ useShortcutActions: mocks.useShortcutActions })) vi.mock('@/hooks/useAutoScroll', () => ({ useAutoScroll: mocks.useAutoScroll })) @@ -87,7 +87,6 @@ vi.mock('@/components/message/MessageThread', () => ({ MessageThread: mocks.Mess vi.mock('@/components/message/PromptInput', () => ({ PromptInput: mocks.PromptInput })) vi.mock('@/components/message/FloatingTTSButton', () => ({ FloatingTTSButton: () => null })) vi.mock('@/components/message/MessageSkeleton', () => ({ MessageSkeleton: () => null })) -vi.mock('@/components/session/ContextUsageIndicator', () => ({ ContextUsageIndicator: () => null })) vi.mock('@/components/session/SessionSendErrorBanner', () => ({ SessionSendErrorBanner: () => null })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: () => null })) vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: () => null })) diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index 4c2ef3712..3079727d7 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -8,7 +8,6 @@ import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNav import { getServerProjectPath } from '@/lib/navigation' import { LocationCatcher } from '@/test/test-utils' import { MessageThread } from '@/components/message/MessageThread' -import { ContextUsageIndicator } from '@/components/session/ContextUsageIndicator' import { SessionSendErrorBanner } from '@/components/session/SessionSendErrorBanner' import { PendingActionsGroup } from '@/components/notifications/PendingActionsGroup' import { SessionDetail } from '../SessionDetail' @@ -133,7 +132,6 @@ vi.mock('@/components/message/MessageThread', () => ({ vi.mock('@/components/message/PromptInput', () => ({ PromptInput: vi.fn(() => null) })) vi.mock('@/components/message/FloatingTTSButton', () => ({ FloatingTTSButton: vi.fn(() => null) })) vi.mock('@/components/message/MessageSkeleton', () => ({ MessageSkeleton: vi.fn(() => null) })) -vi.mock('@/components/session/ContextUsageIndicator', () => ({ ContextUsageIndicator: vi.fn(() => null) })) vi.mock('@/components/session/SessionSendErrorBanner', () => ({ SessionSendErrorBanner: vi.fn(() => null) })) vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: vi.fn(() => null) })) vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: mocks.SessionGoalBar })) @@ -279,20 +277,21 @@ describe('SessionDetail remote server mode', () => { expect(mocks.useRepoSiblings).toHaveBeenCalledWith(undefined) }) - it('titles the header with the server name and session directory', async () => { + it('shows the remote project in the header without the server name', async () => { renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen')).toHaveClass('text-info') + expect(screen.getByLabelText('Remote server')).toBeInTheDocument() }) - expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + expect(screen.getByText('app')).toBeInTheDocument() + expect(screen.queryByText(/Kitchen/)).not.toBeInTheDocument() }) it('returns to the project page from the header back button', async () => { const { capturedPathname, capturedSearch } = renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + expect(screen.getByLabelText('Remote server')).toBeInTheDocument() }) fireEvent.click(screen.getByRole('button', { name: 'Go back' })) @@ -452,9 +451,6 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ isConnected: false, isReconnecting: true }), - ) expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: false, isReconnecting: true }), ) @@ -467,9 +463,6 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ isConnected: true, isReconnecting: false }), - ) expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: true, isReconnecting: false }), ) @@ -484,11 +477,34 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByTestId('message-thread')).toBeInTheDocument() }) - expect(vi.mocked(ContextUsageIndicator).mock.calls.at(-1)?.[0]).toEqual( + expect(vi.mocked(SessionSendErrorBanner).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ isConnected: false, isReconnecting: true }), ) }) + it('navigates to the parent session from the header pill', async () => { + mocks.useSession.mockReturnValue({ + data: { ...remoteSession, parentID: 'ses_parent' }, + isLoading: false, + error: undefined, + }) + + const { capturedPathname } = renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Parent session' })) + + expect(capturedPathname.current).toBe('/servers/srv_kitchen/sessions/ses_parent') + }) + + it('opens the session details sheet from the header title', async () => { + renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Session details: Remote chat' })) + + expect(await screen.findByRole('dialog')).toHaveAccessibleName('Session details') + expect(screen.getByText('/home/me/app')).toBeInTheDocument() + }) + it('navigates child sessions within the linked server route', async () => { const { capturedPathname } = renderRemoteSession() @@ -524,6 +540,7 @@ describe('SessionDetail remote server mode', () => { serverId: 'srv_kitchen', serverName: 'Kitchen', projectDirectory: '/home/me/app', + isPending: false, }, }), ) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index 70b102259..52a379df2 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -79,6 +79,7 @@ vi.mock('@/hooks/useSettingsDialog', () => ({ vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile, useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), + useSwipeDismiss: vi.fn(() => ({ bind: vi.fn(), swipeProgress: 0, swipeStyles: {} })), })) vi.mock('@/hooks/useVisualViewport', () => ({ diff --git a/frontend/src/test/fixtures/session-picker.ts b/frontend/src/test/fixtures/session-picker.ts new file mode 100644 index 000000000..28bbc940e --- /dev/null +++ b/frontend/src/test/fixtures/session-picker.ts @@ -0,0 +1,40 @@ +import type { Repo, Session } from '@/api/types' + +/** + * Builds a session fixture for picker tests. + * + * @param id Session id. + * @param title Session title. + * @param directory Session working directory. + * @param updated Session creation and update timestamp. + */ +export function makeSession(id: string, title: string, directory: string, updated = Date.now()): Session { + return { + id, + projectID: 'proj', + title, + time: { created: updated, updated }, + location: { directory }, + cost: 0, + tokens: {}, + } as unknown as Session +} + +/** + * Builds a ready repo fixture for picker tests. + * + * @param id Repo id. + * @param name Repo name. + * @param fullPath Repo working directory. + */ +export function makeRepo(id: number, name: string, fullPath: string): Repo { + return { + id, + name, + localPath: fullPath, + fullPath, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + } +} diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx index 926bcb34e..d7b45c7ab 100644 --- a/frontend/src/test/test-utils.tsx +++ b/frontend/src/test/test-utils.tsx @@ -2,7 +2,7 @@ import { useEffect, type ReactNode } from 'react' import { renderHook } from '@testing-library/react' -import { MemoryRouter, useLocation } from 'react-router-dom' +import { MemoryRouter, useLocation, useNavigationType } from 'react-router-dom' import { vi } from 'vitest' import type { useSettings } from '@/hooks/useSettings' import type { VoiceConversationController } from '@/hooks/useVoiceConversation' @@ -89,14 +89,18 @@ export function renderHookWithRouter(renderFn: () => T, initialEntries?: stri export function LocationCatcher({ capturedSearch, capturedPathname, + capturedNavigationType, }: { capturedSearch: { current: string } capturedPathname?: { current: string } + capturedNavigationType?: { current: string } }) { const location = useLocation() + const navigationType = useNavigationType() useEffect(() => { capturedSearch.current = location.search if (capturedPathname) capturedPathname.current = location.pathname + if (capturedNavigationType) capturedNavigationType.current = navigationType }) return null }