From 666c1f0e0a2b89acee5ec980d594ae73bf272536 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:36:10 -0500 Subject: [PATCH 1/6] Chat folders, ChatService and the /api/chats REST API --- backend/src/index.ts | 6 + backend/src/routes/chats.ts | 63 +++++++ backend/src/services/chats.ts | 165 ++++++++++++++++ backend/src/services/sandbox/command.ts | 3 +- backend/test/routes/chats.test.ts | 132 +++++++++++++ backend/test/services/chats.test.ts | 177 ++++++++++++++++++ backend/test/services/sandbox/command.test.ts | 11 +- shared/src/config/defaults.ts | 1 + shared/src/config/env.ts | 2 + shared/src/schemas/chats.ts | 60 ++++++ shared/src/schemas/index.ts | 1 + shared/src/utils/repo.ts | 4 + 12 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 backend/src/routes/chats.ts create mode 100644 backend/src/services/chats.ts create mode 100644 backend/test/routes/chats.test.ts create mode 100644 backend/test/services/chats.test.ts create mode 100644 shared/src/schemas/chats.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 2768b5ecb..dd739b6fc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -39,6 +39,7 @@ import { createAuth } from './auth' import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' +import { createChatRoutes } from './routes/chats' import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' import { createSessionSandboxRoutes } from './routes/session-sandbox' import { createMultiRunRoutes } from './routes/multi-runs' @@ -69,6 +70,7 @@ import { SessionAncestry } from './services/session-ancestry' import { GoalOutcomeTracker } from './services/goal-outcomes' import { MultiRunService } from './services/multi-runs' import { ChangeWalkthroughService } from './services/change-walkthroughs' +import { ChatService } from './services/chats' import { opencodeServerManager } from './services/opencode-single-server' import { createOpenCodeClient } from './services/opencode/client' import { OpenCodeServerRegistry } from './services/opencode-server-registry' @@ -95,6 +97,7 @@ import { logger } from './utils/logger' import { getWorkspacePath, getReposPath, + getChatsPath, getConfigPath, getAgentsMdPath, getDatabasePath, @@ -220,6 +223,7 @@ try { await ensureDirectoryExists(getWorkspacePath()) await ensureDirectoryExists(getReposPath()) + await ensureDirectoryExists(getChatsPath()) await ensureDirectoryExists(getConfigPath()) logger.info('Workspace directories initialized') @@ -305,6 +309,7 @@ const goalOutcomes = new GoalOutcomeTracker() const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService, (serverId) => opencodeServerRegistry.getClient(serverId)) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient, sessionSettingsService, gitAuthService, gitService) +const chatService = new ChatService(openCodeClient) sseAggregator.onEvent((directory, event) => { sessionSandboxService.handleEvent(directory, event).catch((err) => { @@ -420,6 +425,7 @@ protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/secrets', createGlobalSecretRoutes(secretsService, 'secret')) protectedApi.route('/variables', createGlobalSecretRoutes(secretsService, 'variable')) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/chats', createChatRoutes(chatService)) protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService)) protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket)) protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resolveServerTerminalService)) diff --git a/backend/src/routes/chats.ts b/backend/src/routes/chats.ts new file mode 100644 index 000000000..0220bfa8e --- /dev/null +++ b/backend/src/routes/chats.ts @@ -0,0 +1,63 @@ +import { Hono } from 'hono' +import { CreateChatRequestSchema, ListChatsQuerySchema } from '@opencode-manager/shared/schemas' +import { ChatNotFoundError, ChatSessionMismatchError, type ChatService } from '../services/chats' +import { getErrorMessage } from '../utils/error-utils' +import { logger } from '../utils/logger' +import { handleOpenCodeError, parseJsonBody } from '../utils/route-helpers' + +export function createChatRoutes(chatService: ChatService) { + const app = new Hono() + + app.get('/', async (c) => { + const parsedQuery = ListChatsQuerySchema.safeParse(c.req.query()) + if (!parsedQuery.success) { + return c.json({ error: 'Invalid query', details: parsedQuery.error.issues }, 400) + } + + try { + return c.json(await chatService.list(parsedQuery.data)) + } catch (error) { + logger.error('Failed to list chats', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.get('/:chatId', async (c) => { + try { + const chat = await chatService.resolve(c.req.param('chatId')) + return chat ? c.json(chat) : c.json({ error: 'Chat not found' }, 404) + } catch (error) { + logger.error('Failed to read chat', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + app.post('/', async (c) => { + const body = await parseJsonBody(c, CreateChatRequestSchema, { allowEmpty: true }) + if (!body.ok) { + return body.response + } + + try { + return c.json(await chatService.create(body.data), 201) + } catch (error) { + return handleOpenCodeError(c, error, 'Chat request failed') + } + }) + + app.delete('/:chatId/sessions/:sessionId', async (c) => { + try { + return c.json(await chatService.deleteSession(c.req.param('chatId'), c.req.param('sessionId'))) + } catch (error) { + if (error instanceof ChatNotFoundError) { + return c.json({ error: error.message }, 404) + } + if (error instanceof ChatSessionMismatchError) { + return c.json({ error: error.message }, 400) + } + return handleOpenCodeError(c, error, 'Chat session delete failed') + } + }) + + return app +} diff --git a/backend/src/services/chats.ts b/backend/src/services/chats.ts new file mode 100644 index 000000000..46c9ed924 --- /dev/null +++ b/backend/src/services/chats.ts @@ -0,0 +1,165 @@ +import path from 'path' +import { randomBytes } from 'crypto' +import fs from 'fs/promises' +import { getChatsPath, getReposPath } from '@opencode-manager/shared/config/env' +import { parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { + ChatIdSchema, + parseChatIdCreatedAt, + type Chat, + type CreateChatRequest, + type DeleteChatSessionResponse, + type ListChatsQuery, + type ListChatsResponse, +} from '@opencode-manager/shared/schemas' +import type { OpenCodeClient } from './opencode/client' +import { ensureDirectoryExists } from './file-operations' +import { canonicalPathSync } from '../utils/fs-safe' + +const CHAT_ID_ATTEMPTS = 5 + +export class ChatNotFoundError extends Error { + constructor(chatId: string) { + super(`Chat not found: ${chatId}`) + this.name = 'ChatNotFoundError' + } +} + +export class ChatSessionMismatchError extends Error { + constructor(sessionId: string, chatId: string) { + super(`Session ${sessionId} does not belong to chat ${chatId}`) + this.name = 'ChatSessionMismatchError' + } +} + +function formatChatTimestamp(now: Date): string { + const iso = now.toISOString() + return [ + iso.slice(0, 4), + iso.slice(5, 7), + iso.slice(8, 10), + '-', + iso.slice(11, 13), + iso.slice(14, 16), + iso.slice(17, 19), + ].join('') +} + +/** Owns the lifecycle of chat folders and the OpenCode sessions created inside them. */ +export class ChatService { + constructor( + private readonly openCodeClient: OpenCodeClient, + private readonly root: () => string = getChatsPath, + private readonly now: () => Date = () => new Date(), + ) {} + + async list({ limit, cursor }: ListChatsQuery): Promise { + const chatIds = await this.listChatIds() + const ordered = cursor ? chatIds.filter((id) => id < cursor) : chatIds + const page = ordered.slice(0, limit) + const nextCursor = ordered.length > limit ? page[page.length - 1] : undefined + + return { + chats: page.map((id) => this.toChat(id)), + ...(nextCursor ? { nextCursor } : {}), + } + } + + async resolve(chatId: string): Promise { + if (!ChatIdSchema.safeParse(chatId).success) return null + + try { + const stats = await fs.stat(path.join(this.root(), chatId)) + return stats.isDirectory() ? this.toChat(chatId) : null + } catch { + return null + } + } + + async findByDirectory(directory: string): Promise { + const target = canonicalPathSync(path.resolve(directory)) + for (const id of await this.listChatIds()) { + const chat = this.toChat(id) + if (canonicalPathSync(chat.directory) === target) return chat + } + return null + } + + async create(input: CreateChatRequest): Promise<{ chat: Chat; sessionId: string }> { + const root = this.root() + await ensureDirectoryExists(root) + + const { id, directory } = await this.createFolder(root) + try { + const model = input.model ? parseOpenCodeModelRef(input.model) : undefined + const session = await this.openCodeClient.api.session.create({ + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.agent !== undefined ? { agent: input.agent } : {}), + ...(model ? { model } : {}), + location: { directory }, + }) + return { chat: this.toChat(id), sessionId: session.id } + } catch (error) { + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) + throw error + } + } + + async deleteSession(chatId: string, sessionId: string): Promise { + const chat = await this.resolve(chatId) + if (!chat) throw new ChatNotFoundError(chatId) + + const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const sessionDirectory = canonicalPathSync(path.resolve(session.location.directory)) + if (sessionDirectory !== canonicalPathSync(chat.directory)) { + throw new ChatSessionMismatchError(sessionId, chatId) + } + + await this.openCodeClient.api.session.remove({ sessionID: sessionId }) + const remaining = await this.openCodeClient.api.session.list({ + directory: chat.directory, + limit: 1, + }) + + const folderRemoved = remaining.data.length === 0 + if (folderRemoved) { + await fs.rm(chat.directory, { recursive: true, force: true }) + } + + return { deleted: true, folderRemoved } + } + + private async listChatIds(): Promise { + const root = this.root() + await ensureDirectoryExists(root) + const entries = await fs.readdir(root, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory() && ChatIdSchema.safeParse(entry.name).success) + .map((entry) => entry.name) + .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) + } + + private async createFolder(root: string): Promise<{ id: string; directory: string }> { + for (let attempt = 0; attempt < CHAT_ID_ATTEMPTS; attempt++) { + const id = `${formatChatTimestamp(this.now())}-${randomBytes(3).toString('hex')}` + const directory = path.join(root, id) + try { + await fs.mkdir(directory) + return { id, directory } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + throw new Error('Failed to allocate a unique chat folder') + } + + private toChat(id: string): Chat { + const directory = path.join(this.root(), id) + return { + id, + directory, + filesPath: path.relative(getReposPath(), directory), + createdAt: parseChatIdCreatedAt(id), + } + } +} diff --git a/backend/src/services/sandbox/command.ts b/backend/src/services/sandbox/command.ts index c07b5089c..d0ba2820b 100644 --- a/backend/src/services/sandbox/command.ts +++ b/backend/src/services/sandbox/command.ts @@ -9,6 +9,7 @@ import { getOpenCodeGlobalSkillsPath, getOpenCodeToolOutputPath, getOpenCodeWorktreesPath, + getChatsPath, getReposPath, getScheduleWorktreesPath, } from '@opencode-manager/shared/config/env' @@ -174,7 +175,7 @@ export function buildSandboxVerifyProvisionArgs(name: string): string[] { } export function sandboxProjectRoots(): string[] { - return [getReposPath(), getScheduleWorktreesPath(), getOpenCodeWorktreesPath(), getForgeWorktreesPath()] + return [getReposPath(), getChatsPath(), getScheduleWorktreesPath(), getOpenCodeWorktreesPath(), getForgeWorktreesPath()] } export function sandboxMountRoots(): string[] { diff --git a/backend/test/routes/chats.test.ts b/backend/test/routes/chats.test.ts new file mode 100644 index 000000000..14dd3f200 --- /dev/null +++ b/backend/test/routes/chats.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { ChatService } from '../../src/services/chats' +import { createChatRoutes } from '../../src/routes/chats' + +function createFakeClient() { + const session = { + create: vi.fn(async ({ location }: { location: { directory: string } }) => ({ + id: 'ses_created', + location, + })), + get: vi.fn(async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + location: { directory: '/tmp/other-chat' }, + })), + remove: vi.fn(async () => undefined), + list: vi.fn(async () => ({ data: [], cursor: {} })), + } + return { client: { api: { session } } as unknown as OpenCodeClient, session } +} + +const roots: string[] = [] + +function createApp(root: string) { + const fake = createFakeClient() + const app = new Hono() + app.route('/chats', createChatRoutes(new ChatService(fake.client, () => root))) + return { app, ...fake } +} + +function createRoot(): string { + const root = mkdtempSync(path.join(tmpdir(), 'chats-routes-')) + roots.push(root) + return root +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }) + } +}) + +describe('chat routes', () => { + it('lists chats and pages with the returned cursor', async () => { + const root = createRoot() + for (const id of ['20260101-000000-aaaaaa', '20260102-000000-bbbbbb']) { + mkdirSync(path.join(root, id)) + } + const { app } = createApp(root) + + const first = await app.request('/chats?limit=1') + expect(first.status).toBe(200) + const firstBody = await first.json() as { chats: Array<{ id: string }>; nextCursor?: string } + expect(firstBody.chats.map((chat) => chat.id)).toEqual(['20260102-000000-bbbbbb']) + expect(firstBody.nextCursor).toBe('20260102-000000-bbbbbb') + + const second = await app.request(`/chats?limit=1&cursor=${firstBody.nextCursor}`) + const secondBody = await second.json() as { chats: Array<{ id: string }>; nextCursor?: string } + expect(secondBody.chats.map((chat) => chat.id)).toEqual(['20260101-000000-aaaaaa']) + expect(secondBody.nextCursor).toBeUndefined() + }) + + it('rejects an invalid limit', async () => { + const { app } = createApp(createRoot()) + + const res = await app.request('/chats?limit=0') + expect(res.status).toBe(400) + }) + + it('returns a chat and 404 for unknown or malformed ids', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + mkdirSync(path.join(root, id)) + const { app } = createApp(root) + + const found = await app.request(`/chats/${id}`) + expect(found.status).toBe(200) + const body = await found.json() as { id: string; directory: string } + expect(body.id).toBe(id) + expect(body.directory).toBe(path.join(root, id)) + + expect((await app.request('/chats/20260203-040506-aaaaaa')).status).toBe(404) + expect((await app.request('/chats/not-a-chat')).status).toBe(404) + }) + + it('creates a chat from an empty body', async () => { + const root = createRoot() + const { app, session } = createApp(root) + + const res = await app.request('/chats', { method: 'POST' }) + expect(res.status).toBe(201) + const body = await res.json() as { chat: { id: string; directory: string }; sessionId: string } + expect(body.chat.id).toMatch(/^\d{8}-\d{6}-[0-9a-f]{6}$/) + expect(body.sessionId).toBe('ses_created') + expect(existsSync(body.chat.directory)).toBe(true) + expect(session.create).toHaveBeenCalledWith({ location: { directory: body.chat.directory } }) + }) + + it('deletes a chat session and reports the folder outcome', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = path.join(root, id) + mkdirSync(directory) + const { app, session } = createApp(root) + session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) + + const res = await app.request(`/chats/${id}/sessions/ses_1`, { method: 'DELETE' }) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ deleted: true, folderRemoved: true }) + expect(existsSync(directory)).toBe(false) + }) + + it('returns 400 for a session in another folder and 404 for an unknown chat', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + mkdirSync(path.join(root, id)) + const { app } = createApp(root) + + const mismatch = await app.request(`/chats/${id}/sessions/ses_1`, { method: 'DELETE' }) + expect(mismatch.status).toBe(400) + + const unknown = await app.request('/chats/20260203-040506-aaaaaa/sessions/ses_1', { method: 'DELETE' }) + expect(unknown.status).toBe(404) + + const malformed = await app.request('/chats/../../etc/sessions/ses_1', { method: 'DELETE' }) + expect(malformed.status).toBe(404) + }) +}) diff --git a/backend/test/services/chats.test.ts b/backend/test/services/chats.test.ts new file mode 100644 index 000000000..fdba7a9cf --- /dev/null +++ b/backend/test/services/chats.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { ChatNotFoundError, ChatService, ChatSessionMismatchError } from '../../src/services/chats' + +const CHAT_ID_PATTERN = /^\d{8}-\d{6}-[0-9a-f]{6}$/ + +interface FakeClient { + client: OpenCodeClient + session: { + create: ReturnType + get: ReturnType + remove: ReturnType + list: ReturnType + } +} + +function createFakeClient(): FakeClient { + const session = { + create: vi.fn(async () => ({ id: 'ses_new' })), + get: vi.fn(async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + location: { directory: '/tmp/other-chat' }, + })), + remove: vi.fn(async () => undefined), + list: vi.fn(async () => ({ data: [], cursor: {} })), + } + return { client: { api: { session } } as unknown as OpenCodeClient, session } +} + +function createChatFolder(root: string, id: string): string { + const directory = path.join(root, id) + mkdirSync(directory, { recursive: true }) + return directory +} + +const roots: string[] = [] + +function createRoot(): string { + const root = mkdtempSync(path.join(tmpdir(), 'chats-service-')) + roots.push(root) + return root +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }) + } +}) + +describe('ChatService', () => { + it('creates a chat folder matching the id format and leaves it empty', async () => { + const root = createRoot() + const fake = createFakeClient() + const service = new ChatService(fake.client, () => root, () => new Date('2026-01-02T03:04:05Z')) + + const result = await service.create({ title: 'Hello' }) + + expect(result.sessionId).toBe('ses_new') + expect(result.chat.id).toMatch(CHAT_ID_PATTERN) + expect(result.chat.id.startsWith('20260102-030405-')).toBe(true) + expect(result.chat.directory).toBe(path.join(root, result.chat.id)) + expect(result.chat.createdAt).toBe(Date.UTC(2026, 0, 2, 3, 4, 5)) + expect(readdirSync(result.chat.directory)).toEqual([]) + expect(fake.session.create).toHaveBeenCalledWith({ + title: 'Hello', + location: { directory: result.chat.directory }, + }) + }) + + it('removes the new folder when session creation fails', async () => { + const root = createRoot() + const fake = createFakeClient() + fake.session.create.mockRejectedValueOnce(new Error('session failed')) + const service = new ChatService(fake.client, () => root) + + await expect(service.create({})).rejects.toThrow('session failed') + expect(readdirSync(root)).toEqual([]) + }) + + it('lists chats newest first, pages with a cursor, and ignores non-chat entries', async () => { + const root = createRoot() + const ids = ['20260101-000000-aaaaaa', '20260102-000000-bbbbbb', '20260103-000000-cccccc'] + for (const id of ids) createChatFolder(root, id) + createChatFolder(root, 'not-a-chat') + mkdirSync(path.join(root, '.hidden')) + const service = new ChatService(createFakeClient().client, () => root) + + const first = await service.list({ limit: 2 }) + expect(first.chats.map((chat) => chat.id)).toEqual([ids[2], ids[1]]) + expect(first.nextCursor).toBe(ids[1]) + + const second = await service.list({ limit: 2, cursor: first.nextCursor }) + expect(second.chats.map((chat) => chat.id)).toEqual([ids[0]]) + expect(second.nextCursor).toBeUndefined() + + const last = await service.list({ limit: 2, cursor: ids[0] }) + expect(last.chats).toEqual([]) + expect(last.nextCursor).toBeUndefined() + }) + + it('resolves only existing chat folders and rejects malformed ids', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + createChatFolder(root, id) + const service = new ChatService(createFakeClient().client, () => root) + + expect((await service.resolve(id))?.id).toBe(id) + expect(await service.resolve('20260203-040506-aaaaaa')).toBeNull() + expect(await service.resolve('../../etc')).toBeNull() + expect(await service.resolve('not-a-chat')).toBeNull() + }) + + it('finds a chat by its canonical directory', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + const service = new ChatService(createFakeClient().client, () => root) + + expect((await service.findByDirectory(directory))?.id).toBe(id) + expect(await service.findByDirectory(path.join(root, 'missing'))).toBeNull() + }) + + it('keeps the folder when another session remains after a delete', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + const fake = createFakeClient() + fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) + fake.session.list.mockResolvedValueOnce({ data: [{ id: 'ses_fork' }], cursor: {} }) + const service = new ChatService(fake.client, () => root) + + const result = await service.deleteSession(id, 'ses_1') + + expect(result).toEqual({ deleted: true, folderRemoved: false }) + expect(fake.session.remove).toHaveBeenCalledWith({ sessionID: 'ses_1' }) + expect(fake.session.list).toHaveBeenCalledWith({ directory, limit: 1 }) + expect(existsSync(directory)).toBe(true) + }) + + it('removes the folder when no session remains after a delete', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + const fake = createFakeClient() + fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) + const service = new ChatService(fake.client, () => root) + + const result = await service.deleteSession(id, 'ses_1') + + expect(result).toEqual({ deleted: true, folderRemoved: true }) + expect(existsSync(directory)).toBe(false) + }) + + it('rejects a session that belongs to another folder', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + createChatFolder(root, id) + const fake = createFakeClient() + fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory: '/tmp/other-chat' } }) + const service = new ChatService(fake.client, () => root) + + await expect(service.deleteSession(id, 'ses_1')).rejects.toBeInstanceOf(ChatSessionMismatchError) + expect(fake.session.remove).not.toHaveBeenCalled() + }) + + it('rejects an unknown chat id before touching sessions', async () => { + const fake = createFakeClient() + const service = new ChatService(fake.client, () => createRoot()) + + await expect(service.deleteSession('20260203-040506-abcdef', 'ses_1')).rejects.toBeInstanceOf(ChatNotFoundError) + await expect(service.deleteSession('../escape', 'ses_1')).rejects.toBeInstanceOf(ChatNotFoundError) + expect(fake.session.get).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/sandbox/command.test.ts b/backend/test/services/sandbox/command.test.ts index 22df74cd8..c59db45ca 100644 --- a/backend/test/services/sandbox/command.test.ts +++ b/backend/test/services/sandbox/command.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from 'child_process' import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { ENV, getAssistantOpenCodeDir, getForgeWorktreesPath, getOpenCodeAgentTmpPath, getOpenCodeGlobalSkillsPath, getOpenCodeToolOutputPath, getOpenCodeWorktreesPath, getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import { ENV, getAssistantOpenCodeDir, getChatsPath, getForgeWorktreesPath, getOpenCodeAgentTmpPath, getOpenCodeGlobalSkillsPath, getOpenCodeToolOutputPath, getOpenCodeWorktreesPath, getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' import { SESSION_SANDBOX_NAME_PATTERN, @@ -35,6 +35,7 @@ import { sandboxDockerDataVolumeName, sandboxMountRoots, sandboxNetworkPolicyMismatch, + sandboxProjectRoots, sandboxVmMountRoots, sessionSandboxName, type SandboxVmSpec, @@ -243,6 +244,14 @@ describe('sandbox command builders', () => { expect(args.indexOf(ENV.SANDBOX.IMAGE)).toBeGreaterThan(entrypointIndex + 1) }) + it('includes the chats root in the sandbox project roots', () => { + const roots = sandboxProjectRoots() + + expect(roots).toContain(getChatsPath()) + expect(roots).toContain(getReposPath()) + expect(sandboxMountRoots()).toContain(getChatsPath()) + }) + it('mounts only the worktree, skills, tool-output, and agent tmp directories from outside the repo roots', () => { const workspacePath = path.dirname(getReposPath()) const roots = sandboxMountRoots() diff --git a/shared/src/config/defaults.ts b/shared/src/config/defaults.ts index 7dc62d33e..24a02b1ef 100644 --- a/shared/src/config/defaults.ts +++ b/shared/src/config/defaults.ts @@ -29,6 +29,7 @@ export const DEFAULTS = { WORKSPACE: { BASE_PATH: './workspace', REPOS_DIR: 'repos', + CHATS_DIR: 'chats', SCHEDULE_WORKTREES_DIR: 'schedule-worktrees', WALKTHROUGHS_DIR: 'walkthroughs', CONFIG_DIR: '.config/opencode', diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index be8d9dcdf..5e80879be 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -87,6 +87,7 @@ export const ENV = { get BASE_PATH() { return resolveWorkspacePath() }, get BROWSE_ROOT() { return resolveBrowseRoot() }, REPOS_DIR: DEFAULTS.WORKSPACE.REPOS_DIR, + CHATS_DIR: DEFAULTS.WORKSPACE.CHATS_DIR, SCHEDULE_WORKTREES_DIR: DEFAULTS.WORKSPACE.SCHEDULE_WORKTREES_DIR, WALKTHROUGHS_DIR: DEFAULTS.WORKSPACE.WALKTHROUGHS_DIR, CONFIG_DIR: DEFAULTS.WORKSPACE.CONFIG_DIR, @@ -148,6 +149,7 @@ export const ENV = { export const getWorkspacePath = () => ENV.WORKSPACE.BASE_PATH export const getBrowseRootPath = () => ENV.WORKSPACE.BROWSE_ROOT export const getReposPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.REPOS_DIR) +export const getChatsPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.CHATS_DIR) export const getScheduleWorktreesPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.SCHEDULE_WORKTREES_DIR) export const getWalkthroughsPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.WALKTHROUGHS_DIR) export const getAssistantModePath = () => path.join(getReposPath(), ASSISTANT_REPO_PATH) diff --git a/shared/src/schemas/chats.ts b/shared/src/schemas/chats.ts new file mode 100644 index 000000000..4858a78b3 --- /dev/null +++ b/shared/src/schemas/chats.ts @@ -0,0 +1,60 @@ +import { z } from 'zod' + +export const CHAT_ID_PATTERN = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-[0-9a-f]{6}$/ + +export const ChatIdSchema = z.string().regex(CHAT_ID_PATTERN, 'Invalid chat id') + +export const ChatSchema = z.object({ + id: ChatIdSchema, + directory: z.string(), + filesPath: z.string(), + createdAt: z.number(), +}) + +export const ListChatsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).default(10), + cursor: ChatIdSchema.optional(), +}) + +export const ListChatsResponseSchema = z.object({ + chats: z.array(ChatSchema), + nextCursor: ChatIdSchema.optional(), +}) + +export const CreateChatRequestSchema = z.object({ + title: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), +}) + +export const CreateChatResponseSchema = z.object({ + chat: ChatSchema, + sessionId: z.string(), +}) + +export const DeleteChatSessionResponseSchema = z.object({ + deleted: z.literal(true), + folderRemoved: z.boolean(), +}) + +export type Chat = z.infer +export type ListChatsQuery = z.infer +export type ListChatsResponse = z.infer +export type CreateChatRequest = z.infer +export type CreateChatResponse = z.infer +export type DeleteChatSessionResponse = z.infer + +/** Reads the UTC creation time encoded in a chat id. */ +export function parseChatIdCreatedAt(id: string): number { + const match = CHAT_ID_PATTERN.exec(id) + if (!match) return NaN + const [, year, month, day, hour, minute, second] = match + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ) +} diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index d70baf9ec..1638e812d 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -24,3 +24,4 @@ export * from './change-walkthroughs' export * from './opencode-servers' export * from './secrets' export * from './device-pairing' +export * from './chats' diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index 694a3b9b4..23d6a9c22 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -22,6 +22,10 @@ export function buildSessionPath( return `/repos/${repoId}/sessions/${sessionId}${search ? `?${search}` : ''}` } +export function buildChatSessionPath(chatId: string, sessionId: string): string { + return `/chats/${encodeURIComponent(chatId)}/sessions/${encodeURIComponent(sessionId)}` +} + export function buildServerSessionPath(serverId: string, sessionId: string): string { return `/servers/${encodeURIComponent(serverId)}/sessions/${encodeURIComponent(sessionId)}` } From 3c064e4b6015cb427131b6a8f42063b45acea111 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:40:15 -0500 Subject: [PATCH 2/6] Chat terminals and chat notification links --- backend/src/index.ts | 7 +- backend/src/routes/chat-terminals.ts | 144 ++++++++ backend/src/services/notification.ts | 33 +- backend/test/routes/chat-terminals.test.ts | 340 ++++++++++++++++++ .../test/services/notification-format.test.ts | 4 + .../services/notification-service.test.ts | 44 ++- 6 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 backend/src/routes/chat-terminals.ts create mode 100644 backend/test/routes/chat-terminals.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index dd739b6fc..be7db75cb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -40,6 +40,7 @@ import { createAuthMiddleware } from './auth/middleware' import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createSessionPinRoutes } from './routes/session-pins' import { createChatRoutes } from './routes/chats' +import { createChatTerminalRoutes, createChatTerminalSocketRoutes } from './routes/chat-terminals' import { createSessionPermissionModeRoutes } from './routes/session-permission-modes' import { createSessionSandboxRoutes } from './routes/session-sandbox' import { createMultiRunRoutes } from './routes/multi-runs' @@ -303,13 +304,13 @@ scheduleService.setWorktreeRemovedHandler(async (directory) => { const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) -const notificationService = new NotificationService(db, opencodeServerRegistry) +const chatService = new ChatService(openCodeClient) +const notificationService = new NotificationService(db, opencodeServerRegistry, chatService) const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) const goalOutcomes = new GoalOutcomeTracker() const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService, (serverId) => opencodeServerRegistry.getClient(serverId)) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient, sessionSettingsService, gitAuthService, gitService) -const chatService = new ChatService(openCodeClient) sseAggregator.onEvent((directory, event) => { sessionSandboxService.handleEvent(directory, event).catch((err) => { @@ -425,7 +426,9 @@ protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/secrets', createGlobalSecretRoutes(secretsService, 'secret')) protectedApi.route('/variables', createGlobalSecretRoutes(secretsService, 'variable')) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/chats', createChatTerminalSocketRoutes(chatService, terminalService, upgradeWebSocket)) protectedApi.route('/chats', createChatRoutes(chatService)) +protectedApi.route('/chats', createChatTerminalRoutes(chatService, terminalService)) protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService)) protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket)) protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resolveServerTerminalService)) diff --git a/backend/src/routes/chat-terminals.ts b/backend/src/routes/chat-terminals.ts new file mode 100644 index 000000000..346a0881e --- /dev/null +++ b/backend/src/routes/chat-terminals.ts @@ -0,0 +1,144 @@ +import { Hono } from 'hono' +import type { MiddlewareHandler } from 'hono' +import type { UpgradeWebSocket } from 'hono/ws' +import { getTrustedOrigins } from '@opencode-manager/shared/config/env' +import { CreateTerminalRequestSchema, ResizeTerminalRequestSchema } from '@opencode-manager/shared/schemas' +import type { ChatService } from '../services/chats' +import { TerminalNotFoundError, type TerminalService } from '../services/terminal' +import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' +import { parseTerminalCursor } from '../utils/terminal-cursor' +import { handleOpenCodeError } from '../utils/route-helpers' +import type { Session } from '../auth' + +export function createChatTerminalRoutes(chatService: ChatService, terminalService: TerminalService) { + const app = new Hono() + + app.get('/:chatId/terminals', async (c) => { + const chat = await chatService.resolve(c.req.param('chatId')) + if (!chat) return c.json({ error: 'Chat not found' }, 404) + + try { + const terminals = await terminalService.list(chat.directory) + return c.json({ terminals }) + } catch (error: unknown) { + return handleOpenCodeError(c, error, 'Terminal request failed') + } + }) + + app.post('/:chatId/terminals', async (c) => { + const body = await c.req.json().catch(() => ({})) + const parsed = CreateTerminalRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + + const chat = await chatService.resolve(c.req.param('chatId')) + if (!chat) return c.json({ error: 'Chat not found' }, 404) + + try { + const terminal = await terminalService.create(chat.directory, { + kind: 'shell', + name: parsed.data.title ?? 'Terminal', + }) + return c.json(terminal) + } catch (error: unknown) { + return handleOpenCodeError(c, error, 'Terminal request failed') + } + }) + + app.patch('/:chatId/terminals/:ptyID', async (c) => { + const body = await c.req.json().catch(() => ({})) + const parsed = ResizeTerminalRequestSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid request' }, 400) + } + + const chat = await chatService.resolve(c.req.param('chatId')) + if (!chat) return c.json({ error: 'Chat not found' }, 404) + + const ptyID = c.req.param('ptyID') + + try { + await terminalService.requireTerminal(chat.directory, ptyID) + await terminalService.resize(chat.directory, ptyID, { cols: parsed.data.cols, rows: parsed.data.rows }) + return c.json({ success: true }) + } catch (error: unknown) { + if (error instanceof TerminalNotFoundError) { + return c.json({ error: error.message }, 404) + } + return handleOpenCodeError(c, error, 'Terminal request failed') + } + }) + + app.delete('/:chatId/terminals/:ptyID', async (c) => { + const chat = await chatService.resolve(c.req.param('chatId')) + if (!chat) return c.json({ error: 'Chat not found' }, 404) + + const ptyID = c.req.param('ptyID') + + try { + await terminalService.requireTerminal(chat.directory, ptyID) + await terminalService.remove(chat.directory, ptyID) + return c.json({ success: true }) + } catch (error: unknown) { + if (error instanceof TerminalNotFoundError) { + return c.json({ error: error.message }, 404) + } + return handleOpenCodeError(c, error, 'Terminal request failed') + } + }) + + return app +} + +interface ChatTerminalSocketVariables { + terminalDirectory: string + terminalCursor: number | undefined + session?: Session['session'] +} + +export function createChatTerminalSocketRoutes( + chatService: ChatService, + terminalService: TerminalService, + upgradeWebSocket: UpgradeWebSocket, + trustedOrigins: readonly string[] = getTrustedOrigins(), +) { + const app = new Hono<{ Variables: ChatTerminalSocketVariables }>() + + const validate: MiddlewareHandler<{ Variables: ChatTerminalSocketVariables }> = async (c, next) => { + if (!isAllowedUpgradeOrigin(c.req.header('origin'), trustedOrigins)) { + return c.json({ error: 'Origin not allowed' }, 403) + } + + const cursor = parseTerminalCursor(c.req.query('cursor')) + if (cursor === null) { + return c.json({ error: 'Invalid cursor' }, 400) + } + + const chat = await chatService.resolve(c.req.param('chatId') ?? '') + if (!chat) { + return c.json({ error: 'Chat not found' }, 404) + } + + c.set('terminalDirectory', chat.directory) + c.set('terminalCursor', cursor) + await next() + } + + app.get( + '/:chatId/terminals/:ptyID/connect', + validate, + upgradeWebSocket((c) => { + const directory = c.get('terminalDirectory') + const cursor = c.get('terminalCursor') + const ptyID = c.req.param('ptyID') ?? '' + + return createBridgedSocketEvents( + (peer) => terminalService.connect(directory, ptyID, cursor, peer), + { sessionId: c.get('session')?.id }, + ) + }), + ) + + return app +} diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index b634e840c..b3e12a229 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -25,7 +25,7 @@ import { getScheduleRunBySessionId, isQuietScheduleRunSession } from "../db/sche import { ensurePushSubscriptionsTable } from "../db/push-subscriptions"; import { isAuthSessionActive } from "../db/auth-sessions"; import type { Repo } from "../types/repo"; -import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; +import { buildChatSessionPath, buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; import { resolveRepoForDirectory } from "./repo"; import type { SessionGoalOutcome } from "./goal-outcomes"; @@ -34,6 +34,10 @@ export interface NotificationServerRegistry { get(serverId: string): Pick | null; } +export interface ChatDirectoryResolver { + findByDirectory(directory: string): Promise<{ id: string } | null>; +} + interface VapidConfig { publicKey: string; privateKey: string; @@ -122,8 +126,14 @@ export function buildScheduleRunReportUrl(runId: number): string { export function buildNotificationUrl( repo: Pick | null, - sessionId: string | undefined + sessionId: string | undefined, + chat?: { id: string } | null ): string { + if (chat) { + return sessionId + ? buildChatSessionPath(chat.id, sessionId) + : `/chats/${encodeURIComponent(chat.id)}`; + } if (!repo) return "/"; if (!sessionId) return `/repos/${repo.id}`; return buildSessionPath(repo.id, sessionId); @@ -132,6 +142,7 @@ export function buildNotificationUrl( export function buildEventNotificationPayload( event: Pick, context: { + displayName?: string; repoName?: string; repoId?: number; serverId?: string; @@ -147,7 +158,7 @@ export function buildEventNotificationPayload( const title = config.titleFn(event.data); const detail = config.bodyFn(event.data); - const displayName = context.repoName ?? context.serverName; + const displayName = context.displayName ?? context.repoName ?? context.serverName; const rawBody = displayName ? `${displayName} · ${detail}` : detail; @@ -185,7 +196,8 @@ export class NotificationService { constructor( private db: Database, - private serverRegistry: NotificationServerRegistry | null = null + private serverRegistry: NotificationServerRegistry | null = null, + private chatService: ChatDirectoryResolver | null = null ) { this.settingsService = new SettingsService(db); ensurePushSubscriptionsTable(db); @@ -393,12 +405,16 @@ export class NotificationService { } const repo = directory ? await resolveRepoForDirectory(this.db, directory) : null; + const chat = !repo && directory + ? (await this.chatService?.findByDirectory(directory)) ?? null + : null; const repoId = repo?.id; const repoName = repo ? getRepoName(repo) : undefined; const reportUrl = RUN_OUTCOME_EVENTS.has(event.type) ? this.getScheduleRunReportUrl(sessionId) : null; - const url = reportUrl ?? buildNotificationUrl(repo, sessionId); + const url = reportUrl ?? buildNotificationUrl(repo, sessionId, chat); const payload = buildEventNotificationPayload(event, { + displayName: repoName ?? (chat ? "Chat" : undefined), repoName, repoId, sessionId, @@ -454,9 +470,12 @@ export class NotificationService { const repo = !isRemote && outcome.directory ? await resolveRepoForDirectory(this.db, outcome.directory) : null; + const chat = !isRemote && !repo && outcome.directory + ? (await this.chatService?.findByDirectory(outcome.directory)) ?? null + : null; const server = isRemote ? this.serverRegistry?.get(outcome.serverId) ?? null : null; const repoName = repo ? getRepoName(repo) : undefined; - const displayName = repoName ?? server?.name; + const displayName = repoName ?? (chat ? "Chat" : undefined) ?? server?.name; const payload: PushNotificationPayload = { title: getGoalStatusTitle(goal.status), @@ -474,7 +493,7 @@ export class NotificationService { serverName: server?.name, url: isRemote ? buildServerSessionPath(outcome.serverId, outcome.sessionId) - : buildNotificationUrl(repo, outcome.sessionId), + : buildNotificationUrl(repo, outcome.sessionId, chat), }, }; diff --git a/backend/test/routes/chat-terminals.test.ts b/backend/test/routes/chat-terminals.test.ts new file mode 100644 index 000000000..dc976d01b --- /dev/null +++ b/backend/test/routes/chat-terminals.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest' +import { Hono } from 'hono' +import { createNodeWebSocket } from '@hono/node-ws' +import { createChatTerminalRoutes, createChatTerminalSocketRoutes } from '../../src/routes/chat-terminals' +import { TerminalNotFoundError } from '../../src/services/terminal' +import type { TerminalService } from '../../src/services/terminal' +import type { ChatService } from '../../src/services/chats' +import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' +import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' + +const CHAT_ID = '20260304-050607-abcdef' +const UNKNOWN_CHAT_ID = '20260304-050607-aaaaaa' +const chatDirectory = '/tmp/chat' +const TRUSTED_ORIGIN = 'http://localhost:5003' + +const chat = { + id: CHAT_ID, + directory: chatDirectory, + filesPath: `chats/${CHAT_ID}`, + createdAt: 0, +} + +const shellTerminal = { + id: 'pty-1', + title: 'Terminal', + kind: 'shell', + cwd: chatDirectory, + status: 'running', +} as const + +function createChatService(overrides: { resolve?: ReturnType } = {}): ChatService { + return { + resolve: + overrides.resolve ?? + vi.fn(async (chatId: string) => (chatId === CHAT_ID ? chat : null)), + } as unknown as ChatService +} + +function createService(overrides: Partial = {}): TerminalService { + return { + list: vi.fn(async () => []), + create: vi.fn(async () => shellTerminal), + requireTerminal: vi.fn(async () => shellTerminal), + resize: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + removeAll: vi.fn(async () => undefined), + ...overrides, + } as unknown as TerminalService +} + +function createApp(chatService: ChatService, service: TerminalService): Hono { + const app = new Hono() + app.route('/', createChatTerminalRoutes(chatService, service)) + return app +} + +const jsonHeaders = { 'Content-Type': 'application/json' } + +describe('Chat Terminal Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET /:chatId/terminals', () => { + it('lists the terminals for the chat directory', async () => { + const terminals = [{ ...shellTerminal }] + const list = vi.fn(async () => terminals) + const res = await createApp(createChatService(), createService({ list })).request(`/${CHAT_ID}/terminals`) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ terminals }) + expect(list).toHaveBeenCalledWith(chatDirectory) + }) + + it('ignores a directory query parameter', async () => { + const list = vi.fn(async () => []) + const res = await createApp(createChatService(), createService({ list })).request( + `/${CHAT_ID}/terminals?directory=/tmp/other`, + ) + + expect(res.status).toBe(200) + expect(list).toHaveBeenCalledWith(chatDirectory) + }) + + it('returns 404 for an unknown chat', async () => { + const list = vi.fn(async () => []) + const res = await createApp(createChatService(), createService({ list })).request( + `/${UNKNOWN_CHAT_ID}/terminals`, + ) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: 'Chat not found' }) + expect(list).not.toHaveBeenCalled() + }) + }) + + describe('POST /:chatId/terminals', () => { + it('creates a shell with the default name in the chat directory', async () => { + const create = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ create })).request( + `/${CHAT_ID}/terminals`, + { method: 'POST', headers: jsonHeaders, body: JSON.stringify({}) }, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(shellTerminal) + expect(create).toHaveBeenCalledWith(chatDirectory, { kind: 'shell', name: 'Terminal' }) + }) + + it('creates a shell with the requested title and ignores the body directory', async () => { + const create = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ create })).request( + `/${CHAT_ID}/terminals`, + { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ directory: '/tmp/other', title: 'My shell' }), + }, + ) + + expect(res.status).toBe(200) + expect(create).toHaveBeenCalledWith(chatDirectory, { kind: 'shell', name: 'My shell' }) + }) + + it('returns 400 for an invalid body', async () => { + const create = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ create })).request( + `/${CHAT_ID}/terminals`, + { method: 'POST', headers: jsonHeaders, body: JSON.stringify({ title: 'a'.repeat(81) }) }, + ) + + expect(res.status).toBe(400) + expect(create).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown chat', async () => { + const create = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ create })).request( + `/${UNKNOWN_CHAT_ID}/terminals`, + { method: 'POST', headers: jsonHeaders, body: JSON.stringify({}) }, + ) + + expect(res.status).toBe(404) + expect(create).not.toHaveBeenCalled() + }) + }) + + describe('PATCH /:chatId/terminals/:ptyID', () => { + it('resizes a known terminal in the chat directory', async () => { + const requireTerminal = vi.fn(async () => shellTerminal) + const resize = vi.fn(async () => undefined) + const res = await createApp(createChatService(), createService({ requireTerminal, resize })).request( + `/${CHAT_ID}/terminals/pty-1`, + { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ directory: '/tmp/other', cols: 80, rows: 24 }), + }, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(requireTerminal).toHaveBeenCalledWith(chatDirectory, 'pty-1') + expect(resize).toHaveBeenCalledWith(chatDirectory, 'pty-1', { cols: 80, rows: 24 }) + }) + + it('returns 400 for invalid resize bounds', async () => { + const requireTerminal = vi.fn(async () => shellTerminal) + const app = createApp(createChatService(), createService({ requireTerminal })) + + for (const body of ['{"cols":0,"rows":24}', '{"cols":80}', '{"cols":80,"rows":501}', '{"cols":1.5,"rows":24}']) { + const res = await app.request(`/${CHAT_ID}/terminals/pty-1`, { method: 'PATCH', headers: jsonHeaders, body }) + expect(res.status).toBe(400) + } + + expect(requireTerminal).not.toHaveBeenCalled() + }) + + it('returns 404 for an unknown PTY id', async () => { + const requireTerminal = vi.fn(async () => { + throw new TerminalNotFoundError('missing') + }) + const res = await createApp(createChatService(), createService({ requireTerminal })).request( + `/${CHAT_ID}/terminals/missing`, + { method: 'PATCH', headers: jsonHeaders, body: JSON.stringify({ cols: 80, rows: 24 }) }, + ) + + expect(res.status).toBe(404) + }) + + it('returns 404 for an unknown chat', async () => { + const requireTerminal = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ requireTerminal })).request( + `/${UNKNOWN_CHAT_ID}/terminals/pty-1`, + { method: 'PATCH', headers: jsonHeaders, body: JSON.stringify({ cols: 80, rows: 24 }) }, + ) + + expect(res.status).toBe(404) + expect(requireTerminal).not.toHaveBeenCalled() + }) + }) + + describe('DELETE /:chatId/terminals/:ptyID', () => { + it('removes a known terminal in the chat directory', async () => { + const requireTerminal = vi.fn(async () => shellTerminal) + const remove = vi.fn(async () => undefined) + const res = await createApp(createChatService(), createService({ requireTerminal, remove })).request( + `/${CHAT_ID}/terminals/pty-1?directory=/tmp/other`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(requireTerminal).toHaveBeenCalledWith(chatDirectory, 'pty-1') + expect(remove).toHaveBeenCalledWith(chatDirectory, 'pty-1') + }) + + it('returns 404 for an unknown PTY id', async () => { + const requireTerminal = vi.fn(async () => { + throw new TerminalNotFoundError('missing') + }) + const res = await createApp(createChatService(), createService({ requireTerminal })).request( + `/${CHAT_ID}/terminals/missing`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(404) + }) + + it('returns 404 for an unknown chat', async () => { + const requireTerminal = vi.fn(async () => shellTerminal) + const res = await createApp(createChatService(), createService({ requireTerminal })).request( + `/${UNKNOWN_CHAT_ID}/terminals/pty-1`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(404) + expect(requireTerminal).not.toHaveBeenCalled() + }) + }) +}) + +describe('Chat Terminal Socket Route', () => { + const connect = vi.fn(async () => ({ send: vi.fn(), close: vi.fn() })) + const resolve = vi.fn(async (chatId: string) => (chatId === CHAT_ID ? chat : null)) + + let server: WebSocketTestServer + let binding: ReturnType + let port = 0 + + beforeAll(async () => { + const app = new Hono() + binding = createNodeWebSocket({ app }) + app.route( + '/', + createChatTerminalSocketRoutes( + { resolve } as unknown as ChatService, + { connect } as unknown as TerminalService, + binding.upgradeWebSocket, + [TRUSTED_ORIGIN], + ), + ) + server = await startWebSocketServer({ app, injectWebSocket: binding.injectWebSocket }) + port = server.port + }) + + afterAll(async () => { + binding.wss.close() + await server.close() + }) + + beforeEach(() => { + connect.mockClear() + resolve.mockClear() + }) + + function connectPath(chatId = CHAT_ID, query = ''): string { + return `/${chatId}/terminals/pty-1/connect${query}` + } + + function openEvent(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState === WebSocket.OPEN) { + resolve() + return + } + socket.addEventListener('open', () => resolve(), { once: true }) + socket.addEventListener('error', () => reject(new Error('WebSocket error before open')), { once: true }) + }) + } + + async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting for condition') + await new Promise((resolve) => setTimeout(resolve, 10)) + } + } + + it('connects with the chat directory and parsed cursor', async () => { + const socket = new WebSocket(`ws://127.0.0.1:${port}${connectPath(CHAT_ID, '?cursor=-1')}`) + + await openEvent(socket) + await waitFor(() => connect.mock.calls.length > 0) + + expect(connect).toHaveBeenCalledWith(chatDirectory, 'pty-1', -1, expect.anything()) + socket.close() + }) + + it('accepts a trusted Origin handshake', async () => { + const outcome = await rawWebSocketHandshake({ port, path: connectPath(), origin: TRUSTED_ORIGIN }) + + expect(outcome).toEqual({ upgraded: true }) + }) + + it('accepts a handshake without an Origin header', async () => { + const outcome = await rawWebSocketHandshake({ port, path: connectPath() }) + + expect(outcome).toEqual({ upgraded: true }) + }) + + it('rejects an untrusted Origin with 403', async () => { + const outcome = await rawWebSocketHandshake({ port, path: connectPath(), origin: 'http://evil.example' }) + + expect(outcome).toEqual({ upgraded: false, status: 403 }) + }) + + it('rejects a malformed cursor with 400', async () => { + const outcome = await rawWebSocketHandshake({ port, path: connectPath(CHAT_ID, '?cursor=abc') }) + + expect(outcome).toEqual({ upgraded: false, status: 400 }) + }) + + it('returns 404 for an unknown chat without connecting', async () => { + const outcome = await rawWebSocketHandshake({ port, path: connectPath(UNKNOWN_CHAT_ID) }) + + expect(outcome).toEqual({ upgraded: false, status: 404 }) + expect(connect).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index c6f4dcf6f..a4ea5b980 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -12,6 +12,7 @@ import { buildEventNotificationPayload, buildNotificationUrl } from '../../src/s import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' const ctx = { repoName: 'oc-manager', repoId: 1, sessionId: 'ses_1', directory: '/abs/repo', url: '/repos/1/sessions/ses_1' } +const CHAT_ID = '20260304-050607-abcdef' describe('OpenCode goal labels', () => { it('titles each OpenCode goal status', () => { @@ -239,4 +240,7 @@ describe('buildNotificationUrl', () => { it('appends ?assistant=1 for the assistant repo session', () => { expect(buildNotificationUrl({ id: ASSISTANT_REPO_ID }, 'ses_1')).toBe('/repos/0/sessions/ses_1?assistant=1') }) + it('returns the chat session page for a chat folder', () => { + expect(buildNotificationUrl(null, 'ses_1', { id: CHAT_ID })).toBe(`/chats/${CHAT_ID}/sessions/ses_1`) + }) }) diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index d3b73721a..aed89636d 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -5,7 +5,7 @@ import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' import { createScheduleJob, createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' import { buildCreateSchedulePersistenceInput } from '../../src/services/schedule-config' -import { NotificationService, type NotificationServerRegistry } from '../../src/services/notification' +import { NotificationService, type ChatDirectoryResolver, type NotificationServerRegistry } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' @@ -14,6 +14,8 @@ import type { SessionGoalOutcome } from '../../src/services/goal-outcomes' import { CreateScheduleJobRequestSchema, type ScheduleJob } from '@opencode-manager/shared/schemas' const DIRECTORY = '/abs/repo' +const CHAT_ID = '20260304-050607-abcdef' +const CHAT_DIRECTORY = '/abs/chat' const USER_ID = 'user-1' const sendResult = { delivered: 0, expired: 0, failed: 0, total: 0 } @@ -53,6 +55,7 @@ function permissionAskedEvent(sessionID: string): SSEEvent { function createService( db = new Database(':memory:'), registry: NotificationServerRegistry | null = null, + chatService: ChatDirectoryResolver | null = null, ): NotificationService { migrate(db, allMigrations) createRepo(db, { @@ -64,7 +67,7 @@ function createService( isLocal: true, }) - const service = new NotificationService(db, registry) + const service = new NotificationService(db, registry, chatService) vi.spyOn(service, 'isConfigured').mockReturnValue(true) service.saveSubscription(USER_ID, 'https://push.example.com/sub-1', 'p256dh-key', 'auth-key') new SettingsService(db).updateSettings( @@ -213,6 +216,23 @@ describe('NotificationService.handleSSEEvent session routing', () => { expect(send).toHaveBeenCalledTimes(1) }) + + it('links a session in a chat folder to the chat route with a Chat prefix', async () => { + const findByDirectory = vi.fn(async (directory: string) => + directory === CHAT_DIRECTORY ? { id: CHAT_ID } : null, + ) + const service = createService(undefined, null, { findByDirectory }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(CHAT_DIRECTORY, formCreatedEvent('ses_chat')) + + expect(findByDirectory).toHaveBeenCalledTimes(1) + expect(findByDirectory).toHaveBeenCalledWith(CHAT_DIRECTORY) + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.data?.url).toBe(`/chats/${CHAT_ID}/sessions/ses_chat`) + expect(payload.body.startsWith('Chat · ')).toBe(true) + }) }) describe('NotificationService quiet schedule announcements', () => { @@ -491,4 +511,24 @@ describe('NotificationService goal outcomes', () => { expect(send).toHaveBeenCalledTimes(1) }) + + it('links a goal outcome in a chat folder to the chat route with a Chat prefix', async () => { + const findByDirectory = vi.fn(async (directory: string) => + directory === CHAT_DIRECTORY ? { id: CHAT_ID } : null, + ) + const service = createService(undefined, null, { findByDirectory }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...goalOutcome, + sessionId: 'ses_chat_goal', + directory: CHAT_DIRECTORY, + }) + + expect(findByDirectory).toHaveBeenCalledWith(CHAT_DIRECTORY) + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.data?.url).toBe(`/chats/${CHAT_ID}/sessions/ses_chat_goal`) + expect(payload.body.startsWith('Chat · ')).toBe(true) + }) }) From 275483f5e91a5639fbf45771507fb17dbaae2b46 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:54:26 -0500 Subject: [PATCH 3/6] Frontend chat API, hooks and the chat session page --- frontend/src/App.tsx | 5 + frontend/src/api/chats.ts | 40 +++ .../components/navigation/moreDrawerItems.ts | 13 + .../session/SessionDetailsSheet.test.tsx | 19 +- .../session/SessionDetailsSheet.tsx | 91 ++++-- frontend/src/contexts/EventContext.tsx | 4 +- frontend/src/hooks/useChats.test.tsx | 283 ++++++++++++++++++ frontend/src/hooks/useChats.ts | 182 +++++++++++ frontend/src/hooks/useOpenCode.ts | 4 +- .../src/hooks/useSidebarServerSelection.ts | 6 +- frontend/src/lib/navigation.test.ts | 61 +++- frontend/src/lib/navigation.ts | 28 +- frontend/src/lib/terminal-target.ts | 30 +- frontend/src/pages/SessionDetail.tsx | 215 ++++++++----- .../__tests__/SessionDetail.chat.test.tsx | 269 +++++++++++++++++ 15 files changed, 1140 insertions(+), 110 deletions(-) create mode 100644 frontend/src/api/chats.ts create mode 100644 frontend/src/hooks/useChats.test.tsx create mode 100644 frontend/src/hooks/useChats.ts create mode 100644 frontend/src/pages/__tests__/SessionDetail.chat.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d8704a49..79557d9d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -341,6 +341,11 @@ const router = createBrowserRouter([ element: , loader: protectedLoader, }, + { + path: '/chats/:chatId/sessions/:sessionId', + element: , + loader: protectedLoader, + }, { path: '/repos/:id/schedules', element: , diff --git a/frontend/src/api/chats.ts b/frontend/src/api/chats.ts new file mode 100644 index 000000000..833ffdb86 --- /dev/null +++ b/frontend/src/api/chats.ts @@ -0,0 +1,40 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { + Chat, + CreateChatRequest, + CreateChatResponse, + DeleteChatSessionResponse, + ListChatsResponse, +} from '@opencode-manager/shared/schemas' + +export async function listChats(input: { + limit: number + cursor?: string +}): Promise { + return fetchWrapper(`${API_BASE_URL}/api/chats`, { + params: { limit: input.limit, cursor: input.cursor }, + }) +} + +export async function getChat(chatId: string): Promise { + return fetchWrapper(`${API_BASE_URL}/api/chats/${encodeURIComponent(chatId)}`) +} + +export async function createChat(input: CreateChatRequest): Promise { + return fetchWrapper(`${API_BASE_URL}/api/chats`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) +} + +export async function deleteChatSession( + chatId: string, + sessionId: string, +): Promise { + return fetchWrapper( + `${API_BASE_URL}/api/chats/${encodeURIComponent(chatId)}/sessions/${encodeURIComponent(sessionId)}`, + { method: 'DELETE' }, + ) +} diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 88768887b..14e9479fa 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -50,6 +50,7 @@ function getBaseItems(): MoreDrawerItem[] { const SESSION_DETAIL_PATH = /^\/repos\/(\d+)\/sessions\/[^/]+$/ const SERVER_SESSION_DETAIL_PATH = /^\/servers\/[^/]+\/sessions\/[^/]+$/ const SERVER_PROJECT_PATH = /^\/servers\/[^/]+\/projects$/ +const CHAT_SESSION_DETAIL_PATH = /^\/chats\/[^/]+\/sessions\/[^/]+$/ export function isSessionDetailPath(pathname: string): boolean { return SESSION_DETAIL_PATH.test(pathname) @@ -178,6 +179,18 @@ function buildRouteNavModel(pathname: string): NavModel { } } + if (CHAT_SESSION_DETAIL_PATH.test(pathname)) { + return { + primary: [getAssistantNavItem()], + items: [ + { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, + { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, + { key: 'preview', label: 'Preview', icon: Globe, dialog: 'preview' }, + ...baseItems, + ], + } + } + return { primary: [getAssistantNavItem()], items: baseItems, diff --git a/frontend/src/components/session/SessionDetailsSheet.test.tsx b/frontend/src/components/session/SessionDetailsSheet.test.tsx index dabb6a342..92b48ea54 100644 --- a/frontend/src/components/session/SessionDetailsSheet.test.tsx +++ b/frontend/src/components/session/SessionDetailsSheet.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { render, screen, fireEvent, act } from '@testing-library/react' +import { render, screen, fireEvent, act, waitFor } 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' @@ -250,4 +250,21 @@ describe('SessionDetailsSheet', () => { expect(screen.getByRole('dialog')).toHaveAccessibleName('Session details') expect(screen.getByText('feat/header')).toBeInTheDocument() }) + + it('confirms the optional delete action', async () => { + const user = userEvent.setup() + const onDeleteSession = vi.fn(() => Promise.resolve()) + renderSheet({ onDeleteSession }) + + await user.click(screen.getByRole('button', { name: 'Delete chat session' })) + await user.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(onDeleteSession).toHaveBeenCalledTimes(1)) + }) + + it('omits the delete action without a delete handler', () => { + renderSheet() + + expect(screen.queryByRole('button', { name: 'Delete chat session' })).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/session/SessionDetailsSheet.tsx b/frontend/src/components/session/SessionDetailsSheet.tsx index 85d699d09..7f490ca56 100644 --- a/frontend/src/components/session/SessionDetailsSheet.tsx +++ b/frontend/src/components/session/SessionDetailsSheet.tsx @@ -3,6 +3,7 @@ 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 { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' import { useMobile } from '@/hooks/useMobile' import { cn } from '@/lib/utils' @@ -16,6 +17,8 @@ export interface SessionDetailsSheetProps { branch?: string | null directory?: string onRename: (title: string) => Promise + /** Deletes the open session, when the caller supports it. */ + onDeleteSession?: () => Promise } interface InfoRowProps { @@ -49,12 +52,16 @@ export function SessionDetailsSheet({ branch, directory, onRename, + onDeleteSession, }: SessionDetailsSheetProps) { const isMobile = useMobile() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') const [saving, setSaving] = useState(false) const [renameFailed, setRenameFailed] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + const [deleteFailed, setDeleteFailed] = useState(false) const [lastOpen, setLastOpen] = useState(open) if (open !== lastOpen) { @@ -62,6 +69,9 @@ export function SessionDetailsSheet({ if (open) { setEditing(false) setRenameFailed(false) + setDeleteFailed(false) + } else { + setDeleteOpen(false) } } @@ -106,6 +116,21 @@ export function SessionDetailsSheet({ onOpenChange(true) } + const confirmDelete = async () => { + if (!onDeleteSession) return + setDeleting(true) + setDeleteFailed(false) + try { + await onDeleteSession() + setDeleteOpen(false) + onOpenChange(false) + } catch { + setDeleteFailed(true) + } finally { + setDeleting(false) + } + } + const body = (
@@ -175,31 +200,63 @@ export function SessionDetailsSheet({ {branch ? : null} {directory ? : null} + {onDeleteSession ? ( +
+ + {deleteFailed && ( + Delete failed + )} +
+ ) : null}
) + const deleteDialog = onDeleteSession ? ( + { void confirmDelete() }} + onCancel={() => setDeleteOpen(false)} + isDeleting={deleting} + /> + ) : null + if (isMobile) { return ( - - - {body} - + <> + + + {body} + + {deleteDialog} + ) } return ( - - - - Session details - - {body} - - + <> + + + + Session details + + {body} + + + {deleteDialog} + ) } diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 6acc44832..e3edb7446 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -30,7 +30,7 @@ import type { Repo } from '@/api/types' import { showToast } from '@/lib/toast' import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode-event-stream' import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' -import { getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation' +import { getServerSessionPath, getSessionPath, parseChatSessionRoute, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation' import { serverScopedKey } from '@/lib/serverScope' import { repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' @@ -451,7 +451,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }, [location.pathname, location.search]) const viewedSessionId = useMemo( - () => parseServerSessionRoute(location.pathname)?.sessionId ?? parseRepoRoute(location.pathname).sessionId, + () => parseServerSessionRoute(location.pathname)?.sessionId ?? parseChatSessionRoute(location.pathname)?.sessionId ?? parseRepoRoute(location.pathname).sessionId, [location.pathname], ) diff --git a/frontend/src/hooks/useChats.test.tsx b/frontend/src/hooks/useChats.test.tsx new file mode 100644 index 000000000..366e4ac85 --- /dev/null +++ b/frontend/src/hooks/useChats.test.tsx @@ -0,0 +1,283 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Chat } from '@opencode-manager/shared/schemas' +import type { Session } from '@/api/types' +import { FetchError } from '@/api/fetchWrapper' +import { + useChat, + useChatFolders, + useChatSessions, + useCreateChat, + useDeleteChatSession, +} from './useChats' + +const mocks = vi.hoisted(() => ({ + listChats: vi.fn(), + getChat: vi.fn(), + createChat: vi.fn(), + deleteChatSession: vi.fn(), + useSessionsAcrossDirectories: vi.fn(), + removeDeletedSessionsFromListCaches: vi.fn(), + cleanupSessionPins: vi.fn(), + useSessionPins: vi.fn(), + showToast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/api/chats', () => ({ + listChats: mocks.listChats, + getChat: mocks.getChat, + createChat: mocks.createChat, + deleteChatSession: mocks.deleteChatSession, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSessionsAcrossDirectories: mocks.useSessionsAcrossDirectories, + removeDeletedSessionsFromListCaches: mocks.removeDeletedSessionsFromListCaches, + cleanupSessionPins: mocks.cleanupSessionPins, +})) + +vi.mock('@/hooks/useSessionPins', () => ({ + useSessionPins: mocks.useSessionPins, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: mocks.showToast, +})) + +const chat = (id: string, directory: string): Chat => ({ + id, + directory, + filesPath: `${directory}/files`, + createdAt: 1000, +}) + +const session = (id: string, directory: string, updated: number): Session => ({ + id, + projectID: 'proj_1', + title: `Session ${id}`, + time: { created: updated, updated }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, +}) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +describe('useChatFolders', () => { + beforeEach(() => { + mocks.listChats.mockReset() + }) + + it('pages chats and flattens pages in order', async () => { + mocks.listChats.mockImplementation(async ({ cursor }: { cursor?: string }) => { + if (cursor === 'cursor_2') { + return { chats: [chat('20240101100000-bbbbbb', '/chats/old')] } + } + return { + chats: [ + chat('20240102120000-aaaaaa', '/chats/new'), + chat('20240102110000-cccccc', '/chats/mid'), + ], + nextCursor: 'cursor_2', + } + }) + + const queryClient = createQueryClient() + const { result } = renderHook(() => useChatFolders(2), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.chats.map((entry) => entry.id)).toEqual([ + '20240102120000-aaaaaa', + '20240102110000-cccccc', + ]) + expect(result.current.hasNextPage).toBe(true) + + await act(async () => { + await result.current.fetchNextPage() + }) + + await waitFor(() => expect(result.current.chats).toHaveLength(3)) + + expect(result.current.chats.map((entry) => entry.id)).toEqual([ + '20240102120000-aaaaaa', + '20240102110000-cccccc', + '20240101100000-bbbbbb', + ]) + expect(mocks.listChats).toHaveBeenLastCalledWith({ limit: 2, cursor: 'cursor_2' }) + }) +}) + +describe('useChatSessions', () => { + beforeEach(() => { + mocks.listChats.mockReset() + mocks.useSessionsAcrossDirectories.mockReset() + mocks.useSessionPins.mockReset() + mocks.useSessionsAcrossDirectories.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + error: null, + }) + mocks.useSessionPins.mockReturnValue({ data: [] }) + }) + + it('orders rows by folder then update time, keeps pinned first, drops archived sessions', async () => { + const now = Date.now() + const newestChat = chat('20240102120000-aaaaaa', '/chats/new') + const olderChat = chat('20240101100000-bbbbbb', '/chats/old') + mocks.listChats.mockResolvedValue({ chats: [newestChat, olderChat] }) + + const archived = session('ses_archived', '/chats/new', now - 500) + archived.time.archived = now - 500 + mocks.useSessionsAcrossDirectories.mockReturnValue({ + data: [ + session('ses_new_older', '/chats/new', now - 3000), + session('ses_old_pinned', '/chats/old', now - 4000), + session('ses_new_newer', '/chats/new', now - 1000), + session('ses_old_unpinned', '/chats/old', now - 500), + archived, + session('ses_unrelated', '/chats/other', now - 1000), + ], + isLoading: false, + isError: false, + error: null, + }) + mocks.useSessionPins.mockReturnValue({ + data: [{ sessionId: 'ses_old_pinned', directory: '/chats/old', pinnedAt: now }], + }) + + const queryClient = createQueryClient() + const { result } = renderHook(() => useChatSessions(2), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.rows).toHaveLength(4)) + + expect(result.current.rows.map((row) => row.session.id)).toEqual([ + 'ses_old_pinned', + 'ses_new_newer', + 'ses_new_older', + 'ses_old_unpinned', + ]) + expect(mocks.useSessionsAcrossDirectories).toHaveBeenCalledWith(['/chats/new', '/chats/old']) + + const pinnedRow = result.current.rows[0] + expect(pinnedRow.isPinned).toBe(true) + expect(pinnedRow.chat.id).toBe(olderChat.id) + expect(pinnedRow.path).toBe(`/chats/${olderChat.id}/sessions/ses_old_pinned`) + expect(result.current.rows[1].isPinned).toBe(false) + expect(result.current.rows[1].path).toBe(`/chats/${newestChat.id}/sessions/ses_new_newer`) + expect(result.current.chats.map((entry) => entry.id)).toEqual([newestChat.id, olderChat.id]) + expect(result.current.hasMore).toBe(false) + }) +}) + +describe('useCreateChat', () => { + beforeEach(() => { + mocks.createChat.mockReset() + mocks.showToast.success.mockReset() + mocks.showToast.error.mockReset() + }) + + it('creates the chat, invalidates caches and reports the built session path', async () => { + const createdChat = chat('20240102120000-aaaaaa', '/chats/new') + mocks.createChat.mockResolvedValue({ chat: createdChat, sessionId: 'ses_new' }) + + const queryClient = createQueryClient() + const sessionsKey = ['opencode', 'sessions', '/chats/new', {}] + const chatsKey = ['chats', 10] + queryClient.setQueryData(sessionsKey, { pages: [], pageParams: [] }) + queryClient.setQueryData(chatsKey, { pages: [], pageParams: [] }) + + const onCreated = vi.fn() + const { result } = renderHook(() => useCreateChat(onCreated), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ title: 'New chat' }) + }) + + expect(mocks.createChat).toHaveBeenCalledWith({ title: 'New chat' }) + expect(onCreated).toHaveBeenCalledWith(`/chats/${createdChat.id}/sessions/ses_new`) + + await waitFor(() => expect(queryClient.getQueryState(chatsKey)?.isInvalidated).toBe(true)) + await waitFor(() => expect(queryClient.getQueryState(sessionsKey)?.isInvalidated).toBe(true)) + }) +}) + +describe('useDeleteChatSession', () => { + beforeEach(() => { + mocks.deleteChatSession.mockReset() + mocks.removeDeletedSessionsFromListCaches.mockReset() + mocks.cleanupSessionPins.mockReset() + mocks.showToast.success.mockReset() + mocks.showToast.error.mockReset() + }) + + it('deletes the session, updates list caches and shows a success toast', async () => { + mocks.deleteChatSession.mockResolvedValue({ deleted: true, folderRemoved: false }) + + const queryClient = createQueryClient() + const chatsKey = ['chats', 10] + queryClient.setQueryData(chatsKey, { pages: [], pageParams: [] }) + + const { result } = renderHook(() => useDeleteChatSession(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ + chatId: '20240102120000-aaaaaa', + sessionId: 'ses_new', + directory: '/chats/new', + }) + }) + + expect(mocks.deleteChatSession).toHaveBeenCalledWith('20240102120000-aaaaaa', 'ses_new') + expect(mocks.removeDeletedSessionsFromListCaches).toHaveBeenCalledWith(queryClient, 'ses_new') + expect(mocks.cleanupSessionPins).toHaveBeenCalledWith( + queryClient, + { id: 'ses_new', directory: '/chats/new' }, + '/chats/new', + ) + expect(mocks.showToast.success).toHaveBeenCalledWith('Chat deleted') + await waitFor(() => expect(queryClient.getQueryState(chatsKey)?.isInvalidated).toBe(true)) + }) +}) + +describe('useChat', () => { + beforeEach(() => { + mocks.getChat.mockReset() + }) + + it('resolves a missing chat to null', async () => { + mocks.getChat.mockRejectedValue(new FetchError('Chat not found', 404)) + + const queryClient = createQueryClient() + const { result } = renderHook(() => useChat('20240102120000-aaaaaa'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.data).toBeNull() + expect(mocks.getChat).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/hooks/useChats.ts b/frontend/src/hooks/useChats.ts new file mode 100644 index 000000000..48e98d405 --- /dev/null +++ b/frontend/src/hooks/useChats.ts @@ -0,0 +1,182 @@ +import { useMemo } from 'react' +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import type { Chat, CreateChatRequest } from '@opencode-manager/shared/schemas' +import { buildChatSessionPath } from '@opencode-manager/shared/utils' +import { createChat, deleteChatSession, getChat, listChats } from '@/api/chats' +import { FetchError } from '@/api/fetchWrapper' +import type { Session } from '@/api/types' +import { + cleanupSessionPins, + removeDeletedSessionsFromListCaches, + useSessionsAcrossDirectories, +} from '@/hooks/useOpenCode' +import { useSessionPins } from '@/hooks/useSessionPins' +import { invalidateSessionListCaches } from '@/lib/queryInvalidation' +import { parseNetworkError } from '@/lib/opencode-errors' +import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' +import { partitionSessions } from '@/components/session/session-partition' +import { showToast } from '@/lib/toast' + +const EMPTY_CHATS: Chat[] = [] + +/** A session of a loaded chat folder, ready to render in the chat list. */ +export interface ChatSessionRow { + key: string + session: Session + chat: Chat + path: string + isPinned: boolean +} + +/** Paginated chat folders, newest first. */ +export function useChatFolders(pageSize = 10) { + const query = useInfiniteQuery({ + queryKey: ['chats', pageSize], + queryFn: ({ pageParam }) => listChats({ limit: pageSize, cursor: pageParam }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + + const chats = useMemo( + () => query.data?.pages.flatMap((page) => page.chats) ?? EMPTY_CHATS, + [query.data], + ) + + return { + chats, + fetchNextPage: query.fetchNextPage, + hasNextPage: query.hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + } +} + +/** Root sessions of loaded chat folders, ordered and partitioned for the chat list. */ +export function useChatSessions(pageSize = 10) { + const folders = useChatFolders(pageSize) + const chats = folders.chats + const directories = useMemo(() => chats.map((chat) => chat.directory), [chats]) + const sessions = useSessionsAcrossDirectories(directories) + const { data: pins = [] } = useSessionPins() + + const rows = useMemo(() => { + const chatsByDirectory = new Map() + chats.forEach((chat, order) => { + if (!chatsByDirectory.has(chat.directory)) { + chatsByDirectory.set(chat.directory, { chat, order }) + } + }) + + const pinnedKeys = buildPinnedSessionKeys(pins) + const ordered = sessions.data + .flatMap((session) => { + if (session.parentID || session.time.archived) return [] + const entry = chatsByDirectory.get(session.location.directory) + return entry ? [{ session, order: entry.order }] : [] + }) + .sort((a, b) => a.order - b.order || b.session.time.updated - a.session.time.updated) + .map((entry) => entry.session) + + const partitioned = partitionSessions(ordered, pinnedKeys, getSessionKey) + const pinnedSessionKeys = new Set(partitioned.pinned.map(getSessionKey)) + const orderedRows = [ + ...partitioned.pinned, + ...ordered.filter((session) => !pinnedSessionKeys.has(getSessionKey(session))), + ] + + return orderedRows.flatMap((session) => { + const entry = chatsByDirectory.get(session.location.directory) + if (!entry) return [] + const key = getSessionKey(session) + return [{ + key, + session, + chat: entry.chat, + path: buildChatSessionPath(entry.chat.id, session.id), + isPinned: pinnedKeys.has(key), + }] + }) + }, [chats, pins, sessions.data]) + + return { + rows, + chats, + hasMore: Boolean(folders.hasNextPage), + showMore: folders.fetchNextPage, + isLoading: folders.isLoading || sessions.isLoading, + isError: folders.isError || sessions.isError, + error: folders.error ?? sessions.error, + } +} + +/** Creates a chat folder and navigates to its first session through `onCreated`. */ +export function useCreateChat(onCreated?: (path: string) => void) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (input?: CreateChatRequest) => createChat(input ?? {}), + onSuccess: (response) => { + invalidateSessionListCaches(queryClient) + void queryClient.invalidateQueries({ queryKey: ['chats'] }) + onCreated?.(buildChatSessionPath(response.chat.id, response.sessionId)) + }, + onError: (error) => { + const parsed = parseNetworkError(error) + showToast.error(parsed.title, { + description: parsed.message, + duration: 5000, + }) + }, + }) +} + +/** Deletes one session from a chat folder and removes it from list and pin caches. */ +export function useDeleteChatSession() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (variables: { chatId: string; sessionId: string; directory?: string }) => + deleteChatSession(variables.chatId, variables.sessionId), + onSuccess: (_response, variables) => { + removeDeletedSessionsFromListCaches(queryClient, variables.sessionId) + cleanupSessionPins( + queryClient, + variables.directory + ? { id: variables.sessionId, directory: variables.directory } + : variables.sessionId, + variables.directory, + ) + void queryClient.invalidateQueries({ queryKey: ['chats'] }) + showToast.success('Chat deleted') + }, + onError: () => { + invalidateSessionListCaches(queryClient) + showToast.error('Failed to delete chat') + }, + }) +} + +/** Reads one chat folder, resolving a missing chat to `null`. */ +export function useChat(chatId?: string) { + return useQuery({ + queryKey: ['chats', 'item', chatId], + queryFn: async () => { + try { + return await getChat(chatId!) + } catch (error) { + if (error instanceof FetchError && error.statusCode === 404) return null + throw error + } + }, + enabled: !!chatId, + retry: (failureCount, error) => + !(error instanceof FetchError && error.statusCode === 404) && failureCount < 3, + }) +} diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 3c4ebb649..407efb27b 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -362,7 +362,7 @@ export const useDeleteSession = (directory?: string | string[]) => { }); }; -const removeDeletedSessionsFromListCaches = ( +export const removeDeletedSessionsFromListCaches = ( queryClient: ReturnType, variables: DeleteSessionTarget | DeleteSessionTarget[], ) => { @@ -386,7 +386,7 @@ const removeDeletedSessionsFromListCaches = ( ); }; -const cleanupSessionPins = ( +export const cleanupSessionPins = ( queryClient: ReturnType, variables: DeleteSessionTarget | DeleteSessionTarget[], primaryDirectory?: string, diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts index 688d3db7b..a455d1b9e 100644 --- a/frontend/src/hooks/useSidebarServerSelection.ts +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -3,11 +3,13 @@ import { useLocation, useMatch } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { useAuth } from '@/hooks/useAuth' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' -import { isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { isAssistantPath, parseChatSessionRoute, parseRepoRoute } from '@/lib/navigation' import { useSidebarServerStore } from '@/stores/sidebarServerStore' function isLocalRoute(pathname: string): boolean { - return parseRepoRoute(pathname).repoId !== null || isAssistantPath(pathname) + return parseRepoRoute(pathname).repoId !== null + || isAssistantPath(pathname) + || parseChatSessionRoute(pathname) !== null } /** diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index dc0d64c2f..3f39a3990 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, isSessionDetailRoute, parseActivePickerSession, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, getChatSessionPath, isSessionDetailRoute, parseActivePickerSession, parseChatSessionRoute, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'; describe('getAssistantPath', () => { @@ -103,6 +103,15 @@ describe('linked server paths', () => { }); }); +describe('getChatSessionPath', () => { + it('builds a chat session path with encoded segments', () => { + expect(getChatSessionPath('20260101-120000-abcdef', 'ses_1')).toBe( + '/chats/20260101-120000-abcdef/sessions/ses_1' + ); + expect(getChatSessionPath('chat a/b', 'ses 1')).toBe('/chats/chat%20a%2Fb/sessions/ses%201'); + }); +}); + describe('getRepoPath', () => { it('routes the assistant repo to the assistant page and others to their repo page', () => { expect(getRepoPath(0)).toBe('/assistant'); @@ -192,6 +201,36 @@ describe('parseServerSessionRoute', () => { }); }); +describe('parseChatSessionRoute', () => { + it('parses a chat session route', () => { + expect(parseChatSessionRoute('/chats/20260101-120000-abcdef/sessions/ses_1')).toEqual({ + chatId: '20260101-120000-abcdef', + sessionId: 'ses_1', + }); + }); + + it('returns null for other routes', () => { + expect(parseChatSessionRoute('/chats/20260101-120000-abcdef')).toBeNull(); + expect(parseChatSessionRoute('/repos/5/sessions/ses_1')).toBeNull(); + expect(parseChatSessionRoute('/servers/srv_kitchen/sessions/ses_1')).toBeNull(); + }); + + it('returns null for a malformed chat id segment', () => { + expect(parseChatSessionRoute('/chats/%E0%A4%A/sessions/ses_1')).toBeNull(); + }); + + it('returns null for a malformed session id segment', () => { + expect(parseChatSessionRoute('/chats/20260101-120000-abcdef/sessions/%E0%A4%A')).toBeNull(); + }); + + it('decodes valid encoded segments', () => { + expect(parseChatSessionRoute('/chats/chat%20a%2Fb/sessions/ses%201')).toEqual({ + chatId: 'chat a/b', + sessionId: 'ses 1', + }); + }); +}); + describe('parseActivePickerSession', () => { it('reads a local session route on the local server', () => { expect(parseActivePickerSession('/repos/5/sessions/ses_1')).toEqual({ @@ -200,6 +239,13 @@ describe('parseActivePickerSession', () => { }); }); + it('reads a chat session route on the local server', () => { + expect(parseActivePickerSession('/chats/20260101-120000-abcdef/sessions/ses_1')).toEqual({ + sessionId: 'ses_1', + serverId: LOCAL_OPENCODE_SERVER_ID, + }); + }); + it('reads a linked-server session route on its own server', () => { expect(parseActivePickerSession('/servers/srv_kitchen/sessions/ses_1')).toEqual({ sessionId: 'ses_1', @@ -210,6 +256,7 @@ describe('parseActivePickerSession', () => { it('returns no session outside a session route', () => { expect(parseActivePickerSession('/repos/5')).toEqual({}); expect(parseActivePickerSession('/servers/srv_kitchen/projects')).toEqual({}); + expect(parseActivePickerSession('/chats/20260101-120000-abcdef')).toEqual({}); }); }); @@ -239,6 +286,11 @@ describe('isSessionDetailRoute', () => { expect(isSessionDetailRoute('/servers/https%3A%2F%2Fhost/sessions/ses_abc')).toBe(true); }); + it('matches chat session detail routes', () => { + expect(isSessionDetailRoute('/chats/20260101-120000-abcdef/sessions/ses_abc')).toBe(true); + expect(isSessionDetailRoute('/chats/20260101-120000-abcdef/sessions/ses_abc/')).toBe(true); + }); + it('matches trailing-slash session detail routes', () => { expect(isSessionDetailRoute('/repos/42/sessions/abc/')).toBe(true); expect(isSessionDetailRoute('/servers/srv_kitchen/sessions/ses_abc/')).toBe(true); @@ -249,6 +301,7 @@ describe('isSessionDetailRoute', () => { expect(isSessionDetailRoute('/repos/42')).toBe(false); expect(isSessionDetailRoute('/servers/srv_kitchen/projects')).toBe(false); expect(isSessionDetailRoute('/servers/srv_kitchen/sessions')).toBe(false); + expect(isSessionDetailRoute('/chats/20260101-120000-abcdef')).toBe(false); expect(isSessionDetailRoute('/assistant')).toBe(false); }); }); @@ -316,6 +369,12 @@ describe('getSwipeBackTarget', () => { }); }); + describe('chat routes', () => { + it('returns root for a chat session detail', () => { + expect(getSwipeBackTarget('/chats/20260101-120000-abcdef/sessions/ses_1', '')).toBe('/'); + }); + }); + describe('repo route', () => { it('returns root for repo detail', () => { expect(getSwipeBackTarget('/repos/42', '')).toBe('/'); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 9226190cd..376df7734 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,4 +1,4 @@ -import { ASSISTANT_REPO_ID, buildServerSessionPath, buildSessionPath } from '@opencode-manager/shared/utils'; +import { ASSISTANT_REPO_ID, buildChatSessionPath, buildServerSessionPath, buildSessionPath } from '@opencode-manager/shared/utils'; import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'; export function getAssistantPath(): string { @@ -32,6 +32,10 @@ export function getServerSessionPath(serverId: string, sessionId: string): strin return buildServerSessionPath(serverId, sessionId); } +export function getChatSessionPath(chatId: string, sessionId: string): string { + return buildChatSessionPath(chatId, sessionId); +} + export function getServerProjectPath(serverId: string, directory: string): string { return `/servers/${encodeURIComponent(serverId)}/projects?directory=${encodeURIComponent(directory)}`; } @@ -65,6 +69,15 @@ export function parseServerSessionRoute(pathname: string): { serverId: string; s return { serverId, sessionId }; } +export function parseChatSessionRoute(pathname: string): { chatId: string; sessionId: string } | null { + const match = /^\/chats\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname); + if (!match) return null; + const chatId = decodePathSegment(match[1]); + const sessionId = decodePathSegment(match[2]); + if (chatId === null || sessionId === null) return null; + return { chatId, sessionId }; +} + /** * Reads the session route in view, whether it belongs to the local server or a linked one. * @@ -76,6 +89,10 @@ export function parseActivePickerSession(pathname: string): { sessionId?: string if (repoRoute.sessionId) { return { sessionId: repoRoute.sessionId, serverId: LOCAL_OPENCODE_SERVER_ID }; } + const chatSessionRoute = parseChatSessionRoute(pathname); + if (chatSessionRoute) { + return { sessionId: chatSessionRoute.sessionId, serverId: LOCAL_OPENCODE_SERVER_ID }; + } const serverSessionRoute = parseServerSessionRoute(pathname); if (!serverSessionRoute) return {}; return { sessionId: serverSessionRoute.sessionId, serverId: serverSessionRoute.serverId }; @@ -83,7 +100,9 @@ export function parseActivePickerSession(pathname: string): { sessionId?: string export function isSessionDetailRoute(pathname: string): boolean { const normalizedPathname = pathname.replace(/\/+$/, ''); - return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(normalizedPathname) || parseServerSessionRoute(normalizedPathname) !== null; + return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(normalizedPathname) + || parseServerSessionRoute(normalizedPathname) !== null + || parseChatSessionRoute(normalizedPathname) !== null; } export function parseRepoRoute(pathname: string): { repoId: number | null; section: string | null; sessionId: string | null } { @@ -144,6 +163,11 @@ export function getSwipeBackTarget(pathname: string, search = ''): string | null return '/'; } + const chatSession = parseChatSessionRoute(pathname); + if (chatSession) { + return '/'; + } + if (/^\/servers\/[^/]+$/.test(pathname)) { return '/'; } diff --git a/frontend/src/lib/terminal-target.ts b/frontend/src/lib/terminal-target.ts index 467ff8d93..1369da2bb 100644 --- a/frontend/src/lib/terminal-target.ts +++ b/frontend/src/lib/terminal-target.ts @@ -1,19 +1,39 @@ export type TerminalTarget = | { kind: 'repo'; repoId: number } | { kind: 'server'; serverId: string } + | { kind: 'chat'; chatId: string } export function terminalTargetScope(target: TerminalTarget): number | string { - return target.kind === 'repo' ? target.repoId : target.serverId + switch (target.kind) { + case 'repo': + return target.repoId + case 'server': + return target.serverId + case 'chat': + return target.chatId + } } export function terminalTargetKey(target: TerminalTarget): string { - return target.kind === 'repo' ? `repo:${target.repoId}` : `server:${target.serverId}` + switch (target.kind) { + case 'repo': + return `repo:${target.repoId}` + case 'server': + return `server:${target.serverId}` + case 'chat': + return `chat:${target.chatId}` + } } export function terminalCollectionPath(target: TerminalTarget): string { - return target.kind === 'repo' - ? `/api/repos/${target.repoId}/terminals` - : `/api/opencode-servers/${encodeURIComponent(target.serverId)}/terminals` + switch (target.kind) { + case 'repo': + return `/api/repos/${target.repoId}/terminals` + case 'server': + return `/api/opencode-servers/${encodeURIComponent(target.serverId)}/terminals` + case 'chat': + return `/api/chats/${encodeURIComponent(target.chatId)}/terminals` + } } export function terminalItemPath(target: TerminalTarget, ptyID: string): string { diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index bad9d6038..04b865c76 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -9,8 +9,10 @@ import { X, CornerUpLeft, GitBranch, ArrowDown, Server } from "lucide-react"; import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; import { Badge } from "@/components/ui/badge"; -import { getServerProjectPath, getSessionListPath, getSessionPath } from '@/lib/navigation' +import { getChatSessionPath, getServerProjectPath, getSessionListPath, getSessionPath } from '@/lib/navigation' import { FetchError } from '@/api/fetchWrapper' +import { useChat, useDeleteChatSession } from '@/hooks/useChats' +import type { TerminalTarget } from '@/lib/terminal-target' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; import { NotFoundState } from "@/components/ui/not-found-state"; @@ -129,12 +131,13 @@ function isSessionNotFoundError(error: unknown): boolean { export function SessionDetail() { const serverId = useOpenCodeServerId(); const isRemoteServer = !isLocalOpenCodeServer(serverId); - const { id, sessionId } = useParams<{ id: string; sessionId: string }>(); + const { id, chatId, sessionId } = useParams<{ id?: string; chatId?: string; sessionId: string }>(); const navigate = useNavigate(); const location = useLocation(); const swipeNav = useSwipeNavigation(); + const isChatSession = chatId !== undefined; const repoId = Number(id) || 0; - const isAssistantSession = new URLSearchParams(location.search).get('assistant') === '1'; + const isAssistantSession = !isChatSession && new URLSearchParams(location.search).get('assistant') === '1'; const { preferences, updateSettings } = useSettings(); const { open: openSettings, openTab: openSettingsTab } = useSettingsDialog(); const messageContainerRef = useRef(null); @@ -191,10 +194,13 @@ export function SessionDetail() { const { data: repo, isLoading: repoLoading } = useQuery({ ...repoQueryOptions(repoId), - enabled: id !== undefined && !isRemoteServer, + enabled: id !== undefined && !isRemoteServer && !isChatSession, }); - useRepoActivity(repoId, Boolean(repo) && !isRemoteServer); + useRepoActivity(repoId, Boolean(repo) && !isRemoteServer && !isChatSession); + + const { data: chat, isLoading: chatLoading } = useChat(chatId); + const { mutateAsync: deleteChatSessionAsync } = useDeleteChatSession(); const repoDirectory = repo?.fullPath; const [resolvedSessionDirectory, setResolvedSessionDirectory] = useState<{ sessionId: string; directory: string } | null>(null); @@ -203,7 +209,9 @@ export function SessionDetail() { ? resolvedSessionDirectory.directory : undefined ); - const sessionDirectory = isRemoteServer ? resolvedDirectory : resolvedDirectory ?? repoDirectory; + const sessionDirectory = isChatSession + ? chat?.directory + : isRemoteServer ? resolvedDirectory : resolvedDirectory ?? repoDirectory; const remoteProjectBackPath = isRemoteServer && sessionDirectory ? getServerProjectPath(serverId, sessionDirectory) : '/'; @@ -216,7 +224,7 @@ export function SessionDetail() { }; }, [isRemoteServer, remoteProjectBackPath, swipeNav]); - const { data: repoSiblings } = useRepoSiblings(!isAssistantSession && !isRemoteServer && repo && !repo.isWorktree ? repoId : undefined); + const { data: repoSiblings } = useRepoSiblings(!isChatSession && !isAssistantSession && !isRemoteServer && repo && !repo.isWorktree ? repoId : undefined); const { servers: linkedServers } = useOpenCodeServers({ enabled: isRemoteServer }); @@ -230,8 +238,8 @@ export function SessionDetail() { const remoteProjectDisplayName = remoteProject ? remoteProjectName(remoteProject) : undefined; const sessionBranch = useMemo( - () => (repo && !isAssistantSession ? resolveSessionBranch(repo, sessionDirectory, repoSiblings) : null), - [repo, isAssistantSession, sessionDirectory, repoSiblings], + () => (repo && !isAssistantSession && !isChatSession ? resolveSessionBranch(repo, sessionDirectory, repoSiblings) : null), + [repo, isAssistantSession, isChatSession, sessionDirectory, repoSiblings], ); const { data: remoteVcsInfo } = useVcsInfo(sessionDirectory, serverId, { enabled: isRemoteServer }); @@ -364,7 +372,7 @@ export function SessionDetail() { }, [lastAssistantMessage, transcriptStatus]) const hasIncompleteMessages = lastAssistantMessage ? lastAssistantMessage.time.completed === undefined : false; const isStreamingResponse = hasIncompleteMessages && isSessionActive; - const workspaceBasePath = repo?.localPath; + const workspaceBasePath = isChatSession ? chat?.filesPath : repo?.localPath; useAutoPlayLastResponse({ sessionId: sessionId ?? '', @@ -400,7 +408,7 @@ export function SessionDetail() { useAutoOpenWalkthrough({ sessionId, messages, - enabled: docked && !isRemoteServer, + enabled: docked && !isRemoteServer && !isChatSession, loading: messagesLoading, onOpen: handleShowWalkthrough, }); @@ -446,16 +454,22 @@ export function SessionDetail() { retry: false, }) + const buildSessionRoute = useCallback((targetSessionId: string) => ( + chatId !== undefined + ? getChatSessionPath(chatId, targetSessionId) + : getSessionPath(repoId, targetSessionId, { serverId }) + ), [chatId, repoId, serverId]); + const handleNewSession = useCallback(async () => { try { const newSession = await createSessionAsync({ agent: undefined }); if (newSession?.id) { - navigate(getSessionPath(repoId, newSession.id, { serverId })); + navigate(buildSessionRoute(newSession.id)); } } catch { showToast.error('Failed to create new session'); } - }, [createSessionAsync, navigate, repoId, serverId]); + }, [createSessionAsync, navigate, buildSessionRoute]); const { mutateAsync: undoMessageAsync } = useUndoMessage({ sessionId: sessionId ?? '', @@ -514,7 +528,7 @@ export function SessionDetail() { const forkedSession = await forkSession(sessionId, messageID, serverId); if (forkedSession?.id) { setMessagePickerMode(null); - navigate(getSessionPath(repoId, forkedSession.id, { serverId })); + navigate(buildSessionRoute(forkedSession.id)); if (messageID) { const chosenMessage = forkPickerMessages?.find((message) => message.id === messageID); if (chosenMessage?.type === 'user') { @@ -526,7 +540,7 @@ export function SessionDetail() { } catch (error) { showToast.error(`Fork failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - }, [sessionId, forkPickerMessages, navigate, repoId, serverId]); + }, [sessionId, serverId, forkPickerMessages, navigate, buildSessionRoute]); const openTimelinePicker = useCallback(() => setMessagePickerMode('timeline'), []); @@ -542,13 +556,17 @@ export function SessionDetail() { }, []); const handleCloseSession = useCallback(() => { + if (isChatSession) { + navigate('/') + return + } if (isRemoteServer) { navigate(remoteProjectBackPath) return } const tab = new URLSearchParams(location.search).get('repoTab') ?? undefined; navigate(getSessionListPath(repoId, isAssistantSession, tab)) - }, [navigate, repoId, isAssistantSession, isRemoteServer, remoteProjectBackPath, location.search]) + }, [navigate, repoId, isAssistantSession, isChatSession, isRemoteServer, remoteProjectBackPath, location.search]) const handleOpenModelDialog = useCallback(() => { promptInputRef.current?.openModelPicker(); @@ -561,13 +579,19 @@ export function SessionDetail() { }; const handleFileClick = useCallback((filePath: string) => { - setSelectedFilePath(getWorkspaceFilePath(filePath, { - directory: sessionDirectory, - repoFullPath: repo?.fullPath, - repoLocalPath: repo?.localPath, - })) + setSelectedFilePath(getWorkspaceFilePath(filePath, isChatSession + ? { + directory: sessionDirectory, + repoFullPath: chat?.directory, + repoLocalPath: chat?.filesPath, + } + : { + directory: sessionDirectory, + repoFullPath: repo?.fullPath, + repoLocalPath: repo?.localPath, + })) setFileBrowserOpen(true) - }, [repo?.fullPath, repo?.localPath, sessionDirectory, setFileBrowserOpen]); + }, [chat?.directory, chat?.filesPath, isChatSession, repo?.fullPath, repo?.localPath, sessionDirectory, setFileBrowserOpen]); const renameSession = useCallback(async (title: string) => { if (!sessionId) throw new Error('No active session'); @@ -586,6 +610,12 @@ export function SessionDetail() { } }, [renameSession]); + const handleDeleteChatSession = useCallback(async () => { + if (!chatId || !sessionId || !chat?.directory) return; + await deleteChatSessionAsync({ chatId, sessionId, directory: chat.directory }); + navigate('/'); + }, [chat?.directory, chatId, deleteChatSessionAsync, navigate, sessionId]); + const handleFileBrowserClose = useCallback(() => setFileBrowserOpen(false), [setFileBrowserOpen]); const filesVisible = fileBrowserOpen || sidePanel.activeTool === 'files'; @@ -594,14 +624,14 @@ export function SessionDetail() { }, [filesVisible]); const handleChildSessionClick = useCallback((childSessionId: string) => { - navigate(getSessionPath(repoId, childSessionId, { serverId })) - }, [navigate, repoId, serverId]); + navigate(buildSessionRoute(childSessionId)) + }, [navigate, buildSessionRoute]); const handleParentSessionClick = useCallback(() => { if (session?.parentID) { - navigate(getSessionPath(repoId, session.parentID, { serverId })) + navigate(buildSessionRoute(session.parentID)) } - }, [navigate, repoId, session?.parentID, serverId]); + }, [navigate, session?.parentID, buildSessionRoute]); const handleToggleDetails = useCallback(() => { const newValue = !preferences?.expandToolCalls @@ -742,7 +772,22 @@ export function SessionDetail() { return ; } - if (!isAssistantSession && !isRemoteServer && repoLoading) { + if (isChatSession && chatLoading) { + return ( +
+
+
+ Loading chat... +
+
+ ); + } + + if (isChatSession && !chat) { + return ; + } + + if (!isAssistantSession && !isRemoteServer && !isChatSession && repoLoading) { return (
@@ -753,7 +798,7 @@ export function SessionDetail() { ); } - if (!isAssistantSession && !isRemoteServer && !repo) { + if (!isAssistantSession && !isRemoteServer && !isChatSession && !repo) { return ; } @@ -762,8 +807,8 @@ export function SessionDetail() { return ( ); } @@ -792,15 +837,24 @@ export function SessionDetail() { } const linkedServer = linkedServers.find((candidate) => candidate.id === serverId); - const projectDisplayName = isRemoteServer - ? remoteProjectDisplayName ?? (sessionDirectory ? getDirectoryName(sessionDirectory) : 'Project') - : isAssistantSession || !repo - ? 'Assistant' - : getRepoDisplayName(repo); + const projectDisplayName = isChatSession + ? 'Chat' + : isRemoteServer + ? remoteProjectDisplayName ?? (sessionDirectory ? getDirectoryName(sessionDirectory) : 'Project') + : isAssistantSession || !repo + ? 'Assistant' + : getRepoDisplayName(repo); const tabFromUrl = new URLSearchParams(location.search).get('repoTab') ?? undefined; - const sessionBackPath = isRemoteServer - ? remoteProjectBackPath - : getSessionListPath(repoId, isAssistantSession, tabFromUrl); + const sessionBackPath = isChatSession + ? '/' + : isRemoteServer + ? remoteProjectBackPath + : getSessionListPath(repoId, isAssistantSession, tabFromUrl); + const terminalTarget: TerminalTarget = chatId !== undefined + ? { kind: 'chat', chatId } + : isRemoteServer + ? { kind: 'server', serverId } + : { kind: 'repo', repoId }; return (
- {!isAssistantSession && !isRemoteServer && ( + {!isAssistantSession && !isRemoteServer && !isChatSession && ( )} - +
@@ -885,14 +939,14 @@ export function SessionDetail() { @@ -1019,19 +1073,19 @@ export function SessionDetail() { {showRail && ( )}
@@ -1043,9 +1097,10 @@ export function SessionDetail() { serverName={linkedServer?.name} remote={isRemoteServer} project={projectDisplayName} - branch={headerBranch} + branch={isChatSession ? null : headerBranch} directory={sessionDirectory} onRename={renameSession} + onDeleteSession={isChatSession ? handleDeleteChatSession : undefined} /> {sideQuestion && sessionId && ( @@ -1080,12 +1135,12 @@ export function SessionDetail() { onClose={handleFileBrowserClose} basePath={workspaceBasePath} repoName={projectDisplayName} - repoId={repoId} + repoId={isChatSession ? undefined : repoId} initialSelectedFile={selectedFilePath} /> )} - {sessionId && ( + {!isChatSession && sessionId && ( )} - {!isRemoteServer && sessionId && ( + {!isRemoteServer && !isChatSession && sessionId && ( )} - + {!isChatSession && ( + + )} - {!isAssistantSession && !isRemoteServer && ( + {!isAssistantSession && !isRemoteServer && !isChatSession && ( )} - {isRemoteServer - ? sessionDirectory && ( - setSourceControlOpen(false)} - /> - ) - : ( - setSourceControlOpen(false)} - currentBranch={repo?.currentBranch || repo?.branch || "main"} - repoName={projectDisplayName} - /> - )} + {!isChatSession && ( + isRemoteServer + ? sessionDirectory && ( + setSourceControlOpen(false)} + /> + ) + : ( + setSourceControlOpen(false)} + currentBranch={repo?.currentBranch || repo?.branch || "main"} + repoName={projectDisplayName} + /> + ) + )} setTerminalOpen(false)} @@ -1156,7 +1215,7 @@ export function SessionDetail() { /> )} - {!isRemoteServer && ( + {!isRemoteServer && !isChatSession && ( ({ + useSession: vi.fn(), + useChat: vi.fn(), + useDeleteChatSession: vi.fn(), + useSessionTranscript: vi.fn(), + useSSE: vi.fn(), + useRepoActivity: vi.fn(), + useRepoSiblings: vi.fn(), + usePermissions: vi.fn(), + useForms: vi.fn(), + useSettings: vi.fn(), + useSettingsDialog: vi.fn(), + useMobile: vi.fn(), + useVisualViewport: vi.fn(), + useShortcutActions: vi.fn(), + useAutoScroll: vi.fn(), + useDialogParam: vi.fn(), + useTerminalDialogParam: vi.fn(), + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), + getRepo: vi.fn(), + useSessionStatus: vi.fn(), + MessageThread: vi.fn(() =>
), + PromptInput: vi.fn(() => null), + FileBrowserSheet: vi.fn(() => null), + TerminalPanel: vi.fn(() => null), + PreviewPanel: vi.fn(() => null), + ProjectActionsMenu: vi.fn(() => null), + RepoActionsDialog: vi.fn(() => null), + ResetPermissionsDialog: vi.fn(() => null), + RepoSkillsDialog: vi.fn(() => null), + RepoMcpDialog: vi.fn(() => null), + SourceControlPanel: vi.fn(() => null), + RemoteSourceControlPanel: vi.fn(() => null), + ChangesWalkthroughSheet: vi.fn(() => null), + ToolSidePanel: vi.fn(() => null), + SessionMoreButton: vi.fn(() => null), +})) + +vi.mock('@/api/repos', () => ({ getRepo: mocks.getRepo })) + +vi.mock('@/hooks/useChats', () => ({ + useChat: mocks.useChat, + useDeleteChatSession: mocks.useDeleteChatSession, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSession: mocks.useSession, + useInterruptSession: vi.fn(() => ({ mutate: vi.fn() })), + useUpdateSession: vi.fn(() => ({ mutate: vi.fn(), mutateAsync: vi.fn() })), + useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })), +})) + +vi.mock('@/hooks/useSessionTranscript', () => ({ useSessionTranscript: mocks.useSessionTranscript })) +vi.mock('@/hooks/useRepoSiblings', () => ({ useRepoSiblings: mocks.useRepoSiblings })) +vi.mock('@/hooks/useRepoActivity', () => ({ useRepoActivity: mocks.useRepoActivity })) +vi.mock('@/hooks/useSSE', () => ({ useSSE: mocks.useSSE })) +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) +vi.mock('@/hooks/useModelSelection', () => ({ + useModelSelection: vi.fn(() => ({ model: null, modelString: null, modelRef: null })), +})) +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, + 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 })) +vi.mock('@/hooks/useDialogParam', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useDialogParam: mocks.useDialogParam } +}) +vi.mock('@/hooks/useOpenTerminal', () => ({ useTerminalDialogParam: mocks.useTerminalDialogParam })) +vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ + getAssistantText: vi.fn(() => ''), + getLatestPlayableAssistantMessage: vi.fn(() => null), + useAutoPlayLastResponse: vi.fn(() => {}), +})) +vi.mock('@/hooks/useAutoOpenWalkthrough', () => ({ useAutoOpenWalkthrough: vi.fn(() => {}) })) +vi.mock('@/hooks/useSpeakVisibleMessages', () => ({ useSpeakVisibleMessages: vi.fn(() => vi.fn()) })) +vi.mock('@/hooks/useUndoMessage', () => ({ + useUndoMessage: vi.fn(() => ({ mutateAsync: vi.fn() })), + useRedoMessage: vi.fn(() => ({ mutateAsync: vi.fn() })), +})) +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn() }, +})) +vi.mock('@/stores/uiStateStore', () => ({ useUIState: vi.fn(() => false) })) +vi.mock('@/stores/sessionStatusStore', () => ({ useSessionStatus: mocks.useSessionStatus })) +vi.mock('@/contexts/EventContext', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, usePermissions: mocks.usePermissions, useForms: mocks.useForms } +}) + +vi.mock('@/components/message/MessageThread', () => ({ MessageThread: mocks.MessageThread })) +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/SessionSendErrorBanner', () => ({ SessionSendErrorBanner: () => null })) +vi.mock('@/components/session/BackgroundWorkBar', () => ({ BackgroundWorkBar: () => null })) +vi.mock('@/components/session/SessionGoalBar', () => ({ SessionGoalBar: () => null })) +vi.mock('@/components/session/FormPrompt', () => ({ FormPrompt: () => null })) +vi.mock('@/components/session/MinimizedFormIndicator', () => ({ MinimizedFormIndicator: () => null })) +vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ + ChangesWalkthroughSheet: mocks.ChangesWalkthroughSheet, +})) +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ PendingActionsGroup: () => null })) +vi.mock('@/components/navigation/SessionMoreButton', () => ({ SessionMoreButton: mocks.SessionMoreButton })) +vi.mock('@/components/navigation/ToolSidePanel', () => ({ ToolSidePanel: mocks.ToolSidePanel })) +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: mocks.FileBrowserSheet })) +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: mocks.SourceControlPanel, + RemoteSourceControlPanel: mocks.RemoteSourceControlPanel, +})) +vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalPanel: mocks.TerminalPanel })) +vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewPanel: mocks.PreviewPanel })) +vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: mocks.ProjectActionsMenu })) +vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsDialog: mocks.RepoActionsDialog })) +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ + ResetPermissionsDialog: mocks.ResetPermissionsDialog, +})) +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsDialog: mocks.RepoSkillsDialog })) +vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: mocks.RepoMcpDialog })) + +const chatId = '20260101-120000-abcdef' +const chat = { + id: chatId, + directory: `/chats/${chatId}/work`, + filesPath: `/chats/${chatId}`, + createdAt: 1, +} + +const chatSession = { + id: 'ses_chat', + projectID: 'prj_chat', + title: 'Scratch chat', + time: { created: 1, updated: 2 }, + location: { directory: chat.directory }, +} + +function renderChatSession() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + } /> + + + , + ) +} + +describe('SessionDetail chat session', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useChat.mockReturnValue({ data: chat, isLoading: false, error: undefined }) + mocks.useDeleteChatSession.mockReturnValue({ mutateAsync: vi.fn(), isPending: false }) + mocks.useSession.mockReturnValue({ data: chatSession, isLoading: false, error: undefined }) + mocks.useSessionTranscript.mockReturnValue({ + messages: [], + pending: [], + status: 'idle', + isLoading: false, + fetchOlder: vi.fn(), + hasOlder: false, + }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + mocks.useRepoActivity.mockReturnValue(undefined) + mocks.useRepoSiblings.mockReturnValue({ data: [] }) + mocks.usePermissions.mockReturnValue({ + pendingCount: 0, + hasPermissionsForSession: vi.fn(() => false), + syncForSession: vi.fn(), + }) + mocks.useForms.mockReturnValue({ + current: null, + getForSession: vi.fn(() => null), + pendingCount: 0, + hasFormsForSession: vi.fn(() => false), + reply: vi.fn(), + cancel: vi.fn(), + forgetPendingActionsForSession: vi.fn(), + syncForSession: vi.fn(), + }) + mocks.useSettings.mockReturnValue({ preferences: { expandToolCalls: false }, updateSettings: vi.fn() }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn(), openTab: vi.fn() }) + mocks.useMobile.mockReturnValue(false) + mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) + mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn(), scrollByUser: vi.fn() }) + mocks.useDialogParam.mockReturnValue([false, vi.fn()]) + mocks.useTerminalDialogParam.mockReturnValue([false, vi.fn()]) + mocks.useSessionStatus.mockReturnValue({ setStatus: vi.fn() }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ statusById: {} }) + }) + + it('renders the transcript and labels the project as a chat', async () => { + renderChatSession() + + expect(await screen.findByTestId('message-thread')).toBeInTheDocument() + expect(mocks.PromptInput).toHaveBeenCalled() + expect(screen.getByText('Chat')).toBeInTheDocument() + }) + + it('does not load a Manager repository', async () => { + renderChatSession() + + await screen.findByTestId('message-thread') + + expect(mocks.getRepo).not.toHaveBeenCalled() + }) + + it('scopes the file browser, terminal, and preview to the chat folder', async () => { + renderChatSession() + + await screen.findByTestId('message-thread') + + expect(mocks.FileBrowserSheet.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ basePath: chat.filesPath }), + ) + expect(mocks.TerminalPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + target: { kind: 'chat', chatId }, + directory: chat.directory, + }), + ) + expect(mocks.PreviewPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ directory: chat.directory }), + ) + }) + + it('hides repository-only surfaces', async () => { + renderChatSession() + + await screen.findByTestId('message-thread') + + expect(mocks.ProjectActionsMenu).not.toHaveBeenCalled() + expect(mocks.RepoActionsDialog).not.toHaveBeenCalled() + expect(mocks.RepoMcpDialog).not.toHaveBeenCalled() + expect(mocks.RepoSkillsDialog).not.toHaveBeenCalled() + expect(mocks.ResetPermissionsDialog).not.toHaveBeenCalled() + expect(mocks.SourceControlPanel).not.toHaveBeenCalled() + expect(mocks.RemoteSourceControlPanel).not.toHaveBeenCalled() + expect(mocks.ChangesWalkthroughSheet).not.toHaveBeenCalled() + }) + + it('shows a chat not-found state for an unknown chat', async () => { + mocks.useChat.mockReturnValue({ data: null, isLoading: false, error: undefined }) + + renderChatSession() + + expect(await screen.findByText('Chat not found')).toBeInTheDocument() + expect(screen.queryByTestId('message-thread')).not.toBeInTheDocument() + }) +}) From ccb150d5e65b0bd2662412478e1e469f8d6f8558 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:59:28 -0500 Subject: [PATCH 4/6] Desktop sidebar Chats group --- .../navigation/ChatsNavGroup.test.tsx | 220 ++++++++++++++++++ .../components/navigation/ChatsNavGroup.tsx | 81 +++++++ .../navigation/DesktopSessionTree.test.tsx | 72 +++++- .../navigation/DesktopSessionTree.tsx | 24 +- .../components/navigation/RepoSessionNav.tsx | 74 ++++-- .../navigation/sidebar-session-tree.test.ts | 21 ++ .../navigation/sidebar-session-tree.ts | 20 +- 7 files changed, 476 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/navigation/ChatsNavGroup.test.tsx create mode 100644 frontend/src/components/navigation/ChatsNavGroup.tsx diff --git a/frontend/src/components/navigation/ChatsNavGroup.test.tsx b/frontend/src/components/navigation/ChatsNavGroup.test.tsx new file mode 100644 index 000000000..3b032e22b --- /dev/null +++ b/frontend/src/components/navigation/ChatsNavGroup.test.tsx @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, useLocation } from 'react-router-dom' +import type { ReactNode } from 'react' +import type { Chat } from '@opencode-manager/shared/schemas' +import { ChatsNavGroup } from './ChatsNavGroup' + +type SessionFixture = { + id: string + projectID: string + title: string + time: { created: number; updated: number; archived?: number } + location: { directory: string } + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } +} + +const mocks = vi.hoisted(() => ({ + listChats: vi.fn(), + createChat: vi.fn(), + sessionsData: [] as SessionFixture[], + useSessionsAcrossDirectories: vi.fn(), + useSessionPins: vi.fn(), + showToast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/api/chats', () => ({ + listChats: mocks.listChats, + createChat: mocks.createChat, +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: mocks.useSessionsAcrossDirectories, + removeDeletedSessionsFromListCaches: vi.fn(), + cleanupSessionPins: vi.fn(), + } +}) + +vi.mock('@/hooks/useSessionPins', () => ({ + SESSION_PINS_QUERY_KEY: ['session-pins'], + useSessionPins: mocks.useSessionPins, +})) + +vi.mock('@/lib/toast', () => ({ showToast: mocks.showToast })) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ hasForSession: () => false }), + useForms: () => ({ hasForSession: () => false }), +})) + +function chat(id: string, directory: string): Chat { + return { id, directory, filesPath: `${directory}/files`, createdAt: 1000 } +} + +function createSession(id: string, directory: string, updated = Date.now()): 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 } }, + } +} + +const chatA = chat('20240103120000-aaaaaa', '/chats/a') +const chatB = chat('20240102120000-bbbbbb', '/chats/b') +const chatC = chat('20240101120000-cccccc', '/chats/c') +const chatD = chat('20231231120000-dddddd', '/chats/d') +const chatE = chat('20231230120000-eeeeee', '/chats/e') +const chatF = chat('20231229120000-ffffff', '/chats/f') + +function LocationDisplay() { + const location = useLocation() + return
{location.pathname}
+} + +function createWrapper(initialEntries: string[]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: ReactNode }) => ( + + + {children} + + + + ) +} + +const chatRows = () => screen.getAllByText(/^Session /).map((row) => row.textContent) + +describe('ChatsNavGroup', () => { + beforeEach(() => { + localStorage.clear() + mocks.listChats.mockReset() + mocks.createChat.mockReset() + mocks.sessionsData.splice( + 0, + mocks.sessionsData.length, + createSession('a1', '/chats/a'), + createSession('b1', '/chats/b'), + createSession('c1', '/chats/c'), + createSession('d1', '/chats/d'), + createSession('e1', '/chats/e'), + createSession('f1', '/chats/f'), + ) + mocks.listChats.mockImplementation(async ({ cursor }: { cursor?: string }) => { + if (cursor === 'cursor_2') return { chats: [chatD, chatE, chatF] } + return { chats: [chatA, chatB, chatC], nextCursor: 'cursor_2' } + }) + mocks.createChat.mockResolvedValue({ + chat: chat('20240104120000-111111', '/chats/new'), + sessionId: 'ses_new', + }) + mocks.useSessionsAcrossDirectories.mockImplementation((directories: string[]) => ({ + data: mocks.sessionsData.filter((session) => directories.includes(session.location.directory)), + isLoading: false, + isError: false, + error: null, + })) + mocks.useSessionPins.mockReturnValue({ data: [] }) + mocks.showToast.success.mockReset() + mocks.showToast.error.mockReset() + }) + + it('lists the newest chats and loads the next page with Show more', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + expect(chatRows()).toEqual(['Session a1', 'Session b1', 'Session c1']) + expect(mocks.listChats).toHaveBeenCalledWith({ limit: 3, cursor: undefined }) + + await user.click(screen.getByText('Show more')) + + await waitFor(() => expect(chatRows()).toHaveLength(6)) + expect(chatRows()).toEqual([ + 'Session a1', + 'Session b1', + 'Session c1', + 'Session d1', + 'Session e1', + 'Session f1', + ]) + expect(mocks.listChats).toHaveBeenLastCalledWith({ limit: 3, cursor: 'cursor_2' }) + expect(screen.queryByText('Show more')).toBeNull() + }) + + it('creates a chat and opens its session from the + button', async () => { + const user = userEvent.setup() + render(, { wrapper: createWrapper(['/']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + await user.click(screen.getByRole('button', { name: 'New chat' })) + + await waitFor(() => expect(mocks.createChat).toHaveBeenCalledWith({})) + await waitFor(() => + expect(screen.getByTestId('location').textContent).toBe( + '/chats/20240104120000-111111/sessions/ses_new', + ), + ) + }) + + it('folds the group and keeps that choice for the next mount', async () => { + const user = userEvent.setup() + const firstRender = render(, { wrapper: createWrapper(['/']) }) + + await screen.findByRole('button', { name: /^Session a1/ }) + expect(screen.getByRole('button', { name: 'Show sessions in Chats' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + + await user.click(screen.getByRole('button', { name: 'Show sessions in Chats' })) + + expect(screen.getByRole('button', { name: 'Show sessions in Chats' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + expect(screen.queryByRole('button', { name: /^Session a1/ })).toBeNull() + + firstRender.unmount() + render(, { wrapper: createWrapper(['/']) }) + + expect(screen.getByRole('button', { name: 'Show sessions in Chats' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + expect(screen.queryByRole('button', { name: /^Session a1/ })).toBeNull() + }) + + it('marks the open chat session and group as current', async () => { + render(, { + wrapper: createWrapper([`/chats/${chatA.id}/sessions/a1`]), + }) + + expect(await screen.findByRole('button', { name: /^Session a1/ })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.getByRole('button', { name: /^Session b1/ })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('button', { name: 'Chats' })).toHaveAttribute('aria-current', 'page') + }) + + it('shows an empty state when there are no chats', async () => { + mocks.listChats.mockResolvedValue({ chats: [] }) + + render(, { wrapper: createWrapper(['/']) }) + + expect(await screen.findByText('No chats')).toBeTruthy() + }) +}) diff --git a/frontend/src/components/navigation/ChatsNavGroup.tsx b/frontend/src/components/navigation/ChatsNavGroup.tsx new file mode 100644 index 000000000..cbb70d3fb --- /dev/null +++ b/frontend/src/components/navigation/ChatsNavGroup.tsx @@ -0,0 +1,81 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { parseChatSessionRoute } from '@/lib/navigation' +import { usePersistentBoolean } from '@/hooks/usePersistentBoolean' +import { useChatSessions, type ChatSessionRow } from '@/hooks/useChats' +import { + NewChatButton, + RepoNavGroup, + SessionNavLink, + SessionNavRow, + SessionNavStatus, +} from '@/components/navigation/RepoSessionNav' +import { + SIDEBAR_CHATS_PER_PAGE, + isCurrentSessionItem, + type SidebarSessionItem, +} from '@/components/navigation/sidebar-session-tree' + +const CHATS_GROUP_OPEN_STORAGE_KEY = 'oc:sidebar:chats-open' + +function toChatItem(row: ChatSessionRow): SidebarSessionItem { + return { + key: row.key, + session: row.session, + chatId: row.chat.id, + repoLabel: 'Chat', + branchLabel: null, + isWorktree: false, + isPinned: row.isPinned, + path: row.path, + } +} + +/** + * The sidebar's chat group: the newest chat sessions above the repositories, expandable to more + * pages and persisted independently of the repository accordion. + */ +export function ChatsNavGroup() { + const navigate = useNavigate() + const location = useLocation() + const [isOpen, toggleOpen] = usePersistentBoolean(CHATS_GROUP_OPEN_STORAGE_KEY, true) + const { rows, hasMore, showMore, isLoading, isError } = useChatSessions(SIDEBAR_CHATS_PER_PAGE) + const firstRow = rows[0] + + const openGroup = () => { + if (firstRow) navigate(firstRow.path) + } + + return ( + } + > + {isLoading ? ( + Loading chats... + ) : isError ? ( + Failed to load chats + ) : rows.length === 0 ? ( + No chats + ) : ( + <> + {rows.map((row) => { + const item = toChatItem(row) + return ( + + ) + })} + {hasMore && Show more} + + )} + + ) +} diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index 864c4a8a3..e08d55f49 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -38,6 +38,8 @@ const { sessionPinsData, sessionsHasNextPage, searchError, + listChatsMock, + createChatMock, } = vi.hoisted(() => ({ listReposMock: vi.fn(), getRepoSiblingsMock: vi.fn(), @@ -51,6 +53,8 @@ const { sessionPinsData: { current: [] as SessionPinFixture[] }, sessionsHasNextPage: { current: false }, searchError: { current: false }, + listChatsMock: vi.fn(), + createChatMock: vi.fn(), })) vi.mock('@/api/repos', async (importOriginal) => { @@ -63,6 +67,11 @@ vi.mock('@/api/opencode', async (importOriginal) => { return { ...actual, getSession: getSessionMock } }) +vi.mock('@/api/chats', () => ({ + listChats: listChatsMock, + createChat: createChatMock, +})) + vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() return { @@ -187,6 +196,9 @@ describe('DesktopSessionTree', () => { sessionPinsData.current = [] sessionsHasNextPage.current = false searchError.current = false + listChatsMock.mockReset() + listChatsMock.mockResolvedValue({ chats: [] }) + createChatMock.mockReset() }) it('lists every repo by last access, even inside a repo, with no Recent section', async () => { @@ -197,10 +209,68 @@ describe('DesktopSessionTree', () => { .getAllByRole('button', { name: /^Show sessions in / }) .map((button) => button.getAttribute('aria-label')) - expect(names).toEqual(['Show sessions in Gamma', 'Show sessions in Alpha', 'Show sessions in Beta']) + expect(names).toEqual([ + 'Show sessions in Chats', + 'Show sessions in Gamma', + 'Show sessions in Alpha', + 'Show sessions in Beta', + ]) expect(screen.queryByText('Recent')).toBeNull() }) + it('puts the Chats group above the Assistant and every repository', async () => { + listReposMock.mockResolvedValue([ + repoA, + createRepo({ id: 0, fullPath: '/assistant', name: 'Assistant' }), + ]) + + render(, { wrapper: createWrapper(['/']) }) + + await screen.findByText('Assistant') + const names = screen + .getAllByRole('button', { name: /^Show sessions in / }) + .map((button) => button.getAttribute('aria-label')) + + expect(names).toEqual([ + 'Show sessions in Chats', + 'Show sessions in Assistant', + 'Show sessions in Alpha', + ]) + }) + + it('opens the newest chat from the Chats group header', async () => { + const user = userEvent.setup() + listChatsMock.mockResolvedValue({ + chats: [ + { + id: '20240103120000-aaaaaa', + directory: '/chats/a', + filesPath: '/chats/a', + createdAt: 1000, + }, + ], + }) + sessionsData.push(createSession('chat1', '/chats/a')) + + render(, { wrapper: createWrapper(['/']) }) + + await screen.findByRole('button', { name: /^Session chat1/ }) + await user.click(screen.getByRole('button', { name: 'Chats' })) + + expect(screen.getByTestId('location').textContent).toBe( + '/chats/20240103120000-aaaaaa/sessions/chat1', + ) + }) + + it('shows no Chats group for a linked server', async () => { + useSidebarServerStore.setState({ serverId: 'srv_remote' }) + + render(, { wrapper: createWrapper(['/']) }) + + expect(await screen.findByText('Server not found')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Show sessions in Chats' })).toBeNull() + }) + it('opens the active repo by default and lazily fetches only its sessions', async () => { render(, { wrapper: createWrapper(['/repos/1']) }) diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx index 05ca0cf67..c119f7c51 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -10,6 +10,7 @@ import { useSessionSearch } from '@/hooks/useSessionSearch' import { useSessionPins } from '@/hooks/useSessionPins' import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' +import { ChatsNavGroup } from '@/components/navigation/ChatsNavGroup' import { LocalRepoNewSessionButton, RepoNavGroup, @@ -187,16 +188,19 @@ function LocalSessionTree() { isSearchError={isSearchError} /> ) : ( - navigate(getRepoPath(repoId))} - onSelectSession={navigate} - renderActions={(repo) => ( - - )} - /> + <> + + navigate(getRepoPath(repoId))} + onSelectSession={navigate} + renderActions={(repo) => ( + + )} + /> + )} ) diff --git a/frontend/src/components/navigation/RepoSessionNav.tsx b/frontend/src/components/navigation/RepoSessionNav.tsx index e3c2edd66..ce139e038 100644 --- a/frontend/src/components/navigation/RepoSessionNav.tsx +++ b/frontend/src/components/navigation/RepoSessionNav.tsx @@ -5,6 +5,7 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { RepoActivityIndicator } from '@/components/activity/RepoActivityIndicator' import { useForms, usePermissions } from '@/contexts/EventContext' import { useCreateSession } from '@/hooks/useOpenCode' +import { useCreateChat } from '@/hooks/useChats' import { useRepoSiblings } from '@/hooks/useRepoSiblings' import { useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' import { buildWorktreeActivity, type ActiveWorktree } from '@/lib/activeWork' @@ -298,6 +299,40 @@ export function RepoSessionNavList({ ) } +interface GroupActionButtonProps { + title: string + ariaLabel: string + isPending: boolean + onClick: () => void +} + +function GroupActionButton({ title, ariaLabel, isPending, onClick }: GroupActionButtonProps) { + return ( + + ) +} + interface NewSessionButtonProps { directory: string label: string @@ -321,31 +356,26 @@ export function NewSessionButton({ serverId, ) - const isCreating = createSession.isPending - return ( - + /> + ) +} + +export function NewChatButton({ onOpenSession }: { onOpenSession: (path: string) => void }) { + const createChat = useCreateChat(onOpenSession) + + return ( + createChat.mutate({})} + /> ) } diff --git a/frontend/src/components/navigation/sidebar-session-tree.test.ts b/frontend/src/components/navigation/sidebar-session-tree.test.ts index cf95e9e0c..9d9c31c57 100644 --- a/frontend/src/components/navigation/sidebar-session-tree.test.ts +++ b/frontend/src/components/navigation/sidebar-session-tree.test.ts @@ -72,6 +72,27 @@ describe('isCurrentSessionItem', () => { }) }) +describe('isCurrentSessionItem for chat rows', () => { + const chatId = '20240103120000-aaaaaa' + const item = { + key: `/chats/${chatId}:c1`, + session: createSession('c1', NOW, `/chats/${chatId}`), + chatId, + repoLabel: 'Chat', + branchLabel: null, + isWorktree: false, + isPinned: false, + path: `/chats/${chatId}/sessions/c1`, + } + + it('matches only the exact chat and session route', () => { + expect(isCurrentSessionItem(item, `/chats/${chatId}/sessions/c1`)).toBe(true) + expect(isCurrentSessionItem(item, `/chats/${chatId}/sessions/c2`)).toBe(false) + expect(isCurrentSessionItem(item, '/chats/20240102120000-bbbbbb/sessions/c1')).toBe(false) + expect(isCurrentSessionItem(item, `/repos/1/sessions/c1`)).toBe(false) + }) +}) + describe('isRepoReady', () => { it('is true only for ready repos', () => { expect(isRepoReady(createRepo({ id: 1, fullPath: '/repos/a' }))).toBe(true) diff --git a/frontend/src/components/navigation/sidebar-session-tree.ts b/frontend/src/components/navigation/sidebar-session-tree.ts index ffb0b28aa..a617a370d 100644 --- a/frontend/src/components/navigation/sidebar-session-tree.ts +++ b/frontend/src/components/navigation/sidebar-session-tree.ts @@ -2,11 +2,18 @@ import type { Repo, Session } from '@/api/types' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' import { getSessionKey } from '@/lib/sessionKey' -import { getSessionPath, isAssistantPath, parseRepoRoute, parseServerSessionRoute } from '@/lib/navigation' +import { + getSessionPath, + isAssistantPath, + parseChatSessionRoute, + parseRepoRoute, + parseServerSessionRoute, +} from '@/lib/navigation' import { partitionSessions, selectRootSessions } from '@/components/session/session-partition' import type { ActiveWorktree } from '@/lib/activeWork' export const SIDEBAR_SESSIONS_PER_REPO = 10 +export const SIDEBAR_CHATS_PER_PAGE = 3 /** Collects the loaded session from each query result, dropping unresolved queries. */ export const combineQuerySessions = (results: Array<{ data?: Session }>): Session[] => @@ -28,8 +35,11 @@ export function mergeExtraSessions(sessions: Session[], extraSessions: Session[] export interface SidebarSessionItem { key: string session: Session - repoId: number - /** The linked server owning the session; absent for local repo sessions. */ + /** The repository owning the session; absent for chat and linked-server sessions. */ + repoId?: number + /** The chat folder owning the session; absent for repo and linked-server sessions. */ + chatId?: string + /** The linked server owning the session; absent for local repo and chat sessions. */ serverId?: string repoLabel: string branchLabel: string | null @@ -48,6 +58,10 @@ export interface SidebarRepoGroup { } export function isCurrentSessionItem(item: SidebarSessionItem, pathname: string): boolean { + if (item.chatId) { + const selection = parseChatSessionRoute(pathname) + return selection?.chatId === item.chatId && item.session.id === selection.sessionId + } if (item.serverId) { const selection = parseServerSessionRoute(pathname) return selection?.serverId === item.serverId && item.session.id === selection.sessionId From 5846ee3c209b7d624ca19377ce3719bee483df38 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:54:59 -0500 Subject: [PATCH 5/6] Chats tab in the session picker (phone sheet and desktop --- .../components/session/ChatPickerContent.tsx | 217 ++++++++++++ .../session/SessionPickerDialog.test.tsx | 197 ++++++++++- .../session/SessionPickerDialog.tsx | 316 +++--------------- .../components/session/SessionPickerList.tsx | 274 +++++++++++++++ .../session/SessionPickerSheet.test.tsx | 4 +- 5 files changed, 729 insertions(+), 279 deletions(-) create mode 100644 frontend/src/components/session/ChatPickerContent.tsx create mode 100644 frontend/src/components/session/SessionPickerList.tsx diff --git a/frontend/src/components/session/ChatPickerContent.tsx b/frontend/src/components/session/ChatPickerContent.tsx new file mode 100644 index 000000000..82c1eca3e --- /dev/null +++ b/frontend/src/components/session/ChatPickerContent.tsx @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import type { Session } from '@/api/types' +import { Button } from '@/components/ui/button' +import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' +import { + SessionPickerList, + type SessionPickerRow, + type SessionPickerSection, +} from '@/components/session/SessionPickerList' +import { + PickerSearchBar, + optionId, + usePickerCursor, + usePickerKeyNav, +} from '@/components/session/usePickerKeyboardNav' +import { useChatSessions, useDeleteChatSession, type ChatSessionRow } from '@/hooks/useChats' +import { useToggleSessionPin } from '@/hooks/useSessionPins' + +interface ChatPickerContentProps { + open: boolean + activeSessionID?: string + onNavigate: (path: string) => void + onDeleteActiveSession: () => void + searchInputRef: React.RefObject + searchTrailing?: React.ReactNode +} + +/** + * Chat sessions for the shared session picker: newest first, filtered by title, with pin and + * confirm-before-delete actions on the same row menu as every other picker list. + */ +export function ChatPickerContent({ + open, + activeSessionID, + onNavigate, + onDeleteActiveSession, + searchInputRef, + searchTrailing, +}: ChatPickerContentProps) { + const { rows: chatRows, hasMore, showMore, isLoading, isError } = useChatSessions(10) + const listId = useId() + const optionBaseId = useId() + const togglePin = useToggleSessionPin() + const deleteChatSession = useDeleteChatSession() + const [query, setQuery] = useState('') + const [chatToDelete, setChatToDelete] = useState(null) + const trimmedQuery = query.trim() + + const pinnedKeys = useMemo( + () => new Set(chatRows.filter((row) => row.isPinned).map((row) => row.key)), + [chatRows], + ) + const rowByKey = useMemo(() => new Map(chatRows.map((row) => [row.key, row])), [chatRows]) + + const sections = useMemo(() => { + const needle = trimmedQuery.toLowerCase() + const filtered = needle + ? chatRows.filter((row) => (row.session.title ?? '').toLowerCase().includes(needle)) + : chatRows + return filtered.length > 0 ? [{ id: 'chats', heading: 'Chats', rows: filtered }] : [] + }, [chatRows, trimmedQuery]) + const rows = useMemo(() => sections.flatMap((section) => section.rows), [sections]) + + const activeRow = useMemo( + () => rows.find((row) => row.session.id === activeSessionID), + [rows, activeSessionID], + ) + const defaultCursorKey = !trimmedQuery && activeRow ? activeRow.key : rows[0]?.key ?? null + + const { + rowIndexByKey, + cursorIndex, + cursorRow, + registerRef, + moveCursor, + resetCursor, + } = usePickerCursor(rows, defaultCursorKey) + + const openRow = useCallback( + (row: SessionPickerRow | undefined) => { + if (!row) return + const chatRow = rowByKey.get(row.key) + if (chatRow) onNavigate(chatRow.path) + }, + [rowByKey, onNavigate], + ) + + const requestDeleteRow = useCallback( + (row: SessionPickerRow | undefined) => { + const chatRow = row ? rowByKey.get(row.key) : undefined + if (chatRow) setChatToDelete(chatRow) + }, + [rowByKey], + ) + + const handleRequestDelete = useCallback( + (session: Session) => { + const chatRow = chatRows.find((row) => row.session.id === session.id) + if (chatRow) setChatToDelete(chatRow) + }, + [chatRows], + ) + + const handleTogglePin = useCallback( + (row: SessionPickerRow) => { + const chatRow = rowByKey.get(row.key) + if (!chatRow) return + togglePin.mutate({ + sessionId: chatRow.session.id, + directory: chatRow.session.location.directory, + pinned: !pinnedKeys.has(chatRow.key), + }) + }, + [rowByKey, pinnedKeys, togglePin], + ) + + useEffect(() => { + resetCursor() + }, [trimmedQuery, resetCursor]) + + const handleSearchKeyDown = usePickerKeyNav({ + rows, + cursorIndex, + cursorRow, + moveCursor, + onOpenRow: openRow, + onDeleteRow: requestDeleteRow, + }) + + const confirmDelete = useCallback(() => { + if (!chatToDelete) return + if (chatToDelete.session.id === activeSessionID) onDeleteActiveSession() + void deleteChatSession + .mutateAsync({ + chatId: chatToDelete.chat.id, + sessionId: chatToDelete.session.id, + directory: chatToDelete.session.location.directory, + }) + .catch(() => undefined) + .finally(() => setChatToDelete(null)) + }, [chatToDelete, activeSessionID, onDeleteActiveSession, deleteChatSession]) + + const emptyState = isLoading + ? 'Loading chats...' + : isError + ? 'Failed to load chats' + : trimmedQuery + ? 'No chats found' + : 'No chats yet' + + return ( + <> +
+ = 0 ? optionId(optionBaseId, cursorIndex) : undefined} + value={query} + onChange={setQuery} + onKeyDown={handleSearchKeyDown} + placeholder="Search chats..." + name="chat-picker-search" + trailing={searchTrailing} + /> + + + {hasMore && ( +
+ +
+ )} +
+ +
+ ↑↓ move · Enter open · Ctrl+D delete +
+
+ + { + if (!nextOpen) setChatToDelete(null) + }} + onConfirm={confirmDelete} + onCancel={() => setChatToDelete(null)} + isDeleting={deleteChatSession.isPending} + /> + + ) +} diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 02f76dc47..9b03fd9ec 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -5,6 +5,7 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-librar import userEvent from '@testing-library/user-event' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Repo, Session } from '@/api/types' +import type { ChatSessionRow } from '@/hooks/useChats' import type { ActiveWorkGroups, ActiveWorkItem, ActiveWorkState } from '@/lib/activeWork' import { stubMatchMedia } from '@/test/test-utils' import { makeRepo, makeSession } from '@/test/fixtures/session-picker' @@ -33,6 +34,15 @@ const mocks = vi.hoisted(() => ({ repos: [] as Repo[], activeWorkGroups: { needsInput: [], running: [], recent: [] } as ActiveWorkGroups, activeWorkWindowMs: 0, + chatRows: [] as ChatSessionRow[], + chatHasMore: false, + chatIsLoading: false, + chatIsError: false, + chatError: undefined as Error | undefined, + createdChatId: '20260101-120000-abc123', + showMoreChats: vi.fn(), + createChatMock: vi.fn(), + deleteChatSessionMock: vi.fn(), preferences: undefined as | { leaderKey?: string @@ -113,6 +123,32 @@ vi.mock('@/hooks/useSessionPins', () => ({ useToggleSessionPin: () => ({ mutate: mocks.togglePinMock }), })) +vi.mock('@/hooks/useChats', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useChatSessions: () => ({ + rows: mocks.chatRows, + hasMore: mocks.chatHasMore, + showMore: mocks.showMoreChats, + isLoading: mocks.chatIsLoading, + isError: mocks.chatIsError, + error: mocks.chatError, + }), + useCreateChat: (onCreated?: (path: string) => void) => ({ + mutate: () => { + mocks.createChatMock() + onCreated?.(`/chats/${mocks.createdChatId}/sessions/ses_new`) + }, + isPending: false, + }), + useDeleteChatSession: () => ({ + mutateAsync: mocks.deleteChatSessionMock, + isPending: false, + }), + } +}) + vi.mock('@/components/navigation/RemoteProjectNav', () => ({ RemoteProjectNavList: ({ serverId }: { serverId: string }) => (
@@ -157,6 +193,23 @@ function activeWorkItem( } } +function makeChatRow( + sessionId: string, + title: string, + directory: string, + updated: number, + chatId: string, +): ChatSessionRow { + const session = makeSession(sessionId, title, directory, updated) + return { + key: `${directory}:${sessionId}`, + session, + chat: { id: chatId, directory, filesPath: `${directory}/.chats/${chatId}`, createdAt: updated }, + path: `/chats/${chatId}/sessions/${sessionId}`, + isPinned: false, + } +} + function renderWithClient(ui: React.ReactElement, serverId?: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const directory = mocks.repos[0]?.fullPath @@ -205,6 +258,10 @@ const openActiveTab = () => { fireEvent.mouseDown(screen.getByRole('tab', { name: 'Activity' })) } +const openChatsTab = () => { + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Chats' })) +} + describe('SessionPickerDialog', () => { beforeEach(() => { vi.clearAllMocks() @@ -225,6 +282,12 @@ describe('SessionPickerDialog', () => { mocks.sessionsData = [] mocks.activeWorkGroups = { needsInput: [], running: [], recent: [] } mocks.activeWorkWindowMs = 0 + mocks.chatRows = [] + mocks.chatHasMore = false + mocks.chatIsLoading = false + mocks.chatIsError = false + mocks.chatError = undefined + mocks.deleteChatSessionMock.mockResolvedValue(undefined) mocks.preferences = undefined }) @@ -669,11 +732,12 @@ describe('SessionPickerDialog', () => { expect(screen.getByRole('option', { name: /Active session/ })).toHaveAttribute('aria-selected', 'true') }) - it('renders an Activity/Sessions/Projects tab switcher and defaults to the sessions tab', () => { + it('renders an Activity/Sessions/Chats/Projects tab switcher and defaults to the sessions tab', () => { renderPicker() 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: 'Chats' })).toHaveAttribute('aria-selected', 'false') expect(screen.getByRole('tab', { name: 'Projects' })).toHaveAttribute('aria-selected', 'false') expect(combobox()).toHaveAttribute('placeholder', 'Search sessions...') }) @@ -714,6 +778,8 @@ describe('SessionPickerDialog', () => { await user.click(screen.getByPlaceholderText('Search sessions...')) await user.keyboard('{Control>}s{/Control}') + await waitFor(() => expect(screen.getByPlaceholderText('Search chats...')).toHaveFocus()) + await user.keyboard('{Control>}s{/Control}') await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) }) @@ -726,6 +792,8 @@ describe('SessionPickerDialog', () => { await user.click(screen.getByPlaceholderText('Search sessions...')) await user.keyboard('{Control>}s{/Control}') + await waitFor(() => expect(screen.getByPlaceholderText('Search chats...')).toHaveFocus()) + await user.keyboard('{Control>}s{/Control}') await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) }) @@ -826,11 +894,14 @@ describe('SessionPickerDialog', () => { expect(screen.getByText('No projects found')).toBeInTheDocument() }) - it('cycles Activity, Sessions and Projects with the default Ctrl+S binding', async () => { + it('cycles Sessions, Chats, Projects and Activity with the default Ctrl+S binding', async () => { renderPicker() expect(screen.getByRole('tab', { name: 'This project', selected: true })).toBeInTheDocument() + fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) + expect(await screen.findByRole('tab', { name: 'Chats', selected: true })).toBeInTheDocument() + fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() @@ -847,6 +918,9 @@ describe('SessionPickerDialog', () => { expect(screen.getByText(/Ctrl\+G projects/)).toBeInTheDocument() + fireEvent.keyDown(combobox(), { key: 'g', ctrlKey: true }) + expect(await screen.findByRole('tab', { name: 'Chats', selected: true })).toBeInTheDocument() + fireEvent.keyDown(combobox(), { key: 'g', ctrlKey: true }) expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() @@ -1013,6 +1087,124 @@ describe('SessionPickerDialog', () => { }) }) + describe('chats tab', () => { + const chatFixture = () => [ + makeChatRow('ses_new', 'Newest chat', '/w/chat-newest', 3000, '20260103-100000-cccccc'), + makeChatRow('ses_mid', 'Middle chat', '/w/chat-mid', 2000, '20260102-100000-bbbbbb'), + makeChatRow('ses_old', 'Oldest chat', '/w/chat-old', 1000, '20260101-100000-aaaaaa'), + ] + + it('lists chat sessions newest first in the order the hook returns them', async () => { + mocks.chatRows = chatFixture() + renderPicker() + + openChatsTab() + + const options = await screen.findAllByRole('option') + expect(options).toHaveLength(3) + expect(options[0]).toHaveTextContent('Newest chat') + expect(options[1]).toHaveTextContent('Middle chat') + expect(options[2]).toHaveTextContent('Oldest chat') + }) + + it('filters chats by title without matching other tabs', async () => { + mocks.chatRows = chatFixture() + renderPicker() + + openChatsTab() + await screen.findByRole('option', { name: /Newest chat/ }) + + fireEvent.change(combobox(), { target: { value: 'middle' } }) + + expect(screen.queryByRole('option', { name: /Newest chat/ })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /Middle chat/ })).toBeInTheDocument() + + fireEvent.change(combobox(), { target: { value: 'zzz' } }) + + expect(screen.getByText('No chats found')).toBeInTheDocument() + }) + + it('shows the show more action and forwards it to the chats hook', async () => { + mocks.chatRows = chatFixture() + mocks.chatHasMore = true + renderPicker() + + openChatsTab() + await screen.findByRole('option', { name: /Newest chat/ }) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + + expect(mocks.showMoreChats).toHaveBeenCalledTimes(1) + }) + + it('creates a chat from the header action and navigates to its new session', async () => { + const onNavigate = vi.fn() + renderPicker({ onNavigate }) + + openChatsTab() + fireEvent.click(await screen.findByRole('button', { name: 'New chat' })) + + expect(mocks.createChatMock).toHaveBeenCalledTimes(1) + expect(onNavigate).toHaveBeenCalledWith( + `/chats/${mocks.createdChatId}/sessions/ses_new`, + ) + }) + + it('pins a chat from the shared row menu', async () => { + mocks.chatRows = [chatFixture()[0]] + renderPicker() + const user = userEvent.setup() + + openChatsTab() + await screen.findByRole('option', { name: /Newest chat/ }) + await user.click(screen.getByRole('button', { name: 'Session actions' })) + await user.click(await screen.findByText('Pin')) + + expect(mocks.togglePinMock).toHaveBeenCalledWith({ + sessionId: 'ses_new', + directory: '/w/chat-newest', + pinned: true, + }) + }) + + it('opens the delete confirmation with Ctrl+D', async () => { + mocks.chatRows = [chatFixture()[0]] + renderPicker() + + openChatsTab() + await screen.findByRole('option', { name: /Newest chat/ }) + fireEvent.keyDown(combobox(), { key: 'd', ctrlKey: true }) + + expect(await screen.findByRole('dialog', { name: 'Delete Session' })).toBeInTheDocument() + expect(mocks.deleteChatSessionMock).not.toHaveBeenCalled() + }) + + it('deletes a chat session after confirmation and clears the active session first', async () => { + mocks.chatRows = [chatFixture()[0]] + const { onDeleteActiveSession } = renderPicker({ activeSessionID: 'ses_new' }) + const user = userEvent.setup() + + openChatsTab() + await screen.findByRole('option', { name: /Newest chat/ }) + await user.click(screen.getByRole('button', { name: 'Session actions' })) + await user.click(await screen.findByText('Delete')) + + const dialog = await screen.findByRole('dialog', { name: 'Delete Session' }) + await user.click(within(dialog).getByRole('button', { name: 'Delete' })) + + await waitFor(() => + expect(mocks.deleteChatSessionMock).toHaveBeenCalledWith({ + chatId: '20260103-100000-cccccc', + sessionId: 'ses_new', + directory: '/w/chat-newest', + }), + ) + expect(onDeleteActiveSession).toHaveBeenCalledTimes(1) + expect(onDeleteActiveSession.mock.invocationCallOrder[0]).toBeLessThan( + mocks.deleteChatSessionMock.mock.invocationCallOrder[0], + ) + }) + }) + describe('linked-server mode', () => { beforeEach(() => { mocks.repos = [makeRepo(1, 'alpha', '/w/a')] @@ -1032,6 +1224,7 @@ describe('SessionPickerDialog', () => { expect(screen.getByRole('tab', { name: 'Projects' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'Chats' })).not.toBeInTheDocument() const row = await screen.findByRole('option', { name: /Remote session/ }) expect(row).toHaveTextContent('Kitchen') diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 9e428d226..a2cc33206 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -1,7 +1,7 @@ -import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { format, startOfDay } from 'date-fns' -import { ChevronsUpDown, MoreHorizontal, Pin, PinOff, Plus } from 'lucide-react' +import { ChevronsUpDown, Plus } from 'lucide-react' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { OpenCodeServerProvider, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { openCodeLocationQueryOptions } from '@/lib/openCodeLocation' @@ -9,18 +9,17 @@ import type { Repo, Session } from '@/api/types' import { ACTIVE_WORK_GROUPS, MS_PER_MINUTE, formatActivityEmptyMessage } from '@/lib/activeWork' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' +import { ChatPickerContent } from '@/components/session/ChatPickerContent' +import { + SessionPickerList, + type SessionPickerRow, + type SessionPickerSection, +} from '@/components/session/SessionPickerList' import { ProjectPickerContent } from '@/components/session/ProjectPickerContent' import { AddRepoDialog } from '@/components/repo/AddRepoDialog' -import { LocalRepoNewSessionButton } from '@/components/navigation/RepoSessionNav' +import { LocalRepoNewSessionButton, NewChatButton } from '@/components/navigation/RepoSessionNav' import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { PickerSearchBar, @@ -36,25 +35,25 @@ import { useSessionSearch } from '@/hooks/useSessionSearch' import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins' import { usePersistentBoolean } from '@/hooks/usePersistentBoolean' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' -import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' import { useSettings } from '@/hooks/useSettings' import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' import { formatShortcutLabel, resolveShortcutBindings } from '@/lib/keyboardShortcuts' -import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' +import { cn } from '@/lib/utils' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' const ALL_PROJECTS_STORAGE_KEY = 'oc:session-picker:all-projects' -const UNTITLED_SESSION_TITLE = 'Untitled Session' const NEAR_BOTTOM_PX = 240 -type SessionPickerTab = 'active' | 'sessions' | 'projects' +type SessionPickerTab = 'active' | 'sessions' | 'chats' | 'projects' -const SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'projects'] +const SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'chats', 'projects'] +const REMOTE_SESSION_PICKER_TABS: SessionPickerTab[] = ['active', 'sessions', 'projects'] const SESSION_PICKER_TAB_LABELS: Record = { active: 'Activity', sessions: 'Sessions', + chats: 'Chats', projects: 'Projects', } @@ -84,21 +83,6 @@ type SessionPickerDialogProps = Omit< onOpenChange: (open: boolean) => void } -interface SessionPickerRow { - key: string - session: Session - repo?: Repo - serverId?: string - serverName?: string - updatedAt?: number -} - -interface SessionPickerSection { - id: string - heading: string - rows: SessionPickerRow[] -} - function buildSections( sessions: Session[], pinnedKeys: ReadonlySet, @@ -180,143 +164,6 @@ function sessionWorktreeLabel(session: Session, canonical: string | undefined): return segments[segments.length - 1] } -function SessionPickerGutter({ sessionID, isActive }: { sessionID: string; isActive: boolean }) { - const status = useSessionStatusForSession(sessionID) - if (status.type !== 'idle') { - return ( - - ) - } - if (isActive) { - return - } - return null -} - -interface SessionPickerRowItemProps { - row: SessionPickerRow - index: number - optionId: string - isCursor: boolean - isActive: boolean - isPinned: boolean - isPendingDelete: boolean - showRepo: boolean - showActions: boolean - label?: string - onOpen: (row: SessionPickerRow) => void - onHover: (index: number) => void - onTogglePin: (row: SessionPickerRow) => void - onRequestDelete: (session: Session) => void - registerRef: (key: string, node: HTMLDivElement | null) => void -} - -const SessionPickerRowItem = memo(function SessionPickerRowItem({ - row, - index, - optionId, - isCursor, - isActive, - isPinned, - isPendingDelete, - showRepo, - showActions, - label, - onOpen, - onHover, - onTogglePin, - onRequestDelete, - registerRef, -}: SessionPickerRowItemProps) { - return ( -
registerRef(row.key, node)} - role="presentation" - onPointerMove={(event) => { - if (event.pointerType === 'mouse') onHover(index) - }} - className={cn( - 'group flex items-center rounded-md text-sm min-h-8 pointer-coarse:min-h-11', - isPendingDelete - ? 'bg-destructive text-destructive-foreground' - : isCursor - ? 'bg-primary text-primary-foreground' - : 'hover:bg-accent', - )} - > -
onOpen(row)} - className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2" - > - - - - - {isPendingDelete ? 'Press Ctrl+D again to confirm' : row.session.title || UNTITLED_SESSION_TITLE} - - - {showRepo && row.repo ? ( - {getRepoDisplayName(row.repo)} - ) : null} - {showRepo && !row.repo && (row.serverName || row.serverId) ? ( - {row.serverName ?? row.serverId} - ) : null} - {!showRepo && label && {label}} - {formatShortRelativeTime(new Date(row.updatedAt ?? row.session.time.updated))} - -
-
- {row.serverId || !showActions ? null : - - - - event.stopPropagation()}> - onTogglePin(row)}> - {isPinned ? : } - {isPinned ? 'Unpin' : 'Pin'} - - onRequestDelete(row.session)}>Delete - - } -
-
- ) -}) - interface SessionPickerRowActionsOptions { directories: string[] activeSessionID?: string @@ -446,112 +293,6 @@ function useSessionPickerRowActions({ } } -interface SessionPickerListProps { - listId: string - optionBaseId: string - ariaLabel: string - sections: SessionPickerSection[] - rows: SessionPickerRow[] - rowIndexByKey: ReadonlyMap - cursorIndex: number - activeSessionID?: string - pinnedKeys: ReadonlySet - pendingDeleteKey: string | null - showRepo: boolean - showRowActions?: boolean - labelFor?: (row: SessionPickerRow) => string | undefined - emptyState: React.ReactNode - onOpenRow: (row: SessionPickerRow) => void - onHoverRow: (index: number) => void - onTogglePin: (row: SessionPickerRow) => void - onRequestDelete: (session: Session) => void - registerRef: (key: string, node: HTMLDivElement | null) => void - listRef?: React.RefObject - onScroll?: (event: React.UIEvent) => void - children?: React.ReactNode -} - -function SessionPickerList({ - listId, - optionBaseId, - ariaLabel, - sections, - rows, - rowIndexByKey, - cursorIndex, - activeSessionID, - pinnedKeys, - pendingDeleteKey, - showRepo, - showRowActions = true, - labelFor, - emptyState, - onOpenRow, - onHoverRow, - onTogglePin, - onRequestDelete, - registerRef, - listRef, - onScroll, - children, -}: SessionPickerListProps) { - return ( -
- {rows.length === 0 ? ( -
{emptyState}
- ) : ( - <> - {sections.map((section) => ( -
-
- {section.heading} -
- {section.rows.map((row) => { - const index = rowIndexByKey.get(row.key) ?? -1 - return ( - - ) - })} -
- ))} - {children} - - )} -
- ) -} - interface SessionPickerContentProps { open: boolean currentRepo: Repo | undefined @@ -1240,6 +981,7 @@ function SessionPickerPanelBody({ }: SessionPickerPanelProps) { const searchInputRef = useRef(null) const activeSearchInputRef = useRef(null) + const chatsSearchInputRef = useRef(null) const projectSearchInputRef = useRef(null) const focusSearchAfterSwitchRef = useRef(false) const scopePressRef = useRef(false) @@ -1267,14 +1009,18 @@ function SessionPickerPanelBody({ if (open) setActiveTab('sessions') } + if (isRemote && activeTab === 'chats') setActiveTab('sessions') + const scopeEnabled = !isRemote && Boolean(currentRepo) const allProjects = scopeEnabled ? allProjectsPreference : true const readyRepo = scopeEnabled && currentRepo && isRepoReady(currentRepo) ? currentRepo : undefined const toggleTab = useCallback(() => { focusSearchAfterSwitchRef.current = true - setActiveTab((current) => nextSessionPickerTab(current, SESSION_PICKER_TABS)) - }, []) + setActiveTab((current) => + nextSessionPickerTab(current, isRemote ? REMOTE_SESSION_PICKER_TABS : SESSION_PICKER_TABS), + ) + }, [isRemote]) useShortcutActions({ switchPickerTab: open ? toggleTab : undefined }) @@ -1288,6 +1034,7 @@ function SessionPickerPanelBody({ focusSearchAfterSwitchRef.current = false if (activeTab === 'projects') projectSearchInputRef.current?.focus() else if (activeTab === 'active') activeSearchInputRef.current?.focus() + else if (activeTab === 'chats') chatsSearchInputRef.current?.focus() else searchInputRef.current?.focus() }) return () => cancelAnimationFrame(frame) @@ -1326,6 +1073,7 @@ function SessionPickerPanelBody({ } const sessionsTrailing = withTrailing(newSessionButton) + const chatsTrailing = withTrailing() const projectsTrailing = withTrailing(addRepoButton) const canToggleScope = scopeEnabled && activeTab === 'sessions' @@ -1362,7 +1110,7 @@ function SessionPickerPanelBody({ >
{renderTitle?.(SESSION_PICKER_TAB_LABELS[activeTab])} - + {SESSION_PICKER_TAB_LABELS.active} + {!isRemote && {SESSION_PICKER_TAB_LABELS.chats}} {SESSION_PICKER_TAB_LABELS.projects}
@@ -1428,6 +1177,21 @@ function SessionPickerPanelBody({ /> )} + {!remote && ( + + + + )} + ) + } + if (isActive) { + return + } + return null +} + +interface SessionPickerRowItemProps { + row: SessionPickerRow + index: number + optionId: string + isCursor: boolean + isActive: boolean + isPinned: boolean + isPendingDelete: boolean + showRepo: boolean + showActions: boolean + label?: string + onOpen: (row: SessionPickerRow) => void + onHover: (index: number) => void + onTogglePin: (row: SessionPickerRow) => void + onRequestDelete: (session: Session) => void + registerRef: (key: string, node: HTMLDivElement | null) => void +} + +const SessionPickerRowItem = memo(function SessionPickerRowItem({ + row, + index, + optionId, + isCursor, + isActive, + isPinned, + isPendingDelete, + showRepo, + showActions, + label, + onOpen, + onHover, + onTogglePin, + onRequestDelete, + registerRef, +}: SessionPickerRowItemProps) { + return ( +
registerRef(row.key, node)} + role="presentation" + onPointerMove={(event) => { + if (event.pointerType === 'mouse') onHover(index) + }} + className={cn( + 'group flex items-center rounded-md text-sm min-h-8 pointer-coarse:min-h-11', + isPendingDelete + ? 'bg-destructive text-destructive-foreground' + : isCursor + ? 'bg-primary text-primary-foreground' + : 'hover:bg-accent', + )} + > +
onOpen(row)} + className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2" + > + + + + + {isPendingDelete ? 'Press Ctrl+D again to confirm' : row.session.title || UNTITLED_SESSION_TITLE} + + + {showRepo && row.repo ? ( + {getRepoDisplayName(row.repo)} + ) : null} + {showRepo && !row.repo && (row.serverName || row.serverId) ? ( + {row.serverName ?? row.serverId} + ) : null} + {!showRepo && label && {label}} + {formatShortRelativeTime(new Date(row.updatedAt ?? row.session.time.updated))} + +
+
+ {row.serverId || !showActions ? null : + + + + event.stopPropagation()}> + onTogglePin(row)}> + {isPinned ? : } + {isPinned ? 'Unpin' : 'Pin'} + + onRequestDelete(row.session)}>Delete + + } +
+
+ ) +}) + +interface SessionPickerListProps { + listId: string + optionBaseId: string + ariaLabel: string + sections: SessionPickerSection[] + rows: SessionPickerRow[] + rowIndexByKey: ReadonlyMap + cursorIndex: number + activeSessionID?: string + pinnedKeys: ReadonlySet + pendingDeleteKey: string | null + showRepo: boolean + showRowActions?: boolean + labelFor?: (row: SessionPickerRow) => string | undefined + emptyState: React.ReactNode + onOpenRow: (row: SessionPickerRow) => void + onHoverRow: (index: number) => void + onTogglePin: (row: SessionPickerRow) => void + onRequestDelete: (session: Session) => void + registerRef: (key: string, node: HTMLDivElement | null) => void + listRef?: React.RefObject + onScroll?: (event: React.UIEvent) => void + children?: React.ReactNode +} + +export function SessionPickerList({ + listId, + optionBaseId, + ariaLabel, + sections, + rows, + rowIndexByKey, + cursorIndex, + activeSessionID, + pinnedKeys, + pendingDeleteKey, + showRepo, + showRowActions = true, + labelFor, + emptyState, + onOpenRow, + onHoverRow, + onTogglePin, + onRequestDelete, + registerRef, + listRef, + onScroll, + children, +}: SessionPickerListProps) { + return ( +
+ {rows.length === 0 ? ( +
{emptyState}
+ ) : ( + <> + {sections.map((section) => ( +
+
+ {section.heading} +
+ {section.rows.map((row) => { + const index = rowIndexByKey.get(row.key) ?? -1 + return ( + + ) + })} +
+ ))} + {children} + + )} +
+ ) +} diff --git a/frontend/src/components/session/SessionPickerSheet.test.tsx b/frontend/src/components/session/SessionPickerSheet.test.tsx index 87357acb4..ee417a866 100644 --- a/frontend/src/components/session/SessionPickerSheet.test.tsx +++ b/frontend/src/components/session/SessionPickerSheet.test.tsx @@ -203,12 +203,13 @@ describe('SessionPickerSheet', () => { Reflect.deleteProperty(window, 'matchMedia') }) - it('renders the picker in a bottom sheet with Activity, Sessions and Projects tabs', () => { + it('renders the picker in a bottom sheet with Activity, Sessions, Chats 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: 'Chats' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Projects' })).toBeInTheDocument() expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() }) @@ -267,6 +268,7 @@ describe('SessionPickerSheet', () => { expect(mocks.lastAllDirectories).toBe(true) expect(screen.getByTestId('linked-servers-nav')).toBeInTheDocument() expect(screen.queryByRole('tab', { name: 'This project' })).not.toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'Chats' })).not.toBeInTheDocument() fireEvent.click(row) From 8af2c189e9f3b63bc8ed18317a57f873ec9cd722 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:46:21 -0500 Subject: [PATCH 6/6] Apply remaining changes --- backend/src/index.ts | 2 +- backend/src/services/chats.ts | 52 ++++++--- backend/src/services/notification.ts | 4 +- backend/test/routes/chats.test.ts | 2 +- backend/test/services/chats.test.ts | 108 ++++++++++++++++-- .../test/services/notification-format.test.ts | 3 + backend/test/utils/repo-url.test.ts | 27 +++++ frontend/src/App.permission-dialog.test.tsx | 32 ++++-- .../src/App.push-subscription-sync.test.tsx | 2 +- frontend/src/App.tsx | 4 +- .../navigation/ChatsNavGroup.test.tsx | 9 ++ .../components/navigation/ChatsNavGroup.tsx | 26 ++--- .../navigation/moreDrawerItems.test.ts | 10 +- .../components/navigation/moreDrawerItems.ts | 7 +- .../components/session/ChatPickerContent.tsx | 30 ++--- .../session/SessionPickerDialog.test.tsx | 13 +++ frontend/src/contexts/EventContext.test.tsx | 35 +++++- frontend/src/contexts/EventContext.tsx | 32 +++--- frontend/src/hooks/useChats.test.tsx | 11 +- frontend/src/hooks/useChats.ts | 1 - frontend/src/hooks/useCommands.test.tsx | 26 ++++- frontend/src/hooks/useCommands.ts | 8 +- .../hooks/useGlobalShortcutActions.test.tsx | 29 ++++- .../src/hooks/useGlobalShortcutActions.ts | 6 +- .../hooks/useSessionPickerNavigation.test.tsx | 14 +++ .../src/hooks/useSessionPickerNavigation.ts | 7 ++ frontend/src/lib/activeWork.test.ts | 29 +++++ frontend/src/lib/activeWork.ts | 52 ++++++--- frontend/src/lib/builtinCommands.test.ts | 48 ++++++++ frontend/src/lib/builtinCommands.ts | 26 ++++- frontend/src/pages/SessionDetail.tsx | 13 ++- .../__tests__/SessionDetail.chat.test.tsx | 15 +++ shared/src/utils/repo.ts | 23 ++++ 33 files changed, 576 insertions(+), 130 deletions(-) create mode 100644 frontend/src/lib/builtinCommands.test.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index be7db75cb..4f4601c8f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -304,7 +304,7 @@ scheduleService.setWorktreeRemovedHandler(async (directory) => { const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) -const chatService = new ChatService(openCodeClient) +const chatService = new ChatService(openCodeClient, terminalService) const notificationService = new NotificationService(db, opencodeServerRegistry, chatService) const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) const goalOutcomes = new GoalOutcomeTracker() diff --git a/backend/src/services/chats.ts b/backend/src/services/chats.ts index 46c9ed924..ab8b0becd 100644 --- a/backend/src/services/chats.ts +++ b/backend/src/services/chats.ts @@ -13,8 +13,10 @@ import { type ListChatsResponse, } from '@opencode-manager/shared/schemas' import type { OpenCodeClient } from './opencode/client' +import type { TerminalService } from './terminal' import { ensureDirectoryExists } from './file-operations' import { canonicalPathSync } from '../utils/fs-safe' +import { logger } from '../utils/logger' const CHAT_ID_ATTEMPTS = 5 @@ -49,6 +51,7 @@ function formatChatTimestamp(now: Date): string { export class ChatService { constructor( private readonly openCodeClient: OpenCodeClient, + private readonly terminals: Pick, private readonly root: () => string = getChatsPath, private readonly now: () => Date = () => new Date(), ) {} @@ -77,12 +80,24 @@ export class ChatService { } async findByDirectory(directory: string): Promise { - const target = canonicalPathSync(path.resolve(directory)) - for (const id of await this.listChatIds()) { - const chat = this.toChat(id) - if (canonicalPathSync(chat.directory) === target) return chat + try { + const relative = path.relative( + canonicalPathSync(this.root()), + canonicalPathSync(path.resolve(directory)), + ) + if ( + !relative || + path.isAbsolute(relative) || + relative.includes(path.sep) || + relative.startsWith('..') + ) { + return null + } + if (!ChatIdSchema.safeParse(relative).success) return null + return await this.resolve(relative) + } catch { + return null } - return null } async create(input: CreateChatRequest): Promise<{ chat: Chat; sessionId: string }> { @@ -116,17 +131,28 @@ export class ChatService { } await this.openCodeClient.api.session.remove({ sessionID: sessionId }) - const remaining = await this.openCodeClient.api.session.list({ - directory: chat.directory, - limit: 1, - }) + try { + const remaining = await this.openCodeClient.api.session.list({ + directory: chat.directory, + limit: 1, + }) + + if (remaining.data.length > 0) { + return { deleted: true, folderRemoved: false } + } + + try { + await this.terminals.removeAll(chat.directory) + } catch (error) { + logger.warn(`Failed to remove terminals for chat ${chatId}:`, error) + } - const folderRemoved = remaining.data.length === 0 - if (folderRemoved) { await fs.rm(chat.directory, { recursive: true, force: true }) + return { deleted: true, folderRemoved: true } + } catch (error) { + logger.warn(`Failed to remove folder for chat ${chatId}:`, error) + return { deleted: true, folderRemoved: false } } - - return { deleted: true, folderRemoved } } private async listChatIds(): Promise { diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index b3e12a229..f9fd25a0f 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -130,9 +130,7 @@ export function buildNotificationUrl( chat?: { id: string } | null ): string { if (chat) { - return sessionId - ? buildChatSessionPath(chat.id, sessionId) - : `/chats/${encodeURIComponent(chat.id)}`; + return sessionId ? buildChatSessionPath(chat.id, sessionId) : "/"; } if (!repo) return "/"; if (!sessionId) return `/repos/${repo.id}`; diff --git a/backend/test/routes/chats.test.ts b/backend/test/routes/chats.test.ts index 14dd3f200..9b8f4518e 100644 --- a/backend/test/routes/chats.test.ts +++ b/backend/test/routes/chats.test.ts @@ -28,7 +28,7 @@ const roots: string[] = [] function createApp(root: string) { const fake = createFakeClient() const app = new Hono() - app.route('/chats', createChatRoutes(new ChatService(fake.client, () => root))) + app.route('/chats', createChatRoutes(new ChatService(fake.client, { removeAll: vi.fn(async () => undefined) }, () => root))) return { app, ...fake } } diff --git a/backend/test/services/chats.test.ts b/backend/test/services/chats.test.ts index fdba7a9cf..91ec98e77 100644 --- a/backend/test/services/chats.test.ts +++ b/backend/test/services/chats.test.ts @@ -17,6 +17,10 @@ interface FakeClient { } } +interface FakeTerminals { + terminals: { removeAll: ReturnType } +} + function createFakeClient(): FakeClient { const session = { create: vi.fn(async () => ({ id: 'ses_new' })), @@ -30,6 +34,10 @@ function createFakeClient(): FakeClient { return { client: { api: { session } } as unknown as OpenCodeClient, session } } +function createFakeTerminals(): FakeTerminals { + return { terminals: { removeAll: vi.fn(async () => undefined) } } +} + function createChatFolder(root: string, id: string): string { const directory = path.join(root, id) mkdirSync(directory, { recursive: true }) @@ -54,7 +62,13 @@ describe('ChatService', () => { it('creates a chat folder matching the id format and leaves it empty', async () => { const root = createRoot() const fake = createFakeClient() - const service = new ChatService(fake.client, () => root, () => new Date('2026-01-02T03:04:05Z')) + const { terminals } = createFakeTerminals() + const service = new ChatService( + fake.client, + terminals, + () => root, + () => new Date('2026-01-02T03:04:05Z'), + ) const result = await service.create({ title: 'Hello' }) @@ -74,7 +88,7 @@ describe('ChatService', () => { const root = createRoot() const fake = createFakeClient() fake.session.create.mockRejectedValueOnce(new Error('session failed')) - const service = new ChatService(fake.client, () => root) + const service = new ChatService(fake.client, createFakeTerminals().terminals, () => root) await expect(service.create({})).rejects.toThrow('session failed') expect(readdirSync(root)).toEqual([]) @@ -86,7 +100,11 @@ describe('ChatService', () => { for (const id of ids) createChatFolder(root, id) createChatFolder(root, 'not-a-chat') mkdirSync(path.join(root, '.hidden')) - const service = new ChatService(createFakeClient().client, () => root) + const service = new ChatService( + createFakeClient().client, + createFakeTerminals().terminals, + () => root, + ) const first = await service.list({ limit: 2 }) expect(first.chats.map((chat) => chat.id)).toEqual([ids[2], ids[1]]) @@ -105,7 +123,11 @@ describe('ChatService', () => { const root = createRoot() const id = '20260203-040506-abcdef' createChatFolder(root, id) - const service = new ChatService(createFakeClient().client, () => root) + const service = new ChatService( + createFakeClient().client, + createFakeTerminals().terminals, + () => root, + ) expect((await service.resolve(id))?.id).toBe(id) expect(await service.resolve('20260203-040506-aaaaaa')).toBeNull() @@ -117,50 +139,116 @@ describe('ChatService', () => { const root = createRoot() const id = '20260203-040506-abcdef' const directory = createChatFolder(root, id) - const service = new ChatService(createFakeClient().client, () => root) + const service = new ChatService( + createFakeClient().client, + createFakeTerminals().terminals, + () => root, + ) expect((await service.findByDirectory(directory))?.id).toBe(id) expect(await service.findByDirectory(path.join(root, 'missing'))).toBeNull() }) + it('returns null for paths that are not an existing chat folder directly under the root', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + createChatFolder(root, 'not-a-chat') + const service = new ChatService( + createFakeClient().client, + createFakeTerminals().terminals, + () => root, + ) + + expect(await service.findByDirectory(path.join(directory, 'nested'))).toBeNull() + expect((await service.findByDirectory(directory + path.sep))?.id).toBe(id) + expect(await service.findByDirectory(root)).toBeNull() + expect(await service.findByDirectory(path.join(root, 'not-a-chat'))).toBeNull() + expect(await service.findByDirectory(path.join(root, '..', 'elsewhere'))).toBeNull() + expect(await service.findByDirectory('/unrelated/path')).toBeNull() + }) + it('keeps the folder when another session remains after a delete', async () => { const root = createRoot() const id = '20260203-040506-abcdef' const directory = createChatFolder(root, id) const fake = createFakeClient() + const { terminals } = createFakeTerminals() fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) fake.session.list.mockResolvedValueOnce({ data: [{ id: 'ses_fork' }], cursor: {} }) - const service = new ChatService(fake.client, () => root) + const service = new ChatService(fake.client, terminals, () => root) const result = await service.deleteSession(id, 'ses_1') expect(result).toEqual({ deleted: true, folderRemoved: false }) expect(fake.session.remove).toHaveBeenCalledWith({ sessionID: 'ses_1' }) expect(fake.session.list).toHaveBeenCalledWith({ directory, limit: 1 }) + expect(terminals.removeAll).not.toHaveBeenCalled() expect(existsSync(directory)).toBe(true) }) - it('removes the folder when no session remains after a delete', async () => { + it('removes terminals before the folder when no session remains after a delete', async () => { const root = createRoot() const id = '20260203-040506-abcdef' const directory = createChatFolder(root, id) const fake = createFakeClient() + const { terminals } = createFakeTerminals() + let folderExistedDuringRemoval = false + terminals.removeAll.mockImplementationOnce(async (target: string) => { + folderExistedDuringRemoval = target === directory && existsSync(directory) + }) fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) - const service = new ChatService(fake.client, () => root) + const service = new ChatService(fake.client, terminals, () => root) const result = await service.deleteSession(id, 'ses_1') expect(result).toEqual({ deleted: true, folderRemoved: true }) + expect(terminals.removeAll).toHaveBeenCalledWith(directory) + expect(folderExistedDuringRemoval).toBe(true) expect(existsSync(directory)).toBe(false) }) + it('removes the folder even when terminal cleanup fails', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + const fake = createFakeClient() + const { terminals } = createFakeTerminals() + terminals.removeAll.mockRejectedValueOnce(new Error('terminal cleanup failed')) + fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) + const service = new ChatService(fake.client, terminals, () => root) + + const result = await service.deleteSession(id, 'ses_1') + + expect(result).toEqual({ deleted: true, folderRemoved: true }) + expect(existsSync(directory)).toBe(false) + }) + + it('reports folderRemoved false without throwing when the remaining-session list fails', async () => { + const root = createRoot() + const id = '20260203-040506-abcdef' + const directory = createChatFolder(root, id) + const fake = createFakeClient() + const { terminals } = createFakeTerminals() + fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory } }) + fake.session.list.mockRejectedValueOnce(new Error('list failed')) + const service = new ChatService(fake.client, terminals, () => root) + + const result = await service.deleteSession(id, 'ses_1') + + expect(result).toEqual({ deleted: true, folderRemoved: false }) + expect(fake.session.remove).toHaveBeenCalledWith({ sessionID: 'ses_1' }) + expect(terminals.removeAll).not.toHaveBeenCalled() + expect(existsSync(directory)).toBe(true) + }) + it('rejects a session that belongs to another folder', async () => { const root = createRoot() const id = '20260203-040506-abcdef' createChatFolder(root, id) const fake = createFakeClient() fake.session.get.mockResolvedValueOnce({ id: 'ses_1', location: { directory: '/tmp/other-chat' } }) - const service = new ChatService(fake.client, () => root) + const service = new ChatService(fake.client, createFakeTerminals().terminals, () => root) await expect(service.deleteSession(id, 'ses_1')).rejects.toBeInstanceOf(ChatSessionMismatchError) expect(fake.session.remove).not.toHaveBeenCalled() @@ -168,7 +256,7 @@ describe('ChatService', () => { it('rejects an unknown chat id before touching sessions', async () => { const fake = createFakeClient() - const service = new ChatService(fake.client, () => createRoot()) + const service = new ChatService(fake.client, createFakeTerminals().terminals, () => createRoot()) await expect(service.deleteSession('20260203-040506-abcdef', 'ses_1')).rejects.toBeInstanceOf(ChatNotFoundError) await expect(service.deleteSession('../escape', 'ses_1')).rejects.toBeInstanceOf(ChatNotFoundError) diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index a4ea5b980..a10ae4c62 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -243,4 +243,7 @@ describe('buildNotificationUrl', () => { it('returns the chat session page for a chat folder', () => { expect(buildNotificationUrl(null, 'ses_1', { id: CHAT_ID })).toBe(`/chats/${CHAT_ID}/sessions/ses_1`) }) + it('returns "/" for a chat folder without a session', () => { + expect(buildNotificationUrl(null, undefined, { id: CHAT_ID })).toBe('/') + }) }) diff --git a/backend/test/utils/repo-url.test.ts b/backend/test/utils/repo-url.test.ts index efe8b9eba..e520b9680 100644 --- a/backend/test/utils/repo-url.test.ts +++ b/backend/test/utils/repo-url.test.ts @@ -5,6 +5,7 @@ import { normalizeSSHUrl, extractHostFromSSHUrl, getRepoNameFromUrl, + getChatIdFromDirectory, normalizeRepoUrlForCompare, } from '@opencode-manager/shared/utils' @@ -186,3 +187,29 @@ describe('normalizeRepoUrlForCompare', () => { expect(normalizeRepoUrlForCompare('file:///Users/me/repo')).toBe('file:///users/me/repo') }) }) + +describe('getChatIdFromDirectory', () => { + const chatId = '20260101-120000-abcdef' + + it('reads a chat id from a workspace chat root', () => { + expect(getChatIdFromDirectory(`/workspace/chats/${chatId}`)).toBe(chatId) + }) + + it('ignores trailing slashes and backslashes', () => { + expect(getChatIdFromDirectory(`/workspace/chats/${chatId}/`)).toBe(chatId) + expect(getChatIdFromDirectory(`C:\\workspace\\chats\\${chatId}\\`)).toBe(chatId) + }) + + it('returns null for a directory nested inside a chat', () => { + expect(getChatIdFromDirectory(`/workspace/chats/${chatId}/subagent`)).toBeNull() + }) + + it('returns null when the parent directory is not the chats directory', () => { + expect(getChatIdFromDirectory(`/workspace/repos/${chatId}`)).toBeNull() + }) + + it('returns null for a malformed chat id', () => { + expect(getChatIdFromDirectory('/workspace/chats/20260101-120000-xyzxyz')).toBeNull() + expect(getChatIdFromDirectory('/workspace/chats/not-a-chat')).toBeNull() + }) +}) diff --git a/frontend/src/App.permission-dialog.test.tsx b/frontend/src/App.permission-dialog.test.tsx index 86a7edfa9..7ed8d0ece 100644 --- a/frontend/src/App.permission-dialog.test.tsx +++ b/frontend/src/App.permission-dialog.test.tsx @@ -5,7 +5,7 @@ import type { PermissionRequest } from '@opencode-manager/shared/opencode' const mocks = vi.hoisted(() => ({ usePermissions: vi.fn(), - getRepoIdForSession: vi.fn(), + getLocalSessionPath: vi.fn(), sessionQueryOptions: vi.fn(), navigateToCurrent: vi.fn(), PermissionRequestDialog: vi.fn(), @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/contexts/EventContext', () => ({ usePermissions: mocks.usePermissions, - useEventContext: () => ({ getRepoIdForSession: mocks.getRepoIdForSession }), + useEventContext: () => ({ getLocalSessionPath: mocks.getLocalSessionPath }), })) vi.mock('@/hooks/useOpenCode', () => ({ sessionQueryOptions: mocks.sessionQueryOptions })) @@ -89,19 +89,19 @@ describe('PermissionDialogWrapper', () => { mocks.PermissionRequestDialog.mockReturnValue(
) }) - it('offers the session for a linked-server permission without a Manager repo lookup', () => { - mocks.getRepoIdForSession.mockReturnValue(null) + it('offers the session for a linked-server permission without a local path lookup', () => { + mocks.getLocalSessionPath.mockReturnValue(null) const props = renderWrapper(remotePermission) expect(props.permission).toEqual(remotePermission) expect(props.onOpenSession).toBe(mocks.navigateToCurrent) - expect(mocks.getRepoIdForSession).not.toHaveBeenCalled() + expect(mocks.getLocalSessionPath).not.toHaveBeenCalled() expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_remote', '/srv/app', 'srv_kitchen') }) it('resolves the remote session title from the permission server', async () => { - mocks.getRepoIdForSession.mockReturnValue(null) + mocks.getLocalSessionPath.mockReturnValue(null) renderWrapper(remotePermission) @@ -111,18 +111,28 @@ describe('PermissionDialogWrapper', () => { }) }) - it('offers the session for a local permission resolved through its Manager repo', () => { - mocks.getRepoIdForSession.mockReturnValue(12) + it('offers the session for a local permission resolved through its repo', () => { + mocks.getLocalSessionPath.mockReturnValue('/repos/12/sessions/ses_local') const props = renderWrapper(localPermission) expect(props.onOpenSession).toBe(mocks.navigateToCurrent) - expect(mocks.getRepoIdForSession).toHaveBeenCalledWith('ses_local') + expect(mocks.getLocalSessionPath).toHaveBeenCalledWith('ses_local') expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_local', '/srv/app', 'local') }) - it('hides the session action for a local permission without a Manager repo', () => { - mocks.getRepoIdForSession.mockReturnValue(null) + it('offers the session for a local permission resolved through its chat', () => { + const chatId = '20260101-120000-abcdef' + mocks.getLocalSessionPath.mockReturnValue(`/chats/${chatId}/sessions/ses_local`) + + const props = renderWrapper(localPermission) + + expect(props.onOpenSession).toBe(mocks.navigateToCurrent) + expect(mocks.getLocalSessionPath).toHaveBeenCalledWith('ses_local') + }) + + it('hides the session action for a local permission without a repo or chat', () => { + mocks.getLocalSessionPath.mockReturnValue(null) const props = renderWrapper(localPermission) diff --git a/frontend/src/App.push-subscription-sync.test.tsx b/frontend/src/App.push-subscription-sync.test.tsx index 69314526c..4bd4eba6c 100644 --- a/frontend/src/App.push-subscription-sync.test.tsx +++ b/frontend/src/App.push-subscription-sync.test.tsx @@ -46,7 +46,7 @@ vi.mock('@/contexts/EventContext', () => ({ currentIsFromDifferentSession: false, }), useEventContext: () => ({ - getRepoIdForSession: () => null, + getLocalSessionPath: () => null, sshHostKey: { request: null, respond: vi.fn() }, }), })) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79557d9d3..31d1d9d54 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -122,7 +122,7 @@ export function PermissionDialogWrapper() { currentDirectory, currentIsFromDifferentSession, } = usePermissions() - const { getRepoIdForSession } = useEventContext() + const { getLocalSessionPath } = useEventContext() const isRemotePermission = currentPermission !== null && !isLocalOpenCodeServer(currentPermission.serverId) const { data: session } = useQuery( sessionQueryOptions( @@ -133,7 +133,7 @@ export function PermissionDialogWrapper() { ) const canOpenCurrentSession = currentPermission !== null - && (isRemotePermission || getRepoIdForSession(currentPermission.sessionID) !== null) + && (isRemotePermission || getLocalSessionPath(currentPermission.sessionID) !== null) && (currentIsFromDifferentSession || viewedSessionId === null) return ( diff --git a/frontend/src/components/navigation/ChatsNavGroup.test.tsx b/frontend/src/components/navigation/ChatsNavGroup.test.tsx index 3b032e22b..4b5e89f19 100644 --- a/frontend/src/components/navigation/ChatsNavGroup.test.tsx +++ b/frontend/src/components/navigation/ChatsNavGroup.test.tsx @@ -217,4 +217,13 @@ describe('ChatsNavGroup', () => { expect(await screen.findByText('No chats')).toBeTruthy() }) + + it('keeps Show more available when the loaded chats have no visible sessions', async () => { + mocks.sessionsData.splice(0, mocks.sessionsData.length) + + render(, { wrapper: createWrapper(['/']) }) + + expect(await screen.findByText('No chats')).toBeTruthy() + expect(screen.getByText('Show more')).toBeTruthy() + }) }) diff --git a/frontend/src/components/navigation/ChatsNavGroup.tsx b/frontend/src/components/navigation/ChatsNavGroup.tsx index cbb70d3fb..2d67603d4 100644 --- a/frontend/src/components/navigation/ChatsNavGroup.tsx +++ b/frontend/src/components/navigation/ChatsNavGroup.tsx @@ -61,21 +61,19 @@ export function ChatsNavGroup() { ) : rows.length === 0 ? ( No chats ) : ( - <> - {rows.map((row) => { - const item = toChatItem(row) - return ( - - ) - })} - {hasMore && Show more} - + rows.map((row) => { + const item = toChatItem(row) + return ( + + ) + }) )} + {hasMore && Show more} ) } diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 2cd90aca0..223b1ba98 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildMoreItems, buildNavModel, buildToolItems } from './moreDrawerItems' +import { buildMoreItems, buildNavModel, buildToolItems, isSessionDetailPath } from './moreDrawerItems' describe('buildMoreItems', () => { it('returns Home + All Schedules + Files + Settings + Logout for root path', () => { @@ -232,6 +232,14 @@ describe('buildToolItems', () => { }) }) +describe('isSessionDetailPath', () => { + it('recognizes repo and chat session routes but not linked-server routes', () => { + expect(isSessionDetailPath('/repos/42/sessions/abc')).toBe(true) + expect(isSessionDetailPath('/chats/20240102120000-aaaaaa/sessions/ses_1')).toBe(true) + expect(isSessionDetailPath('/servers/srv_kitchen/sessions/abc')).toBe(false) + }) +}) + describe('buildNavModel', () => { it.each([ '/', diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 14e9479fa..b2d6faf55 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -1,7 +1,7 @@ import type { LucideIcon } from 'lucide-react' import { BookOpen, Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, List, Play, Globe, KeyRound } from 'lucide-react' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' -import { getAssistantPath, isAssistantPath } from '@/lib/navigation' +import { getAssistantPath, isAssistantPath, parseChatSessionRoute } from '@/lib/navigation' export interface MoreDrawerItem { key: string @@ -50,10 +50,9 @@ function getBaseItems(): MoreDrawerItem[] { const SESSION_DETAIL_PATH = /^\/repos\/(\d+)\/sessions\/[^/]+$/ const SERVER_SESSION_DETAIL_PATH = /^\/servers\/[^/]+\/sessions\/[^/]+$/ const SERVER_PROJECT_PATH = /^\/servers\/[^/]+\/projects$/ -const CHAT_SESSION_DETAIL_PATH = /^\/chats\/[^/]+\/sessions\/[^/]+$/ export function isSessionDetailPath(pathname: string): boolean { - return SESSION_DETAIL_PATH.test(pathname) + return SESSION_DETAIL_PATH.test(pathname) || parseChatSessionRoute(pathname) !== null } const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) @@ -179,7 +178,7 @@ function buildRouteNavModel(pathname: string): NavModel { } } - if (CHAT_SESSION_DETAIL_PATH.test(pathname)) { + if (parseChatSessionRoute(pathname) !== null) { return { primary: [getAssistantNavItem()], items: [ diff --git a/frontend/src/components/session/ChatPickerContent.tsx b/frontend/src/components/session/ChatPickerContent.tsx index 82c1eca3e..a817019ef 100644 --- a/frontend/src/components/session/ChatPickerContent.tsx +++ b/frontend/src/components/session/ChatPickerContent.tsx @@ -182,21 +182,21 @@ export function ChatPickerContent({ onTogglePin={handleTogglePin} onRequestDelete={handleRequestDelete} registerRef={registerRef} - > - {hasMore && ( -
- -
- )} - + /> + + {hasMore && ( +
+ +
+ )}
↑↓ move · Enter open · Ctrl+D delete diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 9b03fd9ec..a921a053a 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -1136,6 +1136,19 @@ describe('SessionPickerDialog', () => { expect(mocks.showMoreChats).toHaveBeenCalledTimes(1) }) + it('shows the show more action even when no chat sessions are visible', async () => { + mocks.chatRows = [] + mocks.chatHasMore = true + renderPicker() + + openChatsTab() + + expect(await screen.findByText('No chats yet')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + + expect(mocks.showMoreChats).toHaveBeenCalledTimes(1) + }) + it('creates a chat from the header action and navigates to its new session', async () => { const onNavigate = vi.fn() renderPicker({ onNavigate }) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 0616f5391..61a9cb13e 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -231,12 +231,12 @@ function createWrapper(queryClient = createTestQueryClient(), initialPath = '/') } function CacheResolutionHarness({ sessionID }: { sessionID: string }) { - const { getRepoIdForSession } = useEventContext() - const repoId = getRepoIdForSession(sessionID) + const { getLocalSessionPath } = useEventContext() + const sessionPath = getLocalSessionPath(sessionID) return (
-
{repoId ?? 'none'}
+
{sessionPath ?? 'none'}
) } @@ -1440,7 +1440,7 @@ describe('EventProvider permissions and forms', () => { render(, { wrapper: createWrapper(queryClient) }) await waitFor(() => { - expect(screen.getByTestId('repo-id')).toHaveTextContent('123') + expect(screen.getByTestId('session-path')).toHaveTextContent('/repos/123/sessions/ses-infinite') }) }) @@ -1463,7 +1463,30 @@ describe('EventProvider permissions and forms', () => { render(, { wrapper: createWrapper(queryClient) }) await waitFor(() => { - expect(screen.getByTestId('repo-id')).toHaveTextContent('123') + expect(screen.getByTestId('session-path')).toHaveTextContent('/repos/123/sessions/ses-detail') + }) + }) + + it('resolves a chat session to its chat route without a repo', async () => { + const chatId = '20260101-120000-abcdef' + const directory = `/workspace/chats/${chatId}` + const queryClient = createTestQueryClient() + + queryClient.setQueryData( + ['opencode', 'session', 'ses-chat', directory], + { + id: 'ses-chat', + projectID: 'proj-1', + title: 'Chat Session', + location: { directory }, + time: { created: 1000, updated: 1000 }, + }, + ) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => { + expect(screen.getByTestId('session-path')).toHaveTextContent(`/chats/${chatId}/sessions/ses-chat`) }) }) @@ -1499,7 +1522,7 @@ describe('EventProvider permissions and forms', () => { render(, { wrapper: createWrapper(queryClient) }) await waitFor(() => { - expect(screen.getByTestId('repo-id')).toHaveTextContent('456') + expect(screen.getByTestId('session-path')).toHaveTextContent('/repos/456/sessions/ses-second') }) }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index e3edb7446..d4daccd78 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -14,6 +14,7 @@ import { } from '@/api/opencode' import { listRepos } from '@/api/repos' import { FetchError } from '@opencode-manager/shared' +import { getChatIdFromDirectory } from '@opencode-manager/shared/utils' import { buildServerDirectoryKey, LOCAL_OPENCODE_SERVER_ID, @@ -30,7 +31,7 @@ import type { Repo } from '@/api/types' import { showToast } from '@/lib/toast' import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode-event-stream' import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' -import { getServerSessionPath, getSessionPath, parseChatSessionRoute, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation' +import { getChatSessionPath, getServerSessionPath, getSessionPath, parseChatSessionRoute, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation' import { serverScopedKey } from '@/lib/serverScope' import { repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' @@ -279,7 +280,8 @@ interface EventContextValue { forgetPendingActionsForSession: (sessionID: string) => void } sseHealth: SSEHealthState - getRepoIdForSession: (sessionID: string) => number | null + /** The in-app path of a local session: its repo or chat route, or null when neither resolves. */ + getLocalSessionPath: (sessionID: string) => string | null } /** @@ -527,15 +529,17 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return key ? parseServerDirectoryKey(key).directory : null }, [findSessionDirectoryKey]) - const getRepoIdForSession = useCallback((sessionID: string): number | null => { - if (!repos) return null + const getLocalSessionPath = useCallback((sessionID: string): string | null => { if (useSessionStatus.getState().sessionServers.has(sessionID)) return null const key = findSessionDirectoryKey(sessionID) if (!key) return null const { serverId, directory } = parseServerDirectoryKey(key) if (!isLocalOpenCodeServer(serverId)) return null + const chatId = getChatIdFromDirectory(directory) + if (chatId) return getChatSessionPath(chatId, sessionID) + if (!repos) return null const repo = repos.find(r => repoMatchesDirectory(r, directory)) - return repo?.id ?? null + return repo ? getSessionPath(repo.id, sessionID) : null }, [repos, findSessionDirectoryKey]) const currentPermissionDirectory = useMemo( @@ -692,18 +696,14 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }, [formsBySession]) const navigateToSession = useCallback((sessionID: string, serverId: string) => { - let targetPath: string - if (isLocalOpenCodeServer(serverId)) { - const repoId = getRepoIdForSession(sessionID) - if (repoId === null) return - targetPath = getSessionPath(repoId, sessionID) - } else { - targetPath = getServerSessionPath(serverId, sessionID) - } + const targetPath = isLocalOpenCodeServer(serverId) + ? getLocalSessionPath(sessionID) + : getServerSessionPath(serverId, sessionID) + if (!targetPath) return if (`${window.location.pathname}${window.location.search}` !== targetPath) { navigate(targetPath) } - }, [getRepoIdForSession, navigate]) + }, [getLocalSessionPath, navigate]) const navigateToCurrentForm = useCallback(() => { if (!currentForm) return @@ -1378,7 +1378,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { forgetPendingActionsForSession, }, sseHealth, - getRepoIdForSession, + getLocalSessionPath, }), [ sshHostKeyRequest, respondToSSHHostKey, @@ -1407,7 +1407,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { syncFormsForSession, forgetPendingActionsForSession, sseHealth, - getRepoIdForSession, + getLocalSessionPath, ]) return {children} diff --git a/frontend/src/hooks/useChats.test.tsx b/frontend/src/hooks/useChats.test.tsx index 366e4ac85..19b32c56a 100644 --- a/frontend/src/hooks/useChats.test.tsx +++ b/frontend/src/hooks/useChats.test.tsx @@ -183,7 +183,16 @@ describe('useChatSessions', () => { expect(pinnedRow.path).toBe(`/chats/${olderChat.id}/sessions/ses_old_pinned`) expect(result.current.rows[1].isPinned).toBe(false) expect(result.current.rows[1].path).toBe(`/chats/${newestChat.id}/sessions/ses_new_newer`) - expect(result.current.chats.map((entry) => entry.id)).toEqual([newestChat.id, olderChat.id]) + + const folders = renderHook(() => useChatFolders(2), { + wrapper: createWrapper(queryClient), + }) + await waitFor(() => + expect(folders.result.current.chats.map((entry) => entry.id)).toEqual([ + newestChat.id, + olderChat.id, + ]), + ) expect(result.current.hasMore).toBe(false) }) }) diff --git a/frontend/src/hooks/useChats.ts b/frontend/src/hooks/useChats.ts index 48e98d405..4fb22598e 100644 --- a/frontend/src/hooks/useChats.ts +++ b/frontend/src/hooks/useChats.ts @@ -107,7 +107,6 @@ export function useChatSessions(pageSize = 10) { return { rows, - chats, hasMore: Boolean(folders.hasNextPage), showMore: folders.fetchNextPage, isLoading: folders.isLoading || sessions.isLoading, diff --git a/frontend/src/hooks/useCommands.test.tsx b/frontend/src/hooks/useCommands.test.tsx index 84b88ef66..d4eea90c2 100644 --- a/frontend/src/hooks/useCommands.test.tsx +++ b/frontend/src/hooks/useCommands.test.tsx @@ -1,6 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi, beforeEach } from 'vitest' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' import { useCommands } from './useCommands' import { listCommands } from '../api/opencode' import { BUILTIN_COMMANDS } from '@/lib/builtinCommands' @@ -11,7 +12,7 @@ vi.mock('../api/opencode', () => ({ listCommands: vi.fn(), })) -const createWrapper = (serverId?: string) => { +const createWrapper = (serverId?: string, initialPath = '/') => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -19,7 +20,9 @@ const createWrapper = (serverId?: string) => { }) return ({ children }: { children: React.ReactNode }) => ( - {serverId ? {children} : children} + + {serverId ? {children} : children} + ) } @@ -139,7 +142,9 @@ describe('useCommands', () => { ]) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 60_000 } } }) const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ) const { result, rerender } = renderHook(({ directory }) => useCommands({ directory }), { @@ -191,4 +196,19 @@ describe('useCommands', () => { expect(names).toContain('skills') expect(names).toContain('walkthrough') }) + + it('omits mcp, skills, and walkthrough on a chat session route', () => { + const { result } = renderHook(() => useCommands({ enabled: false }), { + wrapper: createWrapper(undefined, '/chats/20260101-120000-abcdef/sessions/ses_chat'), + }) + + const names = result.current.searchCommands('').map(({ item }) => item.name) + + expect(names).not.toContain('mcp') + expect(names).not.toContain('skills') + expect(names).not.toContain('walkthrough') + expect(names).toContain('sessions') + expect(names).toContain('models') + expect(names).toContain('compact') + }) }) diff --git a/frontend/src/hooks/useCommands.ts b/frontend/src/hooks/useCommands.ts index 1d0ff5204..0987a8f04 100644 --- a/frontend/src/hooks/useCommands.ts +++ b/frontend/src/hooks/useCommands.ts @@ -1,7 +1,9 @@ import { useCallback, useMemo } from 'react' +import { useLocation } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { listCommands } from '@/api/opencode' import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { parseChatSessionRoute } from '@/lib/navigation' import { serverScopedKey } from '@/lib/serverScope' import type { CommandInfo } from '@opencode-manager/shared/opencode' import { getBuiltinCommands } from '@/lib/builtinCommands' @@ -20,11 +22,13 @@ interface UseCommandsOptions { export function useCommands(options: UseCommandsOptions = {}) { const { directory, enabled = true } = options const serverId = useOpenCodeServerId() + const location = useLocation() + const isChat = parseChatSessionRoute(location.pathname) !== null const recentNames = useRecentCommandsStore((state) => state.names) const builtinCommands = useMemo( - () => getBuiltinCommands(isLocalOpenCodeServer(serverId)), - [serverId], + () => getBuiltinCommands(isLocalOpenCodeServer(serverId), isChat), + [serverId, isChat], ) const sortedBuiltinCommands = useMemo(() => sortCommandsByName([...builtinCommands]), [builtinCommands]) diff --git a/frontend/src/hooks/useGlobalShortcutActions.test.tsx b/frontend/src/hooks/useGlobalShortcutActions.test.tsx index be60cc30f..c18387ecb 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.test.tsx +++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx @@ -10,12 +10,13 @@ const mocks = vi.hoisted(() => ({ getSession: vi.fn(), createSession: vi.fn(), useMobile: vi.fn(), + preferences: undefined as + | { leaderKey?: string; keyboardShortcuts?: Record; directShortcuts?: string[] } + | undefined, })) vi.mock('@/hooks/useSettings', () => ({ - useSettings: () => ({ - preferences: { leaderKey: 'Ctrl+O', keyboardShortcuts: {}, directShortcuts: undefined }, - }), + useSettings: () => ({ preferences: mocks.preferences }), })) vi.mock('@/hooks/useMobile', () => ({ @@ -62,6 +63,11 @@ describe('useGlobalShortcutActions', () => { localStorage.clear() queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) mocks.useMobile.mockReturnValue(false) + mocks.preferences = { + leaderKey: 'Ctrl+O', + keyboardShortcuts: {}, + directShortcuts: undefined, + } mocks.getRepo.mockResolvedValue({ id: 5, fullPath: '/repo/five' }) mocks.getSession.mockResolvedValue({ id: 'ses_1', location: { directory: '/srv/kitchen' } }) mocks.createSession.mockResolvedValue({ id: 'session-new' }) @@ -144,6 +150,23 @@ describe('useGlobalShortcutActions', () => { expect(mocks.getRepo).not.toHaveBeenCalled() }) + it('does not fall back to a Manager host for a tool the chat session route lacks', async () => { + mocks.preferences = { + leaderKey: 'Ctrl+O', + keyboardShortcuts: { toggleSourceControl: 'G' }, + directShortcuts: [], + } + const { location } = renderHarness('/chats/20240102120000-aaaaaa/sessions/ses_1') + + pressLeaderFollowUp('g') + + await act(async () => { + await Promise.resolve() + }) + + expect(location()).toBe('/chats/20240102120000-aaaaaa/sessions/ses_1') + }) + it('toggles the navigation sidebar instead of the Files panel from the sidebar shortcut', async () => { const { location } = renderHarness('/repos/5') diff --git a/frontend/src/hooks/useGlobalShortcutActions.ts b/frontend/src/hooks/useGlobalShortcutActions.ts index 0c9b8ec42..32da7904b 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.ts +++ b/frontend/src/hooks/useGlobalShortcutActions.ts @@ -23,6 +23,7 @@ import { getServerSessionPath, getSessionPath, isAssistantPath, + parseChatSessionRoute, parseRepoRoute, parseServerSessionRoute, } from '@/lib/navigation' @@ -39,6 +40,7 @@ export function useGlobalShortcutActions(): void { const isMobile = useMobile() const serverId = useRouteOpenCodeServerId() const isRemoteRoute = !isLocalOpenCodeServer(serverId) + const isChatSessionRoute = parseChatSessionRoute(pathname) !== null const repoId = isAssistantPath(pathname) ? ASSISTANT_REPO_ID : parseRepoRoute(pathname).repoId @@ -75,10 +77,10 @@ export function useGlobalShortcutActions(): void { togglePanelTool(tool) return } - if (isRemoteRoute || tool === 'files') return + if (isRemoteRoute || isChatSessionRoute || tool === 'files') return const host = repoId !== null ? getRepoPath(repoId) : getAssistantPath() navigate(`${host}${dialogSearch(tool)}`) - }, [toolItems, isMobile, togglePanelTool, updateParams, navigate, repoId, isRemoteRoute]) + }, [toolItems, isMobile, togglePanelTool, updateParams, navigate, repoId, isRemoteRoute, isChatSessionRoute]) const newSession = sessionDirectory && !createSession.isPending ? () => createSession.mutate({}) diff --git a/frontend/src/hooks/useSessionPickerNavigation.test.tsx b/frontend/src/hooks/useSessionPickerNavigation.test.tsx index 29889932e..bd9a3d79e 100644 --- a/frontend/src/hooks/useSessionPickerNavigation.test.tsx +++ b/frontend/src/hooks/useSessionPickerNavigation.test.tsx @@ -221,6 +221,20 @@ describe('useSessionPickerNavigation', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('returns to the home route when the open chat session is deleted', () => { + const { result, onClose, location } = renderPickerNavigation( + ['/chats/20240102120000-aaaaaa/sessions/a?mobileTab=sessions'], + { mode: 'push' }, + ) + + act(() => { + result.current.onDeleteActiveSession() + }) + + expect(location()).toBe('/ PUSH') + expect(onClose).toHaveBeenCalledTimes(1) + }) + it('closes the picker without navigating when the session is already open', () => { const { result, onClose, location } = renderPickerNavigation( ['/repos/1/sessions/a?mobileTab=sessions'], diff --git a/frontend/src/hooks/useSessionPickerNavigation.ts b/frontend/src/hooks/useSessionPickerNavigation.ts index 70fc12399..d755c0a92 100644 --- a/frontend/src/hooks/useSessionPickerNavigation.ts +++ b/frontend/src/hooks/useSessionPickerNavigation.ts @@ -8,6 +8,7 @@ import { getSessionListPath, getSessionPath, parseActivePickerSession, + parseChatSessionRoute, parseRepoRoute, withQueryParam, } from '@/lib/navigation' @@ -50,6 +51,7 @@ export function useSessionPickerNavigation({ const navigate = useNavigate() const { pathname, search } = useLocation() const currentRepoId = parseRepoRoute(pathname).repoId + const isChatSessionRoute = parseChatSessionRoute(pathname) !== null const { sessionId: activeSessionID, serverId: activeServerId } = parseActivePickerSession(pathname) const searchParams = new URLSearchParams(search) const isAssistantSession = searchParams.get('assistant') === '1' @@ -99,6 +101,10 @@ export function useSessionPickerNavigation({ open(deleteActiveSessionPath) return } + if (isChatSessionRoute) { + open('/') + return + } if (currentRepoId === null) { if (!isPanelMode) onClose() return @@ -108,6 +114,7 @@ export function useSessionPickerNavigation({ currentRepoId, deleteActiveSessionPath, isAssistantSession, + isChatSessionRoute, isPanelMode, onClose, open, diff --git a/frontend/src/lib/activeWork.test.ts b/frontend/src/lib/activeWork.test.ts index c3ca14d4f..6d189f728 100644 --- a/frontend/src/lib/activeWork.test.ts +++ b/frontend/src/lib/activeWork.test.ts @@ -465,6 +465,10 @@ describe('hasUnmatchedActiveDirectory', () => { it('is false for an empty index', () => { expect(hasUnmatchedActiveDirectory(new Map(), [repoA])).toBe(false) }) + + it('is false when the only indexed directory is a chat root', () => { + expect(hasUnmatchedActiveDirectory(indexFor('/workspace/chats/20260101-120000-abcdef'), [repoA])).toBe(false) + }) }) describe('buildActiveWork', () => { @@ -515,6 +519,20 @@ describe('buildActiveWork', () => { expect(groups.recent).toEqual([]) }) + it('emits a chat root with its chat path and no repo or server', () => { + const chatId = '20260101-120000-abcdef' + const sessions = [createSession('chat-root', NOW - 1000, `/workspace/chats/${chatId}`)] + + const groups = buildActiveWork({ now: NOW, windowMs: WINDOW_MS, repos: [repoA], sessions, parents: new Map(), roots: emptyRoots, outcomes: new Map() }) + + expect(groups.recent.map((item) => item.session.id)).toEqual(['chat-root']) + expect(groups.recent[0].repo).toBeNull() + expect(groups.recent[0].serverId).toBeNull() + expect(groups.recent[0].serverName).toBeNull() + expect(groups.recent[0].worktreeBranch).toBeNull() + expect(groups.recent[0].path).toBe(`/chats/${chatId}/sessions/chat-root`) + }) + it('resolves a repo by local path and trims trailing slashes', () => { const repo = createRepo({ id: 5, fullPath: '/repos/full', localPath: '/local/path' }) const sessions = [createSession('local', NOW - 1000, '/local/path/')] @@ -779,6 +797,17 @@ describe('needsWorktreeLookup', () => { ).toBe(false) }) + it('returns false for a chat session updated inside the window', () => { + expect( + needsWorktreeLookup({ + ...base, + sessions: [createSession('chat-root', NOW - 1000, '/workspace/chats/20260101-120000-abcdef')], + parents: new Map(), + roots: emptyRoots, + }), + ).toBe(false) + }) + it('returns false for an archived unmatched session', () => { const archived = createSession('archived', NOW - 1000, '/repos/other') archived.time.archived = NOW - 500 diff --git a/frontend/src/lib/activeWork.ts b/frontend/src/lib/activeWork.ts index 92b7be70f..9b31fb451 100644 --- a/frontend/src/lib/activeWork.ts +++ b/frontend/src/lib/activeWork.ts @@ -3,10 +3,10 @@ import { LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-man import type { ChildOutcome } from '@/lib/backgroundWork' import type { SessionStatusType } from '@/stores/sessionStatusStore' import { getSessionKey } from '@/lib/sessionKey' -import { getServerSessionPath, getSessionPath } from '@/lib/navigation' +import { getChatSessionPath, getServerSessionPath, getSessionPath } from '@/lib/navigation' import { getRepoBranchLabel } from '@/lib/utils' import { findRepoForDirectory, normalizeDirectory } from '@/components/navigation/sidebar-session-tree' -import { isWorktreeSibling, type RepoSibling } from '@opencode-manager/shared/utils' +import { getChatIdFromDirectory, isWorktreeSibling, type RepoSibling } from '@opencode-manager/shared/utils' /** * The plain directory of a session-status directory value when it belongs to the local server, @@ -43,11 +43,11 @@ export type ActiveWorkState = 'needs-input' | 'running' | 'recent' export interface ActiveWorkItem { key: string session: Session - /** The local repo the session runs in; null for a session owned by a linked server. */ + /** The local repo the session runs in; null for a linked-server or chat session. */ repo: Repo | null - /** The linked server that owns the session; null for a local repo session. */ + /** The linked server that owns the session; null for a local repo or chat session. */ serverId: string | null - /** The owning linked server's display name; null for a local repo session. */ + /** The owning linked server's display name; null for a local repo or chat session. */ serverName: string | null state: ActiveWorkState updatedAt: number @@ -335,13 +335,15 @@ export function repoActivityFromIndex( /** * True when some indexed active directory matches no repo, so a session is active in a directory - * outside the repo list (such as a worktree) and the worktree lookup is worth running. + * outside the repo list (such as a worktree) and the worktree lookup is worth running. Chat + * directories are ignored because they never resolve to a repo worktree. */ export function hasUnmatchedActiveDirectory( index: ReadonlyMap, repos: Repo[], ): boolean { for (const directory of index.keys()) { + if (getChatIdFromDirectory(directory)) continue if (!findRepoForDirectory(repos, directory)) return true } return false @@ -410,7 +412,8 @@ export function resolveSessionBranch( /** * Whether the worktree lookup is worth running: true when some non-archived session that matches no - * repo is either updated inside the window or has its root active (needs-input or running). + * repo and is not a chat session is either updated inside the window or has its root active + * (needs-input or running). */ export function needsWorktreeLookup(input: { now: number @@ -427,6 +430,7 @@ export function needsWorktreeLookup(input: { return sessions.some((session) => { if (session.time.archived) return false if (sessionServers?.has(session.id)) return false + if (getChatIdFromDirectory(session.location.directory)) return false if (findRepoForDirectory(repos, session.location.directory)) return false if (session.time.updated >= windowStart) return true const rootID = rootSessionID(session.id, parents) @@ -437,11 +441,12 @@ export function needsWorktreeLookup(input: { /** * Groups the given sessions into needs-input, running, and recent roots. A session appears in * exactly one group (needs-input over running over recent) and only when it resolves to a local - * repo or to an owning linked server. A session owned by a linked server keeps its own row in any - * state, opening on that server instead of a local repo. A recent session in a schedule worktree is - * omitted so schedules only surface while they need attention. The input may mix roots and children; - * children never become items, but their latest update time rolls up into the root's `updatedAt` and - * decides the recent window and ordering. + * repo, a workspace chat, or an owning linked server. A session owned by a linked server keeps its + * own row in any state, opening on that server instead of a local repo. A chat session keeps its own + * row too, opening on its chat route. A recent session in a schedule worktree is omitted so + * schedules only surface while they need attention. The input may mix roots and children; children + * never become items, but their latest update time rolls up into the root's `updatedAt` and decides + * the recent window and ordering. */ export function buildActiveWork(input: { now: number @@ -517,7 +522,28 @@ export function buildActiveWork(input: { const directory = normalizeDirectory(session.location.directory) const worktree = directory ? worktreeByDirectory.get(directory) : undefined const repo = worktree?.ownerRepo ?? findRepoForDirectory(repos, session.location.directory) - if (!repo) continue + + if (!repo) { + const chatId = getChatIdFromDirectory(session.location.directory) + if (!chatId) continue + seen.add(session.id) + const item: ActiveWorkItem = { + key: getSessionKey(session), + session, + repo: null, + serverId: null, + serverName: null, + state, + updatedAt, + outcome: outcomes.get(session.id), + worktreeBranch: null, + path: getChatSessionPath(chatId, session.id), + } + if (state === 'needs-input') needsInput.push(item) + else if (state === 'running') running.push(item) + else recent.push(item) + continue + } if (worktree?.isSchedule && state === 'recent') continue seen.add(session.id) diff --git a/frontend/src/lib/builtinCommands.test.ts b/frontend/src/lib/builtinCommands.test.ts new file mode 100644 index 000000000..6b614d740 --- /dev/null +++ b/frontend/src/lib/builtinCommands.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { getBuiltinCommands } from './builtinCommands' + +function commandNames(isLocalServer: boolean, isChat = false): string[] { + return getBuiltinCommands(isLocalServer, isChat).map((command) => command.name) +} + +describe('getBuiltinCommands', () => { + it('offers every built-in command for the local server outside chats', () => { + const names = commandNames(true) + + expect(names).toContain('mcp') + expect(names).toContain('skills') + expect(names).toContain('walkthrough') + expect(names).toContain('undo') + expect(names).toContain('sessions') + }) + + it('omits mcp, skills, and walkthrough for chat sessions and keeps the rest', () => { + const names = commandNames(true, true) + + expect(names).not.toContain('mcp') + expect(names).not.toContain('skills') + expect(names).not.toContain('walkthrough') + expect(names).toContain('sessions') + expect(names).toContain('models') + expect(names).toContain('compact') + expect(names).toContain('undo') + expect(names).toContain('settings') + }) + + it('keeps mcp and skills but omits walkthrough for a linked server outside chats', () => { + const names = commandNames(false) + + expect(names).toContain('mcp') + expect(names).toContain('skills') + expect(names).not.toContain('walkthrough') + }) + + it('omits mcp, skills, and walkthrough for a linked server in a chat session', () => { + const names = commandNames(false, true) + + expect(names).not.toContain('mcp') + expect(names).not.toContain('skills') + expect(names).not.toContain('walkthrough') + expect(names).toContain('models') + }) +}) diff --git a/frontend/src/lib/builtinCommands.ts b/frontend/src/lib/builtinCommands.ts index 4c25db67d..7d7c7bb43 100644 --- a/frontend/src/lib/builtinCommands.ts +++ b/frontend/src/lib/builtinCommands.ts @@ -72,12 +72,26 @@ const MANAGER_ONLY_COMMAND_ACTIONS: ReadonlySet = new Set( 'showWalkthrough', ]) +const CHAT_UNAVAILABLE_COMMAND_ACTIONS: ReadonlySet = new Set([ + 'showMcp', + 'showSkills', + 'showWalkthrough', +]) + /** - * Built-in composer commands for a server scope. Commands backed by Manager-only features are omitted for - * linked OpenCode servers; MCP, skills, and the session picker stay because their views work against the - * linked server. + * Built-in composer commands for a server and page scope. Commands backed by Manager-only features are + * omitted for linked OpenCode servers; MCP, skills, and the session picker stay because their views work + * against the linked server. Chat sessions omit the MCP, skills, and walkthrough commands because their + * dialogs are not mounted there. + * + * @param isLocalServer Whether the composer targets the local Manager server. + * @param isChat Whether the composer is mounted on a chat session page. + * @returns The commands offered for that scope. */ -export function getBuiltinCommands(isLocalServer: boolean): readonly BuiltinCommand[] { - if (isLocalServer) return BUILTIN_COMMANDS - return BUILTIN_COMMANDS.filter((command) => !MANAGER_ONLY_COMMAND_ACTIONS.has(command.action)) +export function getBuiltinCommands(isLocalServer: boolean, isChat = false): readonly BuiltinCommand[] { + const available = isLocalServer + ? BUILTIN_COMMANDS + : BUILTIN_COMMANDS.filter((command) => !MANAGER_ONLY_COMMAND_ACTIONS.has(command.action)) + if (!isChat) return available + return available.filter((command) => !CHAT_UNAVAILABLE_COMMAND_ACTIONS.has(command.action)) } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 04b865c76..16e0e6bdc 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -16,6 +16,7 @@ import type { TerminalTarget } from '@/lib/terminal-target' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; import { NotFoundState } from "@/components/ui/not-found-state"; +import { parseNetworkError } from '@/lib/opencode-errors'; import { useSession, useInterruptSession, useUpdateSession, useCreateSession } from "@/hooks/useOpenCode"; import { useSessionTranscript } from "@/hooks/useSessionTranscript"; import { useAutoOpenWalkthrough } from "@/hooks/useAutoOpenWalkthrough"; @@ -199,7 +200,7 @@ export function SessionDetail() { useRepoActivity(repoId, Boolean(repo) && !isRemoteServer && !isChatSession); - const { data: chat, isLoading: chatLoading } = useChat(chatId); + const { data: chat, isLoading: chatLoading, isError: chatFailed, error: chatError } = useChat(chatId); const { mutateAsync: deleteChatSessionAsync } = useDeleteChatSession(); const repoDirectory = repo?.fullPath; @@ -783,6 +784,16 @@ export function SessionDetail() { ); } + if (isChatSession && chatFailed && !chat) { + return ( + + ); + } + if (isChatSession && !chat) { return ; } diff --git a/frontend/src/pages/__tests__/SessionDetail.chat.test.tsx b/frontend/src/pages/__tests__/SessionDetail.chat.test.tsx index d9b8c8cae..5aa70fdd2 100644 --- a/frontend/src/pages/__tests__/SessionDetail.chat.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.chat.test.tsx @@ -266,4 +266,19 @@ describe('SessionDetail chat session', () => { expect(await screen.findByText('Chat not found')).toBeInTheDocument() expect(screen.queryByTestId('message-thread')).not.toBeInTheDocument() }) + + it('shows the load error instead of not-found when the chat request fails', async () => { + mocks.useChat.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Chat request failed'), + }) + + renderChatSession() + + expect(await screen.findByText('Chat request failed')).toBeInTheDocument() + expect(screen.queryByText('Chat not found')).not.toBeInTheDocument() + expect(screen.queryByTestId('message-thread')).not.toBeInTheDocument() + }) }) diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index 23d6a9c22..56387903b 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -1,3 +1,5 @@ +import { DEFAULTS } from '../config/defaults' +import { CHAT_ID_PATTERN } from '../schemas/chats' import type { Repo } from '../types' export const ASSISTANT_REPO_ID = 0 @@ -26,6 +28,27 @@ export function buildChatSessionPath(chatId: string, sessionId: string): string return `/chats/${encodeURIComponent(chatId)}/sessions/${encodeURIComponent(sessionId)}` } +/** + * Reads the chat id a workspace chat directory names. The directory must end in + * `//`; a path nested deeper inside a chat, a different parent + * directory, or a malformed id yields null. Works without Node's `path` module. + * + * @param directory Session directory to read. + * @returns The chat id, or null when the directory is not a chat root. + */ +export function getChatIdFromDirectory(directory: string): string | null { + const normalized = trimTrailingChar(trimTrailingChar(directory.trim(), '/'), '\\') + if (!normalized) return null + + const segments = normalized.split(/[\\/]/) + const chatId = segments[segments.length - 1] + const parent = segments[segments.length - 2] + if (!chatId || !parent) return null + if (parent !== DEFAULTS.WORKSPACE.CHATS_DIR) return null + + return CHAT_ID_PATTERN.test(chatId) ? chatId : null +} + export function buildServerSessionPath(serverId: string, sessionId: string): string { return `/servers/${encodeURIComponent(serverId)}/sessions/${encodeURIComponent(sessionId)}` }