From 485ab940aa4bab3af5da7e0dc0cd477fae8be6f3 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:56 -0400 Subject: [PATCH 1/4] feat(ui): dock repo and session tools in a desktop side panel Add a docked desktop tool panel with an icon rail, driven by the shared panel URL param, and keep the mobile dialogs and drawers. Extract dialog bodies into reusable content components, rename the walkthrough dialog to a sheet, and make walkthrough generation asynchronous with client polling. --- backend/src/routes/change-walkthroughs.ts | 4 +- backend/src/services/change-walkthroughs.ts | 80 ++- .../test/routes/change-walkthroughs.test.ts | 103 +++- .../test/services/change-walkthroughs.test.ts | 75 +++ frontend/src/api/changeWalkthroughs.ts | 26 +- frontend/src/api/fetchWrapper.test.ts | 32 ++ frontend/src/api/fetchWrapper.ts | 2 +- .../file-browser/FileBrowser.compact.test.tsx | 126 +++++ .../components/file-browser/FileBrowser.tsx | 26 +- .../navigation/DesktopSidebar.test.tsx | 139 +---- .../components/navigation/DesktopSidebar.tsx | 71 +-- .../src/components/navigation/MoreDrawer.tsx | 4 +- .../navigation/ToolSidePanel.test.tsx | 228 ++++++++ .../components/navigation/ToolSidePanel.tsx | 175 ++++++ .../navigation/moreDrawerItems.test.ts | 58 +- .../components/navigation/moreDrawerItems.ts | 25 +- .../components/preview/PreviewPanel.test.tsx | 28 +- .../src/components/preview/PreviewPanel.tsx | 154 +++--- .../src/components/repo/MultiRunSheet.tsx | 4 +- .../repo/RepoActionsDialog.test.tsx | 23 +- .../src/components/repo/RepoActionsDialog.tsx | 514 +++++++++--------- .../src/components/repo/RepoMcpDialog.tsx | 98 ++-- .../src/components/repo/RepoSkillsDialog.tsx | 104 ++-- .../schedules/RepoSchedulesContent.tsx | 388 +++++++++++++ .../session/ChangesWalkthroughDialog.tsx | 243 --------- ...t.tsx => ChangesWalkthroughSheet.test.tsx} | 67 ++- .../session/ChangesWalkthroughSheet.tsx | 250 +++++++++ .../source-control/SourceControlPanel.tsx | 60 +- .../src/components/source-control/index.ts | 2 +- .../src/components/terminal/TerminalPanel.tsx | 212 ++++---- .../src/components/ui/side-drawer.test.tsx | 17 + frontend/src/components/ui/side-drawer.tsx | 33 +- frontend/src/components/ui/sidebar.test.tsx | 54 +- frontend/src/components/ui/sidebar.tsx | 43 +- .../src/hooks/useChangeWalkthrough.test.tsx | 74 ++- frontend/src/hooks/useChangeWalkthrough.ts | 19 +- frontend/src/hooks/useOpenNavItem.ts | 27 + .../src/hooks/useSidebarCollapsed.test.tsx | 118 +--- frontend/src/hooks/useSidebarCollapsed.ts | 41 -- frontend/src/hooks/useToolPanel.ts | 87 +++ frontend/src/pages/AssistantRedirect.tsx | 34 +- frontend/src/pages/RepoDetail.tsx | 35 +- frontend/src/pages/Repos.tsx | 12 +- frontend/src/pages/Schedules.tsx | 358 +----------- frontend/src/pages/SessionDetail.tsx | 57 +- .../AssistantRedirect.preview.test.tsx | 16 +- .../RepoDetail.worktree-setup.test.tsx | 10 +- .../__tests__/SessionDetail.commands.test.tsx | 6 +- shared/src/schemas/change-walkthroughs.ts | 10 + 49 files changed, 2625 insertions(+), 1747 deletions(-) create mode 100644 frontend/src/api/fetchWrapper.test.ts create mode 100644 frontend/src/components/file-browser/FileBrowser.compact.test.tsx create mode 100644 frontend/src/components/navigation/ToolSidePanel.test.tsx create mode 100644 frontend/src/components/navigation/ToolSidePanel.tsx create mode 100644 frontend/src/components/schedules/RepoSchedulesContent.tsx delete mode 100644 frontend/src/components/session/ChangesWalkthroughDialog.tsx rename frontend/src/components/session/{ChangesWalkthroughDialog.test.tsx => ChangesWalkthroughSheet.test.tsx} (79%) create mode 100644 frontend/src/components/session/ChangesWalkthroughSheet.tsx create mode 100644 frontend/src/hooks/useOpenNavItem.ts create mode 100644 frontend/src/hooks/useToolPanel.ts diff --git a/backend/src/routes/change-walkthroughs.ts b/backend/src/routes/change-walkthroughs.ts index 03b547db5..aabbee21e 100644 --- a/backend/src/routes/change-walkthroughs.ts +++ b/backend/src/routes/change-walkthroughs.ts @@ -23,8 +23,8 @@ export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) } try { - const { walkthrough, created } = await service.generate(c.req.param('sessionId'), parsed.data) - return c.json({ walkthrough }, created ? 201 : 200) + const state = await service.startGeneration(c.req.param('sessionId'), parsed.data) + return c.json(state, state.generating ? 202 : 200) } catch (error) { return handleServiceError(c, error, 'Failed to generate change walkthrough', ServiceError) } diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts index 9c9d70e42..e1ee6046e 100644 --- a/backend/src/services/change-walkthroughs.ts +++ b/backend/src/services/change-walkthroughs.ts @@ -14,6 +14,7 @@ import { type ChangeWalkthrough, type ChangeWalkthroughState, type GenerateChangeWalkthroughRequest, + type WalkthroughGenerationError, type WalkthroughHunk, type WalkthroughOmittedFile, type WalkthroughStop, @@ -212,8 +213,26 @@ export function parseWalkthroughResponse(text: string, hunks: WalkthroughHunk[]) } } +interface InFlightGeneration { + modelStarted: Promise + result: Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> +} + +function toGenerationError(error: unknown): WalkthroughGenerationError { + if (error instanceof ServiceError) { + return { + message: error.message, + ...(error.code ? { code: error.code } : {}), + ...(error.details !== undefined ? { details: error.details } : {}), + } + } + + return { message: getErrorMessage(error) || 'Failed to generate the change walkthrough' } +} + export class ChangeWalkthroughService { - private readonly inFlight = new Map>() + private readonly inFlight = new Map() + private readonly failures = new Map() private readonly deletedDuringGeneration = new Set() private readonly timeoutMs: number @@ -241,21 +260,19 @@ export class ChangeWalkthroughService { walkthrough, currentDiffHash, stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash, + generating: this.inFlight.has(sessionId), + error: this.failures.get(sessionId) ?? null, } } generate(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { - const existing = this.inFlight.get(sessionId) - if (existing) { - return existing - } + return this.begin(sessionId, request).result + } - const pending = this.runGenerate(sessionId, request).finally(() => { - this.inFlight.delete(sessionId) - this.deletedDuringGeneration.delete(sessionId) - }) - this.inFlight.set(sessionId, pending) - return pending + async startGeneration(sessionId: string, request: GenerateChangeWalkthroughRequest): Promise { + const entry = this.begin(sessionId, request) + await Promise.race([entry.modelStarted, entry.result]) + return this.getState(sessionId) } /** Removes the stored walkthrough of a deleted session, including one still being generated. */ @@ -268,12 +285,51 @@ export class ChangeWalkthroughService { if (this.inFlight.has(sessionID)) { this.deletedDuringGeneration.add(sessionID) } + this.failures.delete(sessionID) deleteChangeWalkthrough(this.db, sessionID) } + private begin(sessionId: string, request: GenerateChangeWalkthroughRequest): InFlightGeneration { + const existing = this.inFlight.get(sessionId) + if (existing) { + return existing + } + + this.failures.delete(sessionId) + + let resolveModelStarted: () => void = () => {} + const modelStarted = new Promise((resolve) => { + resolveModelStarted = resolve + }) + let modelCalled = false + const markModelStarted = () => { + modelCalled = true + resolveModelStarted() + } + + const result = this.runGenerate(sessionId, request, markModelStarted) + .catch((error) => { + if (modelCalled) { + this.failures.set(sessionId, toGenerationError(error)) + } + throw error + }) + .finally(() => { + this.inFlight.delete(sessionId) + this.deletedDuringGeneration.delete(sessionId) + }) + + result.catch(() => {}) + + const entry: InFlightGeneration = { modelStarted, result } + this.inFlight.set(sessionId, entry) + return entry + } + private async runGenerate( sessionId: string, request: GenerateChangeWalkthroughRequest, + onModelStart: () => void, ): Promise<{ walkthrough: ChangeWalkthrough; created: boolean }> { const session = await this.readSession(sessionId) @@ -314,6 +370,8 @@ export class ChangeWalkthroughService { const prompt = buildWalkthroughPrompt({ title, hunks }) + onModelStart() + let responseText: string try { responseText = await generateTextWithTimeout(this.openCodeClient, { prompt }, this.timeoutMs) diff --git a/backend/test/routes/change-walkthroughs.test.ts b/backend/test/routes/change-walkthroughs.test.ts index 44a5e07fd..24bff31a4 100644 --- a/backend/test/routes/change-walkthroughs.test.ts +++ b/backend/test/routes/change-walkthroughs.test.ts @@ -28,7 +28,7 @@ interface FakeSession { function createFakeClient(sessions: Record) { const generateCalls: string[] = [] - let reply = MODEL_REPLY + let generateImpl: () => Promise = async () => MODEL_REPLY const client = { api: { @@ -62,7 +62,7 @@ function createFakeClient(sessions: Record) { generate: { text: vi.fn(async (input: { prompt: string }) => { generateCalls.push(input.prompt) - return { text: reply } + return { text: await generateImpl() } }), }, }, @@ -72,8 +72,8 @@ function createFakeClient(sessions: Record) { return { client, generateCalls, - setReply: (next: string) => { - reply = next + setGenerateImpl: (impl: () => Promise) => { + generateImpl = impl }, } } @@ -102,10 +102,18 @@ describe('change walkthrough routes', () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`) expect(res.status).toBe(200) - const body = (await res.json()) as { walkthrough: unknown; currentDiffHash: string | null; stale: boolean } + const body = (await res.json()) as { + walkthrough: unknown + currentDiffHash: string | null + stale: boolean + generating: boolean + error: unknown + } expect(body.walkthrough).toBeNull() expect(body.stale).toBe(false) expect(body.currentDiffHash).toEqual(expect.any(String)) + expect(body.generating).toBe(false) + expect(body.error).toBeNull() }) it('GET returns 404 for a missing session', async () => { @@ -115,30 +123,59 @@ describe('change walkthrough routes', () => { await expect(res.json()).resolves.toMatchObject({ error: 'Session not found' }) }) - it('POST creates a walkthrough and GET reads it back', async () => { + it('POST returns 202 while generating, then GET reads the walkthrough back', async () => { + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) + const postRes = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) - expect(postRes.status).toBe(201) - const created = (await postRes.json()) as { walkthrough: { summary: string; sessionId: string } } - expect(created.walkthrough.summary).toBe('A summary') + expect(postRes.status).toBe(202) + const body = (await postRes.json()) as { generating: boolean; walkthrough: unknown } + expect(body.generating).toBe(true) + expect(body.walkthrough).toBeNull() + + resolveGenerate(MODEL_REPLY) - const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) - const state = (await getRes.json()) as { walkthrough: { summary: string } } - expect(state.walkthrough.summary).toBe('A summary') + await vi.waitFor(async () => { + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { walkthrough: { summary: string } | null; generating: boolean } + expect(state.generating).toBe(false) + expect(state.walkthrough?.summary).toBe('A summary') + }) }) it('POST accepts an empty body', async () => { + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) - expect(res.status).toBe(201) + expect(res.status).toBe(202) + + resolveGenerate(MODEL_REPLY) + await vi.waitFor(async () => { + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { walkthrough: unknown } + expect(state.walkthrough).not.toBeNull() + }) }) - it('POST returns 200 without a second model call when changes are unchanged', async () => { + it('POST returns 200 with the stored walkthrough without a second model call', async () => { await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + await vi.waitFor(async () => { + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { walkthrough: unknown } + expect(state.walkthrough).not.toBeNull() + }) + const second = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -146,19 +183,35 @@ describe('change walkthrough routes', () => { }) expect(second.status).toBe(200) + const body = (await second.json()) as { walkthrough: { summary: string }; generating: boolean } + expect(body.walkthrough.summary).toBe('A summary') + expect(body.generating).toBe(false) expect(fake.generateCalls).toHaveLength(1) }) it('POST regenerates when asked', async () => { await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + await vi.waitFor(async () => { + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { walkthrough: unknown } + expect(state.walkthrough).not.toBeNull() + }) + + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) + const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ regenerate: true }), }) - expect(res.status).toBe(201) - expect(fake.generateCalls).toHaveLength(2) + expect(res.status).toBe(202) + + resolveGenerate(MODEL_REPLY) + await vi.waitFor(() => expect(fake.generateCalls).toHaveLength(2)) }) it('POST rejects an invalid body with 400', async () => { @@ -192,13 +245,23 @@ describe('change walkthrough routes', () => { await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_NO_TEXT_CHANGES' }) }) - it('POST returns 502 with a code when the model response is unparseable', async () => { - fake.setReply('not json') + it('POST records an unparseable model reply that GET surfaces', async () => { + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) const res = await app.request(`/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + expect(res.status).toBe(202) + + resolveGenerate('not json') - expect(res.status).toBe(502) - await expect(res.json()).resolves.toMatchObject({ code: 'WALKTHROUGH_UNPARSEABLE' }) + await vi.waitFor(async () => { + const getRes = await app.request(`/change-walkthroughs/${SESSION_ID}`) + const state = (await getRes.json()) as { generating: boolean; error: { code?: string } | null } + expect(state.generating).toBe(false) + expect(state.error?.code).toBe('WALKTHROUGH_UNPARSEABLE') + }) }) it('POST returns 404 for a missing session', async () => { diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index 5f394da76..e9ef40450 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -383,6 +383,8 @@ describe('ChangeWalkthroughService', () => { expect(state.walkthrough).toBeNull() expect(state.stale).toBe(false) expect(state.currentDiffHash).toBe(computeChangesHash(threeHunks)) + expect(state.generating).toBe(false) + expect(state.error).toBeNull() }) it('reports stale when the changes differ from the stored hash', async () => { @@ -526,6 +528,79 @@ describe('ChangeWalkthroughService', () => { await expect(service.generate('ses_missing', {})).rejects.toMatchObject({ status: 404 }) }) + describe('startGeneration', () => { + const coveringReply = modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }]) + + it('reports generating while the model call is pending, then the stored walkthrough', async () => { + let resolveGenerate: (text: string) => void = () => {} + fake.setGenerateImpl(() => new Promise((resolve) => { + resolveGenerate = resolve + })) + + const state = await service.startGeneration(SESSION_ID, {}) + expect(state.generating).toBe(true) + expect(state.walkthrough).toBeNull() + expect(state.error).toBeNull() + + resolveGenerate(coveringReply) + + await vi.waitFor(async () => { + const settled = await service.getState(SESSION_ID) + expect(settled.generating).toBe(false) + expect(settled.walkthrough).not.toBeNull() + }) + }) + + it('rejects with 409 when the session has no changes', async () => { + sessions[SESSION_ID]!.changes = [] + + await expect(service.startGeneration(SESSION_ID, {})).rejects.toMatchObject({ + status: 409, + code: 'WALKTHROUGH_NO_CHANGES', + }) + sessions[SESSION_ID]!.changes = threeHunks + expect((await service.getState(SESSION_ID)).error).toBeNull() + }) + + it('records an unparseable model failure on the state', async () => { + fake.setGenerateImpl(async () => 'not json') + + await service.startGeneration(SESSION_ID, {}) + + await vi.waitFor(async () => { + const settled = await service.getState(SESSION_ID) + expect(settled.generating).toBe(false) + expect(settled.error).toMatchObject({ code: 'WALKTHROUGH_UNPARSEABLE' }) + }) + }) + + it('clears a previous error when a new generation starts', async () => { + fake.setGenerateImpl(async () => 'not json') + await service.startGeneration(SESSION_ID, {}) + await vi.waitFor(async () => { + expect((await service.getState(SESSION_ID)).error).not.toBeNull() + }) + + fake.setGenerateImpl(() => new Promise(() => {})) + const state = await service.startGeneration(SESSION_ID, { regenerate: true }) + + expect(state.generating).toBe(true) + expect(state.error).toBeNull() + }) + + it('clears the recorded error when the session is deleted', async () => { + fake.setGenerateImpl(async () => 'not json') + await service.startGeneration(SESSION_ID, {}) + await vi.waitFor(async () => { + expect((await service.getState(SESSION_ID)).error).not.toBeNull() + }) + + service.handleEvent(sessionEvent('session.deleted', SESSION_ID)) + + expect((await service.getState(SESSION_ID)).error).toBeNull() + }) + }) + describe('handleEvent', () => { const coveringReply = modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }]) diff --git a/frontend/src/api/changeWalkthroughs.ts b/frontend/src/api/changeWalkthroughs.ts index d49e57dc6..352c318cf 100644 --- a/frontend/src/api/changeWalkthroughs.ts +++ b/frontend/src/api/changeWalkthroughs.ts @@ -1,29 +1,25 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' import type { - ChangeWalkthrough, ChangeWalkthroughState, GenerateChangeWalkthroughRequest, } from '@opencode-manager/shared/schemas' +function walkthroughUrl(sessionId: string): string { + return `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}` +} + export async function getChangeWalkthrough(sessionId: string): Promise { - return fetchWrapper( - `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}`, - ) + return fetchWrapper(walkthroughUrl(sessionId)) } export async function generateChangeWalkthrough( sessionId: string, request: GenerateChangeWalkthroughRequest, -): Promise { - const res = await fetchWrapper<{ walkthrough: ChangeWalkthrough }>( - `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - timeout: 150_000, - }, - ) - return res.walkthrough +): Promise { + return fetchWrapper(walkthroughUrl(sessionId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) } diff --git a/frontend/src/api/fetchWrapper.test.ts b/frontend/src/api/fetchWrapper.test.ts new file mode 100644 index 000000000..ef2b7fc53 --- /dev/null +++ b/frontend/src/api/fetchWrapper.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FetchError, fetchWrapper } from './fetchWrapper' + +function respondWith(body: string, status: number) { + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status }))) +} + +describe('fetchWrapper error bodies', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the JSON error message', async () => { + respondWith(JSON.stringify({ error: 'Session not found', code: 'NOT_FOUND' }), 404) + + await expect(fetchWrapper('/api/x')).rejects.toMatchObject({ message: 'Session not found', statusCode: 404, code: 'NOT_FOUND' }) + }) + + it('keeps a plain-text error body', async () => { + respondWith('upstream unavailable', 502) + + await expect(fetchWrapper('/api/x')).rejects.toMatchObject({ message: 'upstream unavailable' }) + }) + + it('replaces an HTML error page with a status message', async () => { + respondWith('A timeout occurred', 524) + + const error = await fetchWrapper('/api/x').catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(FetchError) + expect((error as FetchError).message).toBe('Request failed with status 524') + }) +}) diff --git a/frontend/src/api/fetchWrapper.ts b/frontend/src/api/fetchWrapper.ts index 9e81515d5..233786442 100644 --- a/frontend/src/api/fetchWrapper.ts +++ b/frontend/src/api/fetchWrapper.ts @@ -36,7 +36,7 @@ async function handleResponse(response: Response): Promise { try { return JSON.parse(text) as ApiErrorResponse } catch { - return { error: text } + return { error: /^\s* ( + {children} + ) +} + +const fileInfo: FileInfo = { + name: 'notes.txt', + path: 'repo/notes.txt', + isDirectory: false, + size: 12, + mimeType: 'text/plain', + content: btoa('hello world'), + lastModified: new Date('2026-01-01T00:00:00Z'), +} + +const directoryInfo: FileInfo = { + name: 'repo', + path: 'repo', + isDirectory: true, + size: 0, + lastModified: new Date('2026-01-01T00:00:00Z'), + children: [ + { + name: 'notes.txt', + path: 'repo/notes.txt', + isDirectory: false, + size: 12, + mimeType: 'text/plain', + lastModified: new Date('2026-01-01T00:00:00Z'), + }, + ], +} + +function jsonResponse(payload: unknown) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function stubFileFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input), 'http://localhost').searchParams.get('path') + if (path === 'repo/notes.txt') { + return jsonResponse(fileInfo) + } + return jsonResponse(directoryInfo) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +describe('FileBrowser compact', () => { + it('renders a single full-width column without the desktop preview pane', async () => { + const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(false) + stubFileFetch() + + try { + render(, { wrapper: createWrapper() }) + + expect(await screen.findByText('notes.txt')).toBeInTheDocument() + expect(screen.queryByText('Select a file to preview')).not.toBeInTheDocument() + + const column = screen.getByPlaceholderText('Search').closest('.border-r') + expect(column).toHaveClass('w-full') + expect(column).not.toHaveClass('w-[30%]') + } finally { + mobileSpy.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('shows the inline preview when a file is opened and returns to the list on close', async () => { + const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(false) + stubFileFetch() + + try { + render(, { wrapper: createWrapper() }) + + fireEvent.click(await screen.findByText('notes.txt')) + + await waitFor(() => expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()) + expect(screen.getByText('hello world')).toBeInTheDocument() + expect(screen.getByTitle('Close preview')).toBeInTheDocument() + + fireEvent.click(screen.getByTitle('Close preview')) + + await waitFor(() => expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()) + expect(screen.queryByText('hello world')).not.toBeInTheDocument() + } finally { + mobileSpy.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('keeps the split desktop layout when compact is not set', async () => { + const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(false) + stubFileFetch() + + try { + render(, { wrapper: createWrapper() }) + + expect(await screen.findByText('notes.txt')).toBeInTheDocument() + expect(screen.getByText('Select a file to preview')).toBeInTheDocument() + } finally { + mobileSpy.mockRestore() + vi.unstubAllGlobals() + } + }) +}) diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c851bf620..835aaae89 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -27,6 +27,7 @@ interface FileBrowserProps { onDirectoryLoad?: (info: { workspaceRoot?: string; currentPath: string }) => void onPreviewStateChange?: (isOpen: boolean) => void allowNavigateAboveBase?: boolean + compact?: boolean } interface UploadItem { @@ -131,7 +132,7 @@ function getUploadItemsFromFileList(fileList: FileList): UploadItem[] { return items } -export const FileBrowser = forwardRef(function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad, onPreviewStateChange, allowNavigateAboveBase = false }, ref) { +export const FileBrowser = forwardRef(function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad, onPreviewStateChange, allowNavigateAboveBase = false, compact = false }, ref) { const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) const [selectedFile, setSelectedFile] = useState(null) @@ -146,6 +147,7 @@ export const FileBrowser = forwardRef(funct const uploadCancelledRef = useRef(false) const loadRequestRef = useRef(0) const isMobile = useMobile() + const stacked = isMobile || compact const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile) @@ -257,7 +259,7 @@ useEffect(() => { onFileSelect?.(fullFileData) // On mobile, open preview in modal - if (isMobile) { + if (stacked) { setIsPreviewModalOpen(true) onPreviewStateChange?.(true) } @@ -267,7 +269,7 @@ useEffect(() => { } finally { setLoading(false) } - }, [onFileSelect, isMobile, onPreviewStateChange, loadFiles]) + }, [onFileSelect, stacked, onPreviewStateChange, loadFiles]) const handleFileSelect = useCallback(async (file: FileInfo) => { if (file.isDirectory) { @@ -465,10 +467,10 @@ useEffect(() => { }, [initialFileData, loadFiles]) useEffect(() => { - if (!initialFileData || initialFileData.isDirectory || !isMobile) return + if (!initialFileData || initialFileData.isDirectory || !stacked) return setIsPreviewModalOpen(true) onPreviewStateChange?.(true) - }, [initialFileData, isMobile, onPreviewStateChange]) + }, [initialFileData, stacked, onPreviewStateChange]) useEffect(() => { const handleFileSaved = (event: CustomEvent<{ path: string; content?: string }>) => { @@ -600,7 +602,12 @@ useEffect(() => { {/* Mobile: Full width file listing, Desktop: Split view */}
-
+ {compact && !isMobile && isPreviewModalOpen && selectedFile && !selectedFile.isDirectory ? ( +
+ +
+ ) : ( +
{ )}
+ )} {/* Desktop only: Preview panel */} - {!isMobile && ( + {!stacked && (
{selectedFile && !selectedFile.isDirectory ? ( @@ -711,7 +719,7 @@ useEffect(() => { {/* Mobile: Full width file listing, Desktop: Split view */} -
+
{
{/* Desktop only: Preview panel */} - {!isMobile && ( + {!stacked && (
{selectedFile && !selectedFile.isDirectory ? ( diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index a9aa694ee..535a9aa12 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -47,7 +47,6 @@ function createWrapper(initialEntries?: string[]) { describe('DesktopSidebar', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: vi.fn() }) }) it('returns null when user is not authenticated', () => { @@ -149,6 +148,24 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Assistant')).toBeInTheDocument() }) + it.each(['/', '/repos/5', '/repos/5/sessions/abc', '/assistant'])('leaves route tools to the tool rail on %s', (path) => { + vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) + vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) + vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isLoading: false, + logout: vi.fn(), + } as any) + + render(, { wrapper: createWrapper([path]) }) + + for (const label of ['Home', 'Settings', 'Logout']) { + expect(screen.getByText(label)).toBeInTheDocument() + } + for (const label of ['Menu', 'Repos', 'Files', 'MCP', 'Skills', 'Schedules', 'All Schedules', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) { + expect(screen.queryByText(label)).toBeNull() + } + }) it.each([ ['/schedules', 'New Schedule'], ['/repos/5/schedules', 'New Schedule'], @@ -168,7 +185,7 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Assistant')).toBeInTheDocument() }) - it('opens dialog items by updating the dialog query param (push) and closes on back', () => { + it('navigates home from the footer', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -182,18 +199,13 @@ describe('DesktopSidebar', () => { , - { wrapper: createWrapper(['/repos/5/sessions/abc?assistant=1']) } + { wrapper: createWrapper(['/repos/5?repoTab=workspaces']) } ) - fireEvent.click(screen.getByText('Files')) + fireEvent.click(screen.getByText('Home')) - expect(screen.getByTestId('location').textContent).toBe('/repos/5/sessions/abc?assistant=1&dialog=files') - - fireEvent.click(screen.getByTestId('back-button')) - - expect(screen.getByTestId('location').textContent).toBe('/repos/5/sessions/abc?assistant=1') + expect(screen.getByTestId('location').textContent).toBe('/') }) - it('opens settings by updating settings query params', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) @@ -216,29 +228,7 @@ describe('DesktopSidebar', () => { expect(screen.getByTestId('location').textContent).toBe('/?dialog=files&settings=open&settingsTab=account') }) - it('preserves session route as return target when opening schedules', () => { - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render( - <> - - - , - { wrapper: createWrapper(['/repos/5/sessions/abc?assistant=1']) } - ) - - fireEvent.click(screen.getByText('Schedules')) - - expect(screen.getByTestId('location').textContent).toBe('/repos/5/schedules?returnTo=%2Frepos%2F5%2Fsessions%2Fabc%3Fassistant%3D1') - }) - - it('renders both collapsible sections open by default with the account footer', () => { + it('renders the session tree with home and account items in the footer', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -249,13 +239,13 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/repos/5']) }) - expect(screen.getByTestId('session-tree')).toBeInTheDocument() - expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Sessions' })).toContainElement(screen.getByTestId('session-tree')) + expect(screen.queryByRole('button', { name: 'Sessions' })).toBeNull() + expect(screen.getByText('Home')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() }) - - it('renders the vertical nav list and no session tree when collapsed', () => { + it('renders home, repos and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([true, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -267,80 +257,9 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/repos/5']) }) expect(screen.queryByTestId('session-tree')).toBeNull() - expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.getByText('Repos')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() - }) - - it('hides the session tree when the sessions section is collapsed', () => { - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ - isSectionOpen: (section) => section !== 'sessions', - toggleSection: vi.fn(), - }) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/repos/5']) }) - - expect(screen.queryByTestId('session-tree')).toBeNull() - expect(screen.getByText('Files')).toBeInTheDocument() - }) - - it('hides the menu items when the menu section is collapsed', () => { - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ - isSectionOpen: (section) => section !== 'menu', - toggleSection: vi.fn(), - }) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/repos/5']) }) - - expect(screen.getByTestId('session-tree')).toBeInTheDocument() expect(screen.queryByText('Files')).toBeNull() - expect(screen.getByText('Settings')).toBeInTheDocument() - }) - - it('toggles the sessions section from its header', () => { - const toggle = vi.fn() - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: toggle }) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/repos/5']) }) - - fireEvent.click(screen.getByRole('button', { name: 'Sessions' })) - expect(toggle).toHaveBeenCalledWith('sessions') - }) - - it('toggles the menu section from its header', () => { - const toggle = vi.fn() - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: toggle }) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/repos/5']) }) - - fireEvent.click(screen.getByRole('button', { name: 'Menu' })) - expect(toggle).toHaveBeenCalledWith('menu') }) }) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 431545877..72996d292 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -1,30 +1,28 @@ import { useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useDesktop } from '@/hooks/useDesktop' -import { useSidebarCollapsed, useSidebarSections } from '@/hooks/useSidebarCollapsed' +import { useSidebarCollapsed } from '@/hooks/useSidebarCollapsed' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' +import { useOpenNavItem } from '@/hooks/useOpenNavItem' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' -import { getPathWithReturnTo } from '@/lib/navigation' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' import { Sidebar, - SidebarCollapsibleSection, SidebarSection, SidebarItem, } from '@/components/ui/sidebar' import { FolderGit2 } from 'lucide-react' -import { cn } from '@/lib/utils' -const ACCOUNT_ITEM_KEYS = new Set(['settings', 'logout']) +const FOOTER_ITEM_KEYS = new Set(['home', 'settings', 'logout']) export function DesktopSidebar() { const location = useLocation() const navigate = useNavigate() const { updateParams } = useUrlParams() + const openNavItem = useOpenNavItem() const [collapsed, toggle] = useSidebarCollapsed() - const { isSectionOpen, toggleSection } = useSidebarSections(['sessions', 'menu'] as const) const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() @@ -41,17 +39,8 @@ export function DesktopSidebar() { const { primary, items } = buildNavModel(location.pathname) const handleItemClick = (item: MoreDrawerItem) => { - if (item.to) { - const to = item.key === 'schedules' - ? getPathWithReturnTo(item.to, `${location.pathname}${location.search}`) - : item.to - navigate(to) - } else if (item.dialog) { - updateParams((p) => { - p.set('dialog', item.dialog!) - p.delete('mobileTab') - }, 'push') - } else if (item.key === 'logout') { + if (openNavItem(item)) return + if (item.key === 'logout') { logout() } else if (item.key === 'settings') { updateParams((p) => { @@ -64,18 +53,16 @@ export function DesktopSidebar() { } } - const [homeItem, ...routeItems] = items - const navItems: MoreDrawerItem[] = [ - homeItem, + const footerItems = items.filter((item) => FOOTER_ITEM_KEYS.has(item.key)) + const collapsedItems: MoreDrawerItem[] = [ + footerItems[0], { key: 'repos', label: 'Repos', icon: FolderGit2 }, - ...routeItems, + ...footerItems.slice(1), ] - const toolItems = navItems.filter((item) => !ACCOUNT_ITEM_KEYS.has(item.key)) - const accountItems = navItems.filter((item) => ACCOUNT_ITEM_KEYS.has(item.key)) return ( <> - + {primary.length > 0 && ( {primary.map((item: NavPrimaryCta) => ( @@ -94,7 +81,7 @@ export function DesktopSidebar() { {collapsed ? (
- {navItems.map((item: MoreDrawerItem) => ( + {collapsedItems.map((item: MoreDrawerItem) => ( ) : ( <> - toggleSection('sessions')} - className={cn('border-t border-border', isSectionOpen('sessions') && 'flex-1')} - > +
+

Sessions

- - - toggleSection('menu')} - className={cn('border-t border-border', isSectionOpen('sessions') ? 'shrink-0 max-h-[40%]' : 'flex-1')} - contentClassName="overflow-y-auto" - > -
- {toolItems.map((item: MoreDrawerItem) => ( - handleItemClick(item)} - danger={item.danger} - /> - ))} -
-
+
- {accountItems.map((item: MoreDrawerItem) => ( + {footerItems.map((item: MoreDrawerItem) => ( ({ + FileBrowser: ({ initialSelectedFile }: { initialSelectedFile?: string }) =>
files-tool {initialSelectedFile}
, +})) +vi.mock('@/components/source-control', () => ({ + SourceControlContent: () =>
source-control-tool
, +})) +vi.mock('@/components/terminal/TerminalPanel', () => ({ + TerminalWorkspace: () =>
terminal-tool
, +})) +vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewWorkspace: () =>
preview-tool
})) +vi.mock('@/components/schedules/RepoSchedulesContent', () => ({ + RepoSchedulesContent: ({ repoId }: { repoId: number }) =>
schedules-tool {repoId}
, +})) +vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpContent: () =>
mcp-tool
})) +vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsContent: () =>
actions-tool
})) +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsContent: () =>
skills-tool
})) +vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ + ChangesWalkthroughView: ({ sessionId }: { sessionId: string }) =>
walkthrough-tool {sessionId}
, +})) + +function LocationProbe() { + const location = useLocation() + return ( + <> +
{location.pathname}
+
{location.search}
+ + ) +} + +function Harness({ docked }: { docked: boolean }) { + const panel = useToolPanel(docked) + return ( + <> + {docked ? ( + {}} + /> + ) : null} + + + ) +} + +function renderAt(search: string, docked = true) { + return render( + + + , + ) +} + +function search(): URLSearchParams { + return new URLSearchParams(screen.getByTestId('search').textContent ?? '') +} + +function HomeHarness() { + const panel = useToolPanel(true) + return ( + <> + + + + ) +} + +describe('ToolSidePanel', () => { + it('shows the tool name in a tooltip when hovering a rail icon', async () => { + const user = userEvent.setup() + renderAt('') + + await user.hover(screen.getByRole('button', { name: 'Terminal' })) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Terminal') + }) + + it('shows the home route tools without a repo', async () => { + const user = userEvent.setup() + render( + + + , + ) + + expect(screen.queryByRole('button', { name: 'Terminal' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Files' })) + expect(await screen.findByText(/files-tool/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'All Schedules' })) + await waitFor(() => expect(screen.getByTestId('pathname').textContent).toBe('/schedules')) + }) + + it('moves a panel tool opened as a dialog into the docked panel', async () => { + renderAt('?dialog=walkthrough') + + expect(await screen.findByText('walkthrough-tool ses_1')).toBeInTheDocument() + expect(search().get('panel')).toBe('walkthrough') + expect(search().has('dialog')).toBe(false) + }) + + it('leaves non-panel dialogs alone', async () => { + renderAt('?dialog=resetPermissions&panel=files') + + expect(await screen.findByText('files-tool src/a.ts')).toBeInTheDocument() + expect(search().get('dialog')).toBe('resetPermissions') + }) + + it.each([ + ['Preview', 'preview-tool'], + ['MCP', 'mcp-tool'], + ['Actions', 'actions-tool'], + ['Skills', 'skills-tool'], + ])('opens %s inside the docked panel', async (label, content) => { + const user = userEvent.setup() + renderAt('') + + await user.click(screen.getByRole('button', { name: label })) + + expect(await screen.findByText(content)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: label })).toBeInTheDocument() + expect(search().has('dialog')).toBe(false) + }) + + it('moves a preview opened with a port into the panel and keeps its port', async () => { + renderAt('?panel=terminal&terminal=pty-1&dialog=preview&previewPort=5173&previewPath=%2F') + + expect(await screen.findByText('preview-tool')).toBeInTheDocument() + expect(search().get('panel')).toBe('preview') + expect(search().get('previewPort')).toBe('5173') + expect(search().has('terminal')).toBe(false) + }) + + it('toggles tools from the rail and closes the panel', async () => { + const user = userEvent.setup() + renderAt('') + + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Source Control' })) + expect(await screen.findByText('source-control-tool')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Source Control' })).toHaveAttribute('aria-pressed', 'true') + + await user.click(screen.getByRole('button', { name: 'Terminal' })) + expect(await screen.findByText('terminal-tool')).toBeInTheDocument() + expect(screen.queryByText('source-control-tool')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Terminal' })) + await waitFor(() => expect(screen.queryByText('terminal-tool')).not.toBeInTheDocument()) + expect(search().has('panel')).toBe(false) + + await user.click(screen.getByRole('button', { name: 'Files' })) + await user.click(await screen.findByRole('button', { name: 'Close panel' })) + await waitFor(() => expect(search().has('panel')).toBe(false)) + }) + + it('opens non-panel session items from the rail as dialogs', async () => { + const user = userEvent.setup() + renderAt('?panel=files') + + await user.click(screen.getByRole('button', { name: 'Reset Permissions' })) + + await waitFor(() => expect(search().get('dialog')).toBe('resetPermissions')) + expect(search().get('panel')).toBe('files') + }) + + it('opens schedules in the panel without leaving the session', async () => { + const user = userEvent.setup() + renderAt('?panel=files') + + await user.click(screen.getByRole('button', { name: 'Schedules' })) + + expect(await screen.findByText('schedules-tool 1')).toBeInTheDocument() + expect(screen.getByTestId('pathname').textContent).toBe('/repos/1/sessions/ses_1') + expect(search().get('panel')).toBe('schedules') + }) + + it('drops schedule selection params when switching away from schedules', async () => { + const user = userEvent.setup() + renderAt('?panel=schedules&jobId=4&scheduleTab=runs&runId=9') + + await user.click(await screen.findByRole('button', { name: 'Terminal' })) + + await waitFor(() => expect(search().get('panel')).toBe('terminal')) + expect(search().has('jobId')).toBe(false) + expect(search().has('scheduleTab')).toBe(false) + expect(search().has('runId')).toBe(false) + }) + + it('closes a docked schedules panel when the layout is no longer docked', async () => { + renderAt('?panel=schedules&jobId=4', false) + + await waitFor(() => expect(search().has('panel')).toBe(false)) + expect(search().has('dialog')).toBe(false) + expect(search().has('jobId')).toBe(false) + }) + + it('drops terminal selection params when switching away from the terminal', async () => { + const user = userEvent.setup() + renderAt('?panel=terminal&terminal=pty-1') + + await user.click(await screen.findByRole('button', { name: 'Files' })) + + await waitFor(() => expect(search().get('panel')).toBe('files')) + expect(search().has('terminal')).toBe(false) + }) + + it('falls back to the dialog when not docked', async () => { + renderAt('?panel=sourceControl', false) + + await waitFor(() => expect(search().get('dialog')).toBe('sourceControl')) + expect(search().has('panel')).toBe(false) + }) +}) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx new file mode 100644 index 000000000..87e2657ab --- /dev/null +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -0,0 +1,175 @@ +import { X } from 'lucide-react' +import { useLocation } from 'react-router-dom' +import { FileBrowser } from '@/components/file-browser/FileBrowser' +import { SourceControlContent } from '@/components/source-control' +import { TerminalWorkspace } from '@/components/terminal/TerminalPanel' +import { ChangesWalkthroughView } from '@/components/session/ChangesWalkthroughSheet' +import { PreviewWorkspace } from '@/components/preview/PreviewPanel' +import { RepoMcpContent } from '@/components/repo/RepoMcpDialog' +import { RepoActionsContent } from '@/components/repo/RepoActionsDialog' +import { RepoSkillsContent } from '@/components/repo/RepoSkillsDialog' +import { RepoSchedulesContent } from '@/components/schedules/RepoSchedulesContent' +import type { SkillFileInfo } from '@opencode-manager/shared' +import { buildToolItems, type MoreDrawerItem } from '@/components/navigation/moreDrawerItems' +import { isPanelTool, type ToolPanelState, type PanelTool } from '@/hooks/useToolPanel' +import { useOpenNavItem } from '@/hooks/useOpenNavItem' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +interface ToolSidePanelProps { + panel: ToolPanelState + filesBasePath: string | undefined + allowNavigateAboveBase?: boolean + repoId?: number + sessionId?: string + directory?: string + terminalDirectory?: string + repoDirectory?: string + currentBranch?: string + selectedFilePath?: string + onSkillLoaded?: (skill: SkillFileInfo) => void +} + +interface RailButtonProps { + item: MoreDrawerItem + active: boolean + onClick: () => void +} + +function RailButton({ item, active, onClick }: RailButtonProps) { + const Icon = item.icon + return ( + + + + + + {item.label} + + + ) +} + +/** + * Desktop-only docked tool panel with its icon rail, rendered to the right of a page's content. + * The rail lists the current route's tool items; repo- and session-scoped tools render only when their context is given. + */ +export function ToolSidePanel({ + panel, + filesBasePath, + allowNavigateAboveBase, + repoId, + sessionId, + directory, + terminalDirectory, + repoDirectory, + currentBranch = 'main', + selectedFilePath, + onSkillLoaded, +}: ToolSidePanelProps) { + const { activeTool, toggleTool, closePanel } = panel + const location = useLocation() + const openNavItem = useOpenNavItem() + const panelTools: Array<{ item: MoreDrawerItem; tool: PanelTool }> = [] + const actionItems: MoreDrawerItem[] = [] + for (const item of buildToolItems(location.pathname)) { + const tool = item.panelTool ?? item.dialog ?? null + if (isPanelTool(tool)) panelTools.push({ item, tool }) + else actionItems.push(item) + } + const activeLabel = panelTools.find(({ tool }) => tool === activeTool)?.item.label + + if (panelTools.length === 0 && actionItems.length === 0) return null + + const renderTool = (tool: PanelTool) => { + if (tool === 'files') { + return ( + + ) + } + if (tool === 'preview') return + if (tool === 'mcp') return repoDirectory ? : null + if (tool === 'walkthrough') return sessionId ? : null + if (repoId === undefined) return null + switch (tool) { + case 'sourceControl': + return + case 'terminal': + return + case 'actions': + return + case 'skills': + return sessionId ? ( + + ) : ( + + ) + case 'schedules': + return + } + } + + return ( + <> + {activeTool && activeLabel ? ( + + ) : null} + + + + + + ) +} diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 967004191..611608c6c 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 } from './moreDrawerItems' +import { buildMoreItems, buildNavModel, buildToolItems } from './moreDrawerItems' describe('buildMoreItems', () => { it('returns Home + All Schedules + Files + Settings + Logout for root path', () => { @@ -41,7 +41,7 @@ describe('buildMoreItems', () => { it('returns session-specific items for /repos/:id/sessions/:sid', () => { const items = buildMoreItems('/repos/42/sessions/abc') - expect(items).toHaveLength(12) + expect(items).toHaveLength(13) expect(items[0].key).toBe('home') expect(items[1].key).toBe('files') expect(items[2].key).toBe('mcp') @@ -52,23 +52,26 @@ describe('buildMoreItems', () => { expect(items[6].key).toBe('source-control') expect(items[7].key).toBe('terminal') expect(items[7].dialog).toBe('terminal') - expect(items[8].key).toBe('actions') - expect(items[8].dialog).toBe('actions') - expect(items[9].key).toBe('preview') - expect(items[9].dialog).toBe('preview') - expect(items[10].key).toBe('settings') - expect(items[11].key).toBe('logout') + expect(items[8].key).toBe('walkthrough') + expect(items[8].dialog).toBe('walkthrough') + expect(items[9].key).toBe('actions') + expect(items[9].dialog).toBe('actions') + expect(items[10].key).toBe('preview') + expect(items[10].dialog).toBe('preview') + expect(items[11].key).toBe('settings') + expect(items[12].key).toBe('logout') }) it('omits the Actions item for an Assistant session', () => { const items = buildMoreItems('/repos/0/sessions/abc') - expect(items).toHaveLength(11) + expect(items).toHaveLength(12) expect(items.map((item) => item.key)).not.toContain('actions') expect(items[7].key).toBe('terminal') - expect(items[8].key).toBe('preview') - expect(items[8].dialog).toBe('preview') - expect(items[9].key).toBe('settings') - expect(items[10].key).toBe('logout') + expect(items[8].key).toBe('walkthrough') + expect(items[9].key).toBe('preview') + expect(items[9].dialog).toBe('preview') + expect(items[10].key).toBe('settings') + expect(items[11].key).toBe('logout') }) it('returns assistant workspace items for /repos/:id/assistant', () => { @@ -135,6 +138,35 @@ describe('buildMoreItems', () => { }) }) +describe('buildToolItems', () => { + it('returns the session route items without home and account entries', () => { + expect(buildToolItems('/repos/42/sessions/abc').map((item) => item.key)).toEqual([ + 'files', + 'mcp', + 'skills', + 'reset-permissions', + 'schedules', + 'source-control', + 'terminal', + 'walkthrough', + 'actions', + 'preview', + ]) + }) + + it('returns the home route tools', () => { + expect(buildToolItems('/').map((item) => item.key)).toEqual(['all-schedules', 'files']) + }) + + it.each(['/repos/42', '/assistant'])('opens schedules in the panel on %s', (path) => { + expect(buildToolItems(path).find((item) => item.key === 'schedules')?.panelTool).toBe('schedules') + }) + + it.each(['/schedules', '/repos/42/schedules'])('returns nothing on %s', (path) => { + expect(buildToolItems(path)).toEqual([]) + }) +}) + describe('buildNavModel', () => { it.each([ '/', diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 9d4f8cb1b..cd366f52e 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import { Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, Play, Globe } from 'lucide-react' +import { BookOpen, Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, SquareTerminal, Settings, LogOut, Bot, Folder, Home, Play, Globe } from 'lucide-react' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { getAssistantPath, isAssistantPath } from '@/lib/navigation' @@ -9,6 +9,7 @@ export interface MoreDrawerItem { icon: LucideIcon to?: string dialog?: string + panelTool?: string danger?: boolean } @@ -46,6 +47,19 @@ function getBaseItems(): MoreDrawerItem[] { ] } +const SESSION_DETAIL_PATH = /^\/repos\/(\d+)\/sessions\/[^/]+$/ + +export function isSessionDetailPath(pathname: string): boolean { + return SESSION_DETAIL_PATH.test(pathname) +} + +const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) + +/** Route-scoped tool items, shown in the desktop tool rail instead of the sidebar. */ +export function buildToolItems(pathname: string): MoreDrawerItem[] { + return buildNavModel(pathname).items.filter((item) => !NON_TOOL_ITEM_KEYS.has(item.key)) +} + function buildRouteNavModel(pathname: string): NavModel { const baseItems = getBaseItems() @@ -57,7 +71,7 @@ function buildRouteNavModel(pathname: string): NavModel { { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${id}/schedules` }, + { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${id}/schedules`, panelTool: 'schedules' }, { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, { key: 'actions', label: 'Actions', icon: Play, dialog: 'actions' }, @@ -71,7 +85,7 @@ function buildRouteNavModel(pathname: string): NavModel { } } - const sessionDetailMatch = /^\/repos\/(\d+)\/sessions\/[^/]+$/.exec(pathname) + const sessionDetailMatch = SESSION_DETAIL_PATH.exec(pathname) if (sessionDetailMatch) { const repoId = sessionDetailMatch[1] const isAssistantRepo = repoId === String(ASSISTANT_REPO_ID) @@ -80,9 +94,10 @@ function buildRouteNavModel(pathname: string): NavModel { { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${repoId}/schedules` }, + { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: `/repos/${repoId}/schedules`, panelTool: 'schedules' }, { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, + { key: 'walkthrough', label: 'Walkthrough', icon: BookOpen, dialog: 'walkthrough' }, ...(isAssistantRepo ? [] : [{ key: 'actions', label: 'Actions', icon: Play, dialog: 'actions' }]), @@ -102,7 +117,7 @@ function buildRouteNavModel(pathname: string): NavModel { { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, { key: 'reset-permissions', label: 'Reset Permissions', icon: ShieldOff, dialog: 'resetPermissions', danger: true }, - { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: '/repos/0/schedules' }, + { key: 'schedules', label: 'Schedules', icon: CalendarClock, to: '/repos/0/schedules', panelTool: 'schedules' }, { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, { key: 'preview', label: 'Preview', icon: Globe, dialog: 'preview' }, diff --git a/frontend/src/components/preview/PreviewPanel.test.tsx b/frontend/src/components/preview/PreviewPanel.test.tsx index 7ea61e06d..7677fec7d 100644 --- a/frontend/src/components/preview/PreviewPanel.test.tsx +++ b/frontend/src/components/preview/PreviewPanel.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router-dom' import type { CreatePreviewSessionResponse } from '@opencode-manager/shared/types' import { createPreviewSession, usePreviewPorts } from '@/api/preview' -import { PreviewPanel } from './PreviewPanel' +import { PreviewPanel, PreviewWorkspace } from './PreviewPanel' vi.mock('@/api/preview', () => ({ usePreviewPorts: vi.fn(), @@ -41,6 +41,14 @@ function panelElement(initialEntry: string) { ) } +function workspaceElement(compact: boolean, initialEntry = DEFAULT_ENTRY) { + return ( + + + + ) +} + function renderPanel(initialEntry = DEFAULT_ENTRY) { const result = render(panelElement(initialEntry)) return { @@ -314,4 +322,22 @@ describe('PreviewPanel', () => { expect(createPreviewSession).toHaveBeenCalledTimes(1) }) + + it('keeps the port menu but hides the viewport presets in compact mode', async () => { + render(workspaceElement(true)) + await screen.findByTitle('Preview') + + expect(screen.getByRole('combobox', { name: 'Preview port' })).toBeInTheDocument() + expect(screen.queryByLabelText('Mobile viewport')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Tablet viewport')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Full width viewport')).not.toBeInTheDocument() + }) + + it('shows the viewport presets when not compact', async () => { + render(workspaceElement(false)) + await screen.findByTitle('Preview') + + expect(screen.getByRole('combobox', { name: 'Preview port' })).toBeInTheDocument() + expect(screen.getByLabelText('Mobile viewport')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/preview/PreviewPanel.tsx b/frontend/src/components/preview/PreviewPanel.tsx index 7658f996c..4933fdf1d 100644 --- a/frontend/src/components/preview/PreviewPanel.tsx +++ b/frontend/src/components/preview/PreviewPanel.tsx @@ -18,6 +18,12 @@ interface PreviewPanelProps { directory: string | undefined } +interface PreviewWorkspaceProps { + isOpen: boolean + directory: string | undefined + compact: boolean +} + type ViewportPreset = 'mobile' | 'tablet' | 'full' const VIEWPORT_WIDTHS: Record = { @@ -59,8 +65,7 @@ interface ActiveSession { data: CreatePreviewSessionResponse } -export function PreviewPanel({ isOpen, onClose, directory }: PreviewPanelProps) { - const isMobile = useMobile() +export function PreviewWorkspace({ isOpen, directory, compact }: PreviewWorkspaceProps) { const { searchParams, updateParams } = useUrlParams() const requestedPort = useMemo(() => { @@ -78,7 +83,6 @@ export function PreviewPanel({ isOpen, onClose, directory }: PreviewPanelProps) const sessionPortRef = useRef(null) const activePathRef = useRef(null) const requestIdRef = useRef(0) - const contentRef = useRef(null) const activeSession = session && session.targetPort === requestedPort ? session : null @@ -313,6 +317,86 @@ export function PreviewPanel({ isOpen, onClose, directory }: PreviewPanelProps) ) } + return ( +
+
+ { void portsQuery.refetch() }} + className={compact ? 'w-full' : 'sm:w-72 sm:flex-shrink-0'} + /> +
+ setPathDraft(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') handleGo() }} + className="h-9 min-w-0 flex-1 bg-transparent px-3 py-1" + placeholder="/" + /> + + + + {!compact && ( +
+ {VIEWPORT_PRESETS.map(({ preset, label, icon: Icon }) => ( + + ))} +
+ )} +
+
+ +
{body()}
+
+ ) +} + +export function PreviewPanel({ isOpen, onClose, directory }: PreviewPanelProps) { + const isMobile = useMobile() + const contentRef = useRef(null) + return ( { if (!open) onClose() }}> -
- { void portsQuery.refetch() }} - className="sm:w-72 sm:flex-shrink-0" - /> -
- setPathDraft(event.target.value)} - onKeyDown={(event) => { if (event.key === 'Enter') handleGo() }} - className="h-9 min-w-0 flex-1 bg-transparent px-3 py-1" - placeholder="/" - /> - - - -
- {VIEWPORT_PRESETS.map(({ preset, label, icon: Icon }) => ( - - ))} -
-
-
- -
{body()}
+
) diff --git a/frontend/src/components/repo/MultiRunSheet.tsx b/frontend/src/components/repo/MultiRunSheet.tsx index 6ea8cd6dc..8da078b71 100644 --- a/frontend/src/components/repo/MultiRunSheet.tsx +++ b/frontend/src/components/repo/MultiRunSheet.tsx @@ -13,7 +13,7 @@ import { BranchCombobox } from '@/components/repo/BranchCombobox' import { ModelCheckboxList } from '@/components/repo/ModelCheckboxList' import { MultiRunCard } from '@/components/repo/MultiRunCard' import { FusionComposer, type FusionComposerSubmission } from '@/components/repo/FusionComposer' -import { ChangesWalkthroughDialog } from '@/components/session/ChangesWalkthroughDialog' +import { ChangesWalkthroughSheet } from '@/components/session/ChangesWalkthroughSheet' import { useModelSections } from '@/hooks/useModelSections' import { useDiscardMultiRunEntry, useFuseMultiRun, useLaunchMultiRun, useMultiRuns } from '@/hooks/useMultiRuns' import { filterModelSections } from '@/lib/modelSections' @@ -252,7 +252,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC /> {walkthroughSessionId ? ( - { diff --git a/frontend/src/components/repo/RepoActionsDialog.test.tsx b/frontend/src/components/repo/RepoActionsDialog.test.tsx index de40dd437..4cffc6f6e 100644 --- a/frontend/src/components/repo/RepoActionsDialog.test.tsx +++ b/frontend/src/components/repo/RepoActionsDialog.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor, within } from '@testing-library/react' import userEvent, { type UserEvent } from '@testing-library/user-event' import { FetchError } from '@opencode-manager/shared' -import { RepoActionsDialog } from './RepoActionsDialog' +import { RepoActionsContent, RepoActionsDialog } from './RepoActionsDialog' import type { ProjectConfigResponse } from '@opencode-manager/shared/types' const mocks = vi.hoisted(() => ({ @@ -78,6 +78,27 @@ describe('RepoActionsDialog', () => { vi.clearAllMocks() }) + it('renders the actions content standalone without the dialog shell', () => { + mockConfig(baseConfig()) + const view = render() + + expect(screen.getByText('No actions configured')).toBeInTheDocument() + expect(screen.queryByText('Project Actions')).not.toBeInTheDocument() + + mockConfig( + baseConfig({ + actions: [ + { id: 'personal-1', name: 'Dev server', command: 'pnpm dev', autoOpenUrl: false, source: 'personal' }, + ], + }), + ) + view.rerender() + + expect(screen.getByText('Dev server')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /actions/i })).toBeInTheDocument() + expect(screen.queryByText('Project Actions')).not.toBeInTheDocument() + }) + it('sends the full personal list when adding an action', async () => { mockConfig( baseConfig({ diff --git a/frontend/src/components/repo/RepoActionsDialog.tsx b/frontend/src/components/repo/RepoActionsDialog.tsx index 18b78f62c..18000fa57 100644 --- a/frontend/src/components/repo/RepoActionsDialog.tsx +++ b/frontend/src/components/repo/RepoActionsDialog.tsx @@ -191,14 +191,13 @@ function TabCount({ count }: { count: number }) { return {count} } -interface RepoActionsDialogProps { +interface RepoActionsContentProps { repoId: number directory: string | undefined open: boolean - onOpenChange: (open: boolean) => void } -export function RepoActionsDialog({ repoId, directory, open, onOpenChange }: RepoActionsDialogProps) { +export function RepoActionsContent({ repoId, directory, open }: RepoActionsContentProps) { const configQuery = useProjectConfig(repoId, directory, open && !!directory) const updateActions = useUpdateProjectActions(repoId, directory) const updateSetup = useUpdateWorktreeSetup(repoId, directory) @@ -329,263 +328,251 @@ export function RepoActionsDialog({ repoId, directory, open, onOpenChange }: Rep ) return ( - <> - - - - Project Actions - Commands and setup steps for this location. - - - {configQuery.isLoading ? ( -
- -
- ) : !config ? ( -

- {configQuery.error instanceof Error ? configQuery.error.message : 'No configuration available.'} -

- ) : ( - <> - {hasRepoFileStatus && ( -
- {config.repoFile.error && ( -

- {config.repoFile.error} -

- )} - {config.repoFile.warnings.map((warning) => ( -

- {warning} -

- ))} - {needsTrust && ( -
- -
-

Repository commands are not trusted

-

They won't run until you review and trust them.

-
- -
- )} +
+ {configQuery.isLoading ? ( +
+ +
+ ) : !config ? ( +

+ {configQuery.error instanceof Error ? configQuery.error.message : 'No configuration available.'} +

+ ) : ( + <> + {hasRepoFileStatus && ( +
+ {config.repoFile.error && ( +

+ {config.repoFile.error} +

+ )} + {config.repoFile.warnings.map((warning) => ( +

+ {warning} +

+ ))} + {needsTrust && ( +
+ +
+

Repository commands are not trusted

+

They won't run until you review and trust them.

+
+
)} +
+ )} - -
- - - Actions - - - - Worktree setup - - - -
+ +
+ + + Actions + + + + Worktree setup + + + +
- -
-

Start these from the actions menu in the header.

- -
-
- - {personalActions.map((action) => - draft?.id === action.id ? ( - renderActionForm(draft) - ) : ( - } - description={{action.command}} - belowDescription={} - onClick={() => setDraft(actionToDraft(action))} - trailing={ - - } - actions={[ - { - label: 'Move to repository', - onClick: () => moveAction(action, 'repo'), - disabled: moveItem.isPending, - }, - { - label: 'Delete', - destructive: true, - separatorBefore: true, - onClick: () => handleDeleteAction(action.id), - disabled: updateActions.isPending, - }, - ]} - actionsLabel={`Actions for ${action.name}`} - /> - ), - )} - {isAddingAction && draft && renderActionForm(draft)} - {repoActions.map((action) => ( - } - badges={In repo} - description={{action.command}} - belowDescription={} - actions={[ - { - label: 'Move to my settings', - onClick: () => moveAction(action, 'personal'), - disabled: moveItem.isPending, - }, - ]} - actionsLabel={`Actions for ${action.name}`} - /> - ))} - -
-
- - -
-

- Run in order after a new worktree is created.{' '} - $ROOT_PROJECT_PATH points to the main checkout. -

- -
-
- - {setupCommands.map((command, index) => ( -
- - setSetupCommands((current) => - current.map((item, itemIndex) => (itemIndex === index ? event.target.value : item)), - ) - } - /> + +
+

Start these from the actions menu in the header.

+ +
+
+ + {personalActions.map((action) => + draft?.id === action.id ? ( + renderActionForm(draft) + ) : ( + } + description={{action.command}} + belowDescription={} + onClick={() => setDraft(actionToDraft(action))} + trailing={ - - moveSetup(command, 'repo'), - disabled: setupDirty || moveItem.isPending, - }, - { - label: 'Remove', - destructive: true, - separatorBefore: true, - onClick: () => - setSetupCommands((current) => current.filter((_, itemIndex) => itemIndex !== index)), - }, - ]} - /> -
- ))} - {repoSetup.map((item) => ( - {item.command}} - titleClassName="font-mono text-xs font-normal" - badges={In repo} - actions={[ - { - label: 'Move to my settings', - onClick: () => moveSetup(item.command, 'personal'), - disabled: moveItem.isPending, - }, - ]} - actionsLabel={`Actions for ${item.command}`} - /> - ))} - -
-
-

- {setupDirty ? 'Unsaved changes' : ''} -

- -
- - - - )} - -
+ } + actions={[ + { + label: 'Move to repository', + onClick: () => moveAction(action, 'repo'), + disabled: moveItem.isPending, + }, + { + label: 'Delete', + destructive: true, + separatorBefore: true, + onClick: () => handleDeleteAction(action.id), + disabled: updateActions.isPending, + }, + ]} + actionsLabel={`Actions for ${action.name}`} + /> + ), + )} + {isAddingAction && draft && renderActionForm(draft)} + {repoActions.map((action) => ( + } + badges={In repo} + description={{action.command}} + belowDescription={} + actions={[ + { + label: 'Move to my settings', + onClick: () => moveAction(action, 'personal'), + disabled: moveItem.isPending, + }, + ]} + actionsLabel={`Actions for ${action.name}`} + /> + ))} + +
+ + + +
+

+ Run in order after a new worktree is created.{' '} + $ROOT_PROJECT_PATH points to the main checkout. +

+ +
+
+ + {setupCommands.map((command, index) => ( +
+ + setSetupCommands((current) => + current.map((item, itemIndex) => (itemIndex === index ? event.target.value : item)), + ) + } + /> + + + moveSetup(command, 'repo'), + disabled: setupDirty || moveItem.isPending, + }, + { + label: 'Remove', + destructive: true, + separatorBefore: true, + onClick: () => + setSetupCommands((current) => current.filter((_, itemIndex) => itemIndex !== index)), + }, + ]} + /> +
+ ))} + {repoSetup.map((item) => ( + {item.command}} + titleClassName="font-mono text-xs font-normal" + badges={In repo} + actions={[ + { + label: 'Move to my settings', + onClick: () => moveSetup(item.command, 'personal'), + disabled: moveItem.isPending, + }, + ]} + actionsLabel={`Actions for ${item.command}`} + /> + ))} +
+
+
+

+ {setupDirty ? 'Unsaved changes' : ''} +

+ +
+
+ + + )} - +
+ ) +} + +interface RepoActionsDialogProps { + repoId: number + directory: string | undefined + open: boolean + onOpenChange: (open: boolean) => void +} + +export function RepoActionsDialog({ repoId, directory, open, onOpenChange }: RepoActionsDialogProps) { + return ( + + + + Project Actions + Commands and setup steps for this location. + + + + + ) } diff --git a/frontend/src/components/repo/RepoMcpDialog.tsx b/frontend/src/components/repo/RepoMcpDialog.tsx index af7cbb8f6..c0e2405d9 100644 --- a/frontend/src/components/repo/RepoMcpDialog.tsx +++ b/frontend/src/components/repo/RepoMcpDialog.tsx @@ -15,7 +15,12 @@ interface RepoMcpDialogProps { directory: string | undefined } -export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogProps) { +interface RepoMcpContentProps { + open: boolean + directory: string +} + +export function RepoMcpContent({ open, directory }: RepoMcpContentProps) { const queryClient = useQueryClient() const [localStatus, setLocalStatus] = useState>({}) const [isLoadingStatus, setIsLoadingStatus] = useState(false) @@ -26,8 +31,6 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr const serverIds = Object.keys(localStatus) const fetchStatus = useCallback(async () => { - if (!directory) return - setIsLoadingStatus(true) try { setLocalStatus(await mcpApi.getStatus(directory)) @@ -39,8 +42,6 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr const toggleMutation = useMutation({ mutationFn: async ({ serverId, enable }: { serverId: string; enable: boolean }) => { - if (!directory) throw new Error('No directory provided') - if (enable) { await mcpApi.connect(serverId, directory) } else { @@ -59,7 +60,6 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr const removeAuthMutation = useMutation({ mutationFn: async (serverId: string) => { - if (!directory) throw new Error('No directory provided') await mcpApi.removeAuth(serverId, directory) }, onSuccess: async () => { @@ -79,7 +79,7 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr } const handleOAuthCheckStatus = async (): Promise => { - if (!authDialogServerId || !directory) return false + if (!authDialogServerId) return false const status = await mcpApi.getStatus(directory) if (status[authDialogServerId]?.status === 'connected') { setLocalStatus(status) @@ -94,16 +94,58 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr } useEffect(() => { - if (open && directory) { + if (open) { fetchStatus() } - }, [open, directory, fetchStatus]) + }, [open, fetchStatus]) + return ( +
+ + + setRemoveAuthConfirmServer(null)} + onConfirm={() => { + if (removeAuthConfirmServer) { + removeAuthMutation.mutate(removeAuthConfirmServer) + } + }} + onCancel={() => setRemoveAuthConfirmServer(null)} + title="Remove Authentication" + description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." + itemName={removeAuthConfirmServer ? formatMcpServerName(removeAuthConfirmServer) : ''} + isDeleting={removeAuthMutation.isPending} + /> + + !o && setAuthDialogServerId(null)} + serverName={authDialogServerId || ''} + onStartAuth={handleOAuthStartAuth} + onCheckStatus={handleOAuthCheckStatus} + onSuccess={handleOAuthSuccess} + directory={directory} + /> +
+ ) +} + +export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogProps) { if (!directory) return null return ( - + MCP for This Location @@ -111,41 +153,7 @@ export function RepoMcpDialog({ open, onOpenChange, directory }: RepoMcpDialogPr - - - setRemoveAuthConfirmServer(null)} - onConfirm={() => { - if (removeAuthConfirmServer) { - removeAuthMutation.mutate(removeAuthConfirmServer) - } - }} - onCancel={() => setRemoveAuthConfirmServer(null)} - title="Remove Authentication" - description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." - itemName={removeAuthConfirmServer ? formatMcpServerName(removeAuthConfirmServer) : ''} - isDeleting={removeAuthMutation.isPending} - /> - - !o && setAuthDialogServerId(null)} - serverName={authDialogServerId || ''} - onStartAuth={handleOAuthStartAuth} - onCheckStatus={handleOAuthCheckStatus} - onSuccess={handleOAuthSuccess} - directory={directory} - /> + ) diff --git a/frontend/src/components/repo/RepoSkillsDialog.tsx b/frontend/src/components/repo/RepoSkillsDialog.tsx index 0af1c3bf8..98a58f594 100644 --- a/frontend/src/components/repo/RepoSkillsDialog.tsx +++ b/frontend/src/components/repo/RepoSkillsDialog.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Download } from 'lucide-react' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { DeleteDialog } from '@/components/ui/delete-dialog' import { SkillLibraryList } from '@/components/skills/SkillLibraryList' @@ -12,25 +12,30 @@ import { useDeleteSkill } from '@/hooks/useDeleteSkill' import { invalidateSkillCaches } from '@/lib/queryInvalidation' import type { SkillFileInfo } from '@opencode-manager/shared' -type RepoSkillsDialogBaseProps = { +type RepoSkillsSessionProps = + | { sessionId: string; directory?: string; onSkillLoaded?: (skill: SkillFileInfo) => void } + | { sessionId?: undefined; directory?: undefined; onSkillLoaded?: undefined } + +type RepoSkillsDialogProps = { open: boolean onOpenChange: (open: boolean) => void repoId: number -} +} & RepoSkillsSessionProps -type RepoSkillsDialogProps = RepoSkillsDialogBaseProps & ( - | { sessionId: string; directory?: string; onSkillLoaded?: (skill: SkillFileInfo) => void } - | { sessionId?: undefined; directory?: undefined; onSkillLoaded?: undefined } -) +type RepoSkillsContentProps = { + open: boolean + repoId: number + onDone: () => void +} & RepoSkillsSessionProps -export function RepoSkillsDialog({ +export function RepoSkillsContent({ open, - onOpenChange, repoId, sessionId, directory, onSkillLoaded, -}: RepoSkillsDialogProps) { + onDone, +}: RepoSkillsContentProps) { const queryClient = useQueryClient() const [installDialogOpen, setInstallDialogOpen] = useState(false) const { deleteSkill, setDeleteSkill, confirmDelete, isDeleting } = useDeleteSkill() @@ -49,49 +54,37 @@ export function RepoSkillsDialog({ const handleLoad = (skill: SkillFileInfo) => { loadSkill.mutate({ skillName: skill.name }) onSkillLoaded?.(skill) - onOpenChange(false) + onDone() } const handleInstalled = () => { invalidateSkillCaches(queryClient) } - if (!repoId && !sessionId) { - return null - } - return ( - <> - - - -
-
- Skills - - {canLoad ? 'Search and load a skill into the current session' : 'Skills available for this repository'} - -
- -
-
-
- -
-
-
+
+
+

+ {canLoad ? 'Search and load a skill into the current session' : 'Skills available for this repository'} +

+ +
+ +
+ +
- +
+ ) +} + +export function RepoSkillsDialog({ onOpenChange, ...props }: RepoSkillsDialogProps) { + if (!props.repoId && !props.sessionId) { + return null + } + + return ( + + + + Skills + + onOpenChange(false)} /> + + ) } diff --git a/frontend/src/components/schedules/RepoSchedulesContent.tsx b/frontend/src/components/schedules/RepoSchedulesContent.tsx new file mode 100644 index 000000000..52306f270 --- /dev/null +++ b/frontend/src/components/schedules/RepoSchedulesContent.tsx @@ -0,0 +1,388 @@ +import { useEffect, useMemo, useState } from 'react' +import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' +import { + useAllSchedules, + useCancelRepoScheduleRun, + useClearRepoScheduleRuns, + useCreateRepoSchedule, + useDeleteRepoSchedule, + useDeleteRepoScheduleRun, + useRepoSchedule, + useRepoScheduleRuns, + useRunRepoSchedule, + useScheduleWorktrees, + useUpdateRepoSchedule, +} from '@/hooks/useSchedules' +import { useScheduleUrlState } from '@/hooks/useScheduleUrlState' +import type { ScheduleJobWithRepo } from '@/api/schedules' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { cn } from '@/lib/utils' +import { CalendarClock, Loader2, Plus } from 'lucide-react' +import { + JobDetailTab, + RunHistoryTab, + ScheduleJobDialog, + ScheduleJobsTable, + ScheduleListToolbar, + ScheduleRunRemovalDialog, + ScheduleTabMenu, +} from '@/components/schedules' +import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from './schedule-utils' + +interface RepoSchedulesContentProps { + repoId: number + embedded: boolean +} + +/** + * A repository's schedule jobs, job detail and run history with their dialogs. + * `embedded` renders it inside a side panel, which has no page header to hold the New Schedule action. + */ +export function RepoSchedulesContent({ repoId, embedded }: RepoSchedulesContentProps) { + const { + scheduleTab, + setScheduleTab, + dialog, + jobId, + runId, + openNewJob, + openEditJob, + openDeleteJob, + closeDialog, + selectRun, + selectJobAndView, + selectJobAndCloseDialog, + replaceUrlParams, + } = useScheduleUrlState() + + const repoScheduleTab = scheduleTab === 'prompts' ? 'jobs' : scheduleTab + + const { data: allSchedules, isLoading: jobsLoading } = useAllSchedules() + const jobs = useMemo( + () => allSchedules?.filter((job) => job.repoId === repoId), + [allSchedules, repoId], + ) + const { data: selectedJob, isFetching: isJobFetching } = useRepoSchedule(repoId, jobId) + const { data: runs, isLoading: runsLoading } = useRepoScheduleRuns(repoId, jobId, 30) + const { data: scheduleWorktrees = [] } = useScheduleWorktrees(repoId, jobId) + + const createMutation = useCreateRepoSchedule() + const updateMutation = useUpdateRepoSchedule() + const deleteMutation = useDeleteRepoSchedule() + const runMutation = useRunRepoSchedule() + const cancelRunMutation = useCancelRepoScheduleRun() + const clearRunsMutation = useClearRepoScheduleRuns() + const deleteRunMutation = useDeleteRepoScheduleRun() + + const [jobSearch, setJobSearch] = useState('') + const [clearRunsOpen, setClearRunsOpen] = useState(false) + const [runToDelete, setRunToDelete] = useState(null) + + const clearableRuns = useMemo(() => (runs ?? []).filter((run) => run.status !== 'running'), [runs]) + const affectedClearWorktreeCount = useMemo( + () => scheduleWorktrees.filter((worktree) => worktree.runId !== null && !worktree.inUse).length, + [scheduleWorktrees], + ) + const runToDeleteWorktreeCount = useMemo( + () => (runToDelete !== null && scheduleWorktrees.some((worktree) => worktree.runId === runToDelete) ? 1 : 0), + [scheduleWorktrees, runToDelete], + ) + + useEffect(() => { + if (scheduleTab === 'prompts') { + setScheduleTab('jobs') + } + }, [scheduleTab, setScheduleTab]) + + const editingJob = useMemo( + () => (dialog === 'edit' && jobId !== null ? jobs?.find((j) => j.id === jobId) : undefined), + [dialog, jobId, jobs], + ) + + useEffect(() => { + if (jobs === undefined) return + + if (!jobs.length) { + if (jobId !== null || scheduleTab !== 'jobs') { + replaceUrlParams((p) => { + p.delete('jobId') + p.delete('scheduleTab') + }) + } + return + } + + const stillExists = jobId !== null && jobs.some((job) => job.id === jobId) + if (!stillExists) { + const newId = jobs[0]?.id ?? null + if (newId !== jobId || scheduleTab !== 'jobs') { + replaceUrlParams((p) => { + if (newId === null) p.delete('jobId') + else p.set('jobId', String(newId)) + p.delete('scheduleTab') + }) + } + } + }, [jobs, jobId, scheduleTab, replaceUrlParams]) + + useEffect(() => { + if (runs === undefined) return + if (runId === null) return + if (!runs.some((run) => run.id === runId)) selectRun(null) + }, [runs, runId, selectRun]) + + const runningRun = useMemo(() => runs?.find((run) => run.status === 'running') ?? null, [runs]) + + if (jobsLoading) { + return ( +
+ +
+ ) + } + + const hasJobs = (jobs?.length ?? 0) > 0 + + const handleCreate = (data: CreateScheduleJobRequest) => { + createMutation.mutate({ repoId, data }, { + onSuccess: (job) => { + selectJobAndCloseDialog(job.id) + }, + }) + } + + const handleUpdate = (data: CreateScheduleJobRequest) => { + if (dialog !== 'edit' || jobId === null) { + return + } + + updateMutation.mutate({ + repoId, + jobId, + data: toUpdateScheduleRequest(data), + }, { + onSuccess: () => { + closeDialog() + }, + }) + } + + const handleDelete = () => { + if (dialog !== 'delete' || jobId === null) { + return + } + + deleteMutation.mutate({ repoId, jobId }, { + onSuccess: () => { + closeDialog() + }, + }) + } + + const handleToggleEnabled = (job: ScheduleJob) => { + updateMutation.mutate({ + repoId, + jobId: job.id, + data: { enabled: !job.enabled }, + }) + } + + const handleRunNow = (job: ScheduleJob) => { + runMutation.mutate({ repoId, jobId: job.id }, { + onSuccess: (run) => { + selectRun(run.id) + }, + }) + } + + const handleCancelRun = () => { + const target = runId !== null ? runs?.find((run) => run.id === runId) ?? null : null + if (!target || target.status !== 'running') { + return + } + + cancelRunMutation.mutate({ + repoId, + jobId: target.jobId, + runId: target.id, + }, { + onSuccess: (run) => { + selectRun(run.id) + }, + }) + } + + const handleCancelJobRun = (job: ScheduleJobWithRepo) => { + if (!job.lastRun) { + return + } + + cancelRunMutation.mutate({ + repoId, + jobId: job.id, + runId: job.lastRun.id, + }) + } + + const handleClearHistory = (worktrees?: ScheduleRunWorktreesMode) => { + if (jobId === null) { + return + } + + clearRunsMutation.mutate({ repoId, jobId, worktrees }, { + onSuccess: () => setClearRunsOpen(false), + }) + } + + const handleConfirmDeleteRun = (worktrees?: ScheduleRunWorktreesMode) => { + if (jobId === null || runToDelete === null) { + return + } + + deleteRunMutation.mutate({ repoId, jobId, runId: runToDelete, worktrees }, { + onSuccess: () => setRunToDelete(null), + }) + } + + return ( + <> +
+ {!hasJobs ? ( +
+ + +
+ +
+
+

No schedules yet

+

Create a schedule for this repo to automate recurring agent work, then inspect runs, logs, and sessions here.

+
+ +
+
+
+ ) : ( + <> + {repoScheduleTab === 'jobs' && ( +
+ + {embedded ? ( + + ) : null} + +
+
+ matchesScheduleJobSearch(job, jobSearch))} + showRepo={false} + selectedJobId={jobId} + onOpen={(job) => selectJobAndView(job.id)} + onRunNow={handleRunNow} + onToggleEnabled={handleToggleEnabled} + onEdit={(job) => openEditJob(job.id)} + onDelete={(job) => openDeleteJob(job.id)} + onCancelRun={handleCancelJobRun} + runPending={runMutation.isPending} + cancelPending={cancelRunMutation.isPending} + /> +
+
+
+ )} + {repoScheduleTab === 'detail' && ( + openEditJob(job.id)} + onDelete={openDeleteJob} + onToggleEnabled={() => { if (selectedJob) handleToggleEnabled(selectedJob) }} + onRunNow={() => { if (selectedJob) handleRunNow(selectedJob) }} + updatePending={updateMutation.isPending} + runPending={runMutation.isPending} + runningRun={Boolean(runningRun)} + isJobFetching={isJobFetching} + /> + )} + {repoScheduleTab === 'runs' && ( + setClearRunsOpen(true)} + clearHistoryPending={clearRunsMutation.isPending} + onDeleteRun={(id) => setRunToDelete(id)} + deleteRunPending={deleteRunMutation.isPending} + /> + )} + + )} +
+ + {hasJobs && ( +
+ setScheduleTab(tab)} + /> +
+ )} + + { + if (!open) closeDialog() + }} + job={editingJob} + repoId={repoId} + isSaving={createMutation.isPending || updateMutation.isPending} + onSubmit={dialog === 'edit' ? handleUpdate : handleCreate} + /> + + !open && closeDialog()} + onConfirm={handleDelete} + onCancel={() => closeDialog()} + title="Delete Schedule" + description={DELETE_SCHEDULE_DESCRIPTION} + isDeleting={deleteMutation.isPending} + /> + + !open && setClearRunsOpen(false)} + title="Clear run history" + description={ + <>This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone. + } + affectedWorktreeCount={affectedClearWorktreeCount} + isPending={clearRunsMutation.isPending} + onCancel={() => setClearRunsOpen(false)} + onConfirm={handleClearHistory} + /> + + !open && setRunToDelete(null)} + title="Delete run" + description="This permanently deletes this run. This cannot be undone." + affectedWorktreeCount={runToDeleteWorktreeCount} + isPending={deleteRunMutation.isPending} + onCancel={() => setRunToDelete(null)} + onConfirm={handleConfirmDeleteRun} + /> + + ) +} diff --git a/frontend/src/components/session/ChangesWalkthroughDialog.tsx b/frontend/src/components/session/ChangesWalkthroughDialog.tsx deleted file mode 100644 index 6139fc93a..000000000 --- a/frontend/src/components/session/ChangesWalkthroughDialog.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { memo, useEffect, useMemo, useState } from 'react' -import { ChevronLeft, ChevronRight, Loader2, RefreshCw } from 'lucide-react' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { DiffLines } from '@/components/file-browser/DiffLines' -import { ScheduleRunMarkdown } from '@/components/schedules/ScheduleRunMarkdown' -import { useChangeWalkthrough, useGenerateChangeWalkthrough } from '@/hooks/useChangeWalkthrough' -import { GIT_STATUS_COLORS, GIT_STATUS_LABELS } from '@/lib/git-status-styles' -import { WalkthroughOmittedFileSchema } from '@opencode-manager/shared/schemas' -import type { WalkthroughHunk, WalkthroughOmittedFile } from '@opencode-manager/shared/schemas' - -interface ChangesWalkthroughDialogProps { - sessionId: string - open: boolean - onOpenChange: (open: boolean) => void -} - -function isNoChangesError(error: unknown): boolean { - const code = (error as { code?: string } | null)?.code - return code === 'WALKTHROUGH_NO_CHANGES' || code === 'WALKTHROUGH_NO_TEXT_CHANGES' -} - -function isContextLimitError(error: unknown): boolean { - return (error as { code?: string } | null)?.code === 'WALKTHROUGH_CONTEXT_LIMIT' -} - -function omittedFilesFromError(error: unknown): WalkthroughOmittedFile[] { - const details = (error as { details?: { omittedFiles?: unknown } } | null)?.details - const parsed = WalkthroughOmittedFileSchema.array().safeParse(details?.omittedFiles) - return parsed.success ? parsed.data : [] -} - -function walkthroughErrorMessage(error: unknown): string { - if (isNoChangesError(error)) { - return 'This session has no text changes to walk through' - } - if (isContextLimitError(error)) { - return 'These changes are too large to walk through' - } - return error instanceof Error ? error.message : 'Failed to load the walkthrough' -} - -const OMITTED_REASON_LABELS: Record = { - binary: 'binary file', - budget: 'over the diff budget', -} - -export const ChangesWalkthroughDialog = memo(function ChangesWalkthroughDialog({ sessionId, open, onOpenChange }: ChangesWalkthroughDialogProps) { - const stateQuery = useChangeWalkthrough(sessionId, open) - const generate = useGenerateChangeWalkthrough(sessionId) - const [stopIndex, setStopIndex] = useState(0) - - const walkthrough = stateQuery.data?.walkthrough ?? null - const stale = stateQuery.data?.stale ?? false - const error = generate.error ?? stateQuery.error - const contextLimitFiles = isContextLimitError(error) ? omittedFilesFromError(error) : [] - - useEffect(() => { - generate.reset() - }, [open, sessionId, generate.reset]) - - useEffect(() => { - setStopIndex(0) - }, [open, sessionId, walkthrough?.createdAt]) - - const hunksById = useMemo( - () => new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []), - [walkthrough], - ) - - const stops = walkthrough?.stops ?? [] - const clampedIndex = stops.length > 0 ? Math.min(stopIndex, stops.length - 1) : 0 - const selectedStop = stops[clampedIndex] ?? null - const selectedHunks = (selectedStop?.hunkIds ?? []) - .map((id) => hunksById.get(id)) - .filter((hunk): hunk is WalkthroughHunk => hunk !== undefined) - - return ( - - - - Change walkthrough - Walk through this session's changes, explained step by step. - - -
- {error ? ( -
-

{walkthroughErrorMessage(error)}

- {contextLimitFiles.length > 0 ? ( -
-

Omitted files

- {contextLimitFiles.map((omitted) => ( -

- {omitted.file} — {OMITTED_REASON_LABELS[omitted.reason]} -

- ))} -
- ) : null} -
- ) : null} - - {stateQuery.isLoading ? ( -
- -
- ) : !walkthrough ? ( -
-

- Generate a step-by-step walkthrough of the changes in this session. -

- -
- ) : ( -
- {stale ? ( -
-

- Changes have been updated since this walkthrough was generated -

- -
- ) : null} - - - - {stops.length > 0 ? ( -
-
    - {stops.map((stop, index) => ( -
  1. - -
  2. - ))} -
- -
- - - Stop {clampedIndex + 1} of {stops.length} - - -
- - {selectedStop ? ( -
- - {selectedHunks.map((hunk) => ( -
-
- - {hunk.file} - - {hunk.truncated ? ( - truncated - ) : null} - - {GIT_STATUS_LABELS[hunk.status]} - -
-
- -
-
- ))} -
- ) : null} -
- ) : null} - - {walkthrough.omittedFiles.length > 0 ? ( -
-

Omitted files

- {walkthrough.omittedFiles.map((omitted) => ( -

- {omitted.file} — {OMITTED_REASON_LABELS[omitted.reason]} -

- ))} -
- ) : null} -
- )} -
-
-
- ) -}) diff --git a/frontend/src/components/session/ChangesWalkthroughDialog.test.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx similarity index 79% rename from frontend/src/components/session/ChangesWalkthroughDialog.test.tsx rename to frontend/src/components/session/ChangesWalkthroughSheet.test.tsx index 8cea08bc5..5d7dd5f71 100644 --- a/frontend/src/components/session/ChangesWalkthroughDialog.test.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' -import { ChangesWalkthroughDialog } from './ChangesWalkthroughDialog' +import { ChangesWalkthroughSheet } from './ChangesWalkthroughSheet' import { FetchError } from '@/api/fetchWrapper' import type { ChangeWalkthrough, ChangeWalkthroughState } from '@opencode-manager/shared/schemas' @@ -48,7 +48,7 @@ const walkthrough: ChangeWalkthrough = { } function state(overrides: Partial = {}): ChangeWalkthroughState { - return { walkthrough, currentDiffHash: 'hash-1', stale: false, ...overrides } + return { walkthrough, currentDiffHash: 'hash-1', stale: false, generating: false, error: null, ...overrides } } function createWrapper() { @@ -58,16 +58,16 @@ function createWrapper() { ) } -function renderDialog(overrides: Partial> = {}) { +function renderSheet(overrides: Partial> = {}) { const onOpenChange = vi.fn() const view = render( - , + , { wrapper: createWrapper() }, ) return { ...view, onOpenChange } } -describe('ChangesWalkthroughDialog', () => { +describe('ChangesWalkthroughSheet', () => { beforeAll(() => { Element.prototype.hasPointerCapture ??= () => false Element.prototype.setPointerCapture ??= () => {} @@ -83,13 +83,13 @@ describe('ChangesWalkthroughDialog', () => { const user = userEvent.setup() let generated = false mocks.getChangeWalkthrough.mockImplementation(async () => - generated ? state() : { walkthrough: null, currentDiffHash: 'hash-1', stale: false }, + generated ? state() : state({ walkthrough: null }), ) mocks.generateChangeWalkthrough.mockImplementation(async () => { generated = true - return walkthrough + return state() }) - renderDialog() + renderSheet() const generate = await screen.findByRole('button', { name: 'Generate walkthrough' }) await user.click(generate) @@ -108,7 +108,7 @@ describe('ChangesWalkthroughDialog', () => { it('moves through the stops with the navigator in order', async () => { const user = userEvent.setup() mocks.getChangeWalkthrough.mockResolvedValue(state()) - renderDialog() + renderSheet() expect(await screen.findByText('Stop 1 of 2')).toBeInTheDocument() expect(screen.getByText('src/greet.ts')).toBeInTheDocument() @@ -130,8 +130,8 @@ describe('ChangesWalkthroughDialog', () => { it('shows the stale warning and regenerates', async () => { const user = userEvent.setup() mocks.getChangeWalkthrough.mockResolvedValue(state({ stale: true, currentDiffHash: 'hash-2' })) - mocks.generateChangeWalkthrough.mockResolvedValue(walkthrough) - renderDialog() + mocks.generateChangeWalkthrough.mockResolvedValue(state({ generating: true })) + renderSheet() expect( await screen.findByText('Changes have been updated since this walkthrough was generated'), @@ -144,13 +144,34 @@ describe('ChangesWalkthroughDialog', () => { }) }) + it('shows progress while the server is generating instead of the generate button', async () => { + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null, generating: true })) + renderSheet() + + expect(await screen.findByText(/Generating walkthrough/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Generate walkthrough' })).not.toBeInTheDocument() + }) + + it('renders a generation failure recorded by the server', async () => { + mocks.getChangeWalkthrough.mockResolvedValue( + state({ + walkthrough: null, + error: { message: 'The model did not return a usable change walkthrough', code: 'WALKTHROUGH_UNPARSEABLE' }, + }), + ) + renderSheet() + + expect(await screen.findByText('The model did not return a usable change walkthrough')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Generate walkthrough' })).toBeEnabled() + }) + it('renders the no-changes message inline', async () => { const user = userEvent.setup() - mocks.getChangeWalkthrough.mockResolvedValue({ walkthrough: null, currentDiffHash: null, stale: false }) + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null, currentDiffHash: null })) mocks.generateChangeWalkthrough.mockRejectedValue( new FetchError('This session has no changes to walk through', 409, 'WALKTHROUGH_NO_CHANGES'), ) - renderDialog() + renderSheet() await user.click(await screen.findByRole('button', { name: 'Generate walkthrough' })) @@ -159,9 +180,9 @@ describe('ChangesWalkthroughDialog', () => { it('renders other generation errors inline', async () => { const user = userEvent.setup() - mocks.getChangeWalkthrough.mockResolvedValue({ walkthrough: null, currentDiffHash: null, stale: false }) + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null, currentDiffHash: null })) mocks.generateChangeWalkthrough.mockRejectedValue(new Error('model exploded')) - renderDialog() + renderSheet() await user.click(await screen.findByRole('button', { name: 'Generate walkthrough' })) @@ -170,7 +191,7 @@ describe('ChangesWalkthroughDialog', () => { it('lists the omitted files when the diff is too large to walk through', async () => { const user = userEvent.setup() - mocks.getChangeWalkthrough.mockResolvedValue({ walkthrough: null, currentDiffHash: null, stale: false }) + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null, currentDiffHash: null })) mocks.generateChangeWalkthrough.mockRejectedValue( new FetchError('These changes are too large to walk through', 413, 'WALKTHROUGH_CONTEXT_LIMIT', undefined, { details: { @@ -181,7 +202,7 @@ describe('ChangesWalkthroughDialog', () => { }, }), ) - renderDialog() + renderSheet() await user.click(await screen.findByRole('button', { name: 'Generate walkthrough' })) @@ -202,7 +223,7 @@ describe('ChangesWalkthroughDialog', () => { }, }), ) - const { container } = renderDialog() + const { container } = renderSheet() expect(await screen.findByText(/Summary/)).toBeInTheDocument() expect(await screen.findByText(/Explanation/)).toBeInTheDocument() @@ -218,22 +239,22 @@ describe('ChangesWalkthroughDialog', () => { const user = userEvent.setup() let generated = false mocks.getChangeWalkthrough.mockImplementation(async () => - generated ? state() : { walkthrough: null, currentDiffHash: null, stale: false }, + generated ? state() : state({ walkthrough: null, currentDiffHash: null }), ) mocks.generateChangeWalkthrough .mockRejectedValueOnce(new FetchError(serverMessage, 409, code)) .mockImplementationOnce(async () => { generated = true - return walkthrough + return state() }) - const { rerender, onOpenChange } = renderDialog() + const { rerender, onOpenChange } = renderSheet() await user.click(await screen.findByRole('button', { name: 'Generate walkthrough' })) expect(await screen.findByText('This session has no text changes to walk through')).toBeInTheDocument() - rerender() - rerender() + rerender() + rerender() const generate = await screen.findByRole('button', { name: 'Generate walkthrough' }) expect(generate).toBeEnabled() diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.tsx new file mode 100644 index 000000000..2c7a7ef75 --- /dev/null +++ b/frontend/src/components/session/ChangesWalkthroughSheet.tsx @@ -0,0 +1,250 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronLeft, ChevronRight, Loader2, RefreshCw } from 'lucide-react' +import { SideDrawer, SideDrawerHeader } from '@/components/ui/side-drawer' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { DiffLines } from '@/components/file-browser/DiffLines' +import { ScheduleRunMarkdown } from '@/components/schedules/ScheduleRunMarkdown' +import { useChangeWalkthrough, useGenerateChangeWalkthrough } from '@/hooks/useChangeWalkthrough' +import { GIT_STATUS_COLORS, GIT_STATUS_LABELS } from '@/lib/git-status-styles' +import { cn } from '@/lib/utils' +import { WalkthroughOmittedFileSchema } from '@opencode-manager/shared/schemas' +import type { WalkthroughHunk, WalkthroughOmittedFile } from '@opencode-manager/shared/schemas' + +interface WalkthroughErrorLike { + message?: string + code?: string + details?: unknown +} + +function errorCode(error: unknown): string | undefined { + return (error as WalkthroughErrorLike | null)?.code +} + +function isNoChangesError(error: unknown): boolean { + const code = errorCode(error) + return code === 'WALKTHROUGH_NO_CHANGES' || code === 'WALKTHROUGH_NO_TEXT_CHANGES' +} + +function isContextLimitError(error: unknown): boolean { + return errorCode(error) === 'WALKTHROUGH_CONTEXT_LIMIT' +} + +function omittedFilesFromError(error: unknown): WalkthroughOmittedFile[] { + const details = (error as { details?: { omittedFiles?: unknown } } | null)?.details + const parsed = WalkthroughOmittedFileSchema.array().safeParse(details?.omittedFiles) + return parsed.success ? parsed.data : [] +} + +function walkthroughErrorMessage(error: unknown): string { + if (isNoChangesError(error)) { + return 'This session has no text changes to walk through' + } + if (isContextLimitError(error)) { + return 'These changes are too large to walk through' + } + return (error as WalkthroughErrorLike | null)?.message || 'Failed to load the walkthrough' +} + +const OMITTED_REASON_LABELS: Record = { + binary: 'binary file', + budget: 'over the diff budget', +} + +function OmittedFiles({ files }: { files: WalkthroughOmittedFile[] }) { + return ( +
+

Omitted files

+ {files.map((omitted) => ( +

+ {omitted.file} — {OMITTED_REASON_LABELS[omitted.reason]} +

+ ))} +
+ ) +} + +interface ChangesWalkthroughViewProps { + sessionId: string + active: boolean +} + +/** Generates and steps through a session's change walkthrough; the caller supplies the surrounding chrome. */ +export const ChangesWalkthroughView = memo(function ChangesWalkthroughView({ sessionId, active }: ChangesWalkthroughViewProps) { + const stateQuery = useChangeWalkthrough(sessionId, active) + const generate = useGenerateChangeWalkthrough(sessionId) + const resetGenerate = generate.reset + const [stopIndex, setStopIndex] = useState(0) + const detailRef = useRef(null) + + const state = stateQuery.data + const walkthrough = state?.walkthrough ?? null + const stale = state?.stale ?? false + const generating = generate.isPending || (state?.generating ?? false) + const error = generate.error ?? stateQuery.error ?? (generating ? null : state?.error ?? null) + const contextLimitFiles = isContextLimitError(error) ? omittedFilesFromError(error) : [] + + useEffect(() => { + resetGenerate() + }, [active, sessionId, resetGenerate]) + + useEffect(() => { + setStopIndex(0) + }, [active, sessionId, walkthrough?.createdAt]) + + const hunksById = useMemo( + () => new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []), + [walkthrough], + ) + + const stops = walkthrough?.stops ?? [] + const clampedIndex = stops.length > 0 ? Math.min(stopIndex, stops.length - 1) : 0 + const selectedStop = stops[clampedIndex] ?? null + const selectedHunks = (selectedStop?.hunkIds ?? []) + .map((id) => hunksById.get(id)) + .filter((hunk): hunk is WalkthroughHunk => hunk !== undefined) + + const selectStop = useCallback((index: number) => { + setStopIndex(index) + detailRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' }) + }, []) + + return ( +
+
+ {error ? ( +
+

{walkthroughErrorMessage(error)}

+ {contextLimitFiles.length > 0 ? : null} +
+ ) : null} + + {generating ? ( +
+ + Generating walkthrough… this can take a minute or two. +
+ ) : null} + + {stateQuery.isLoading ? ( +
+ +
+ ) : !walkthrough ? ( + generating ? null : ( +
+

+ Generate a step-by-step walkthrough of the changes in this session. +

+ +
+ ) + ) : ( +
+ {stale && !generating ? ( +
+

+ Changes have been updated since this walkthrough was generated +

+ +
+ ) : null} + + + + {stops.length > 0 ? ( +
    + {stops.map((stop, index) => ( +
  1. + +
  2. + ))} +
+ ) : null} + + {selectedStop ? ( +
+

+ {clampedIndex + 1}. {selectedStop.title} +

+ + {selectedHunks.map((hunk) => ( +
+
+ + {hunk.file} + + {hunk.truncated ? truncated : null} + + {GIT_STATUS_LABELS[hunk.status]} + +
+
+ +
+
+ ))} +
+ ) : null} + + {walkthrough.omittedFiles.length > 0 ? : null} +
+ )} +
+ + {stops.length > 0 ? ( +
+ + + Stop {clampedIndex + 1} of {stops.length} + + +
+ ) : null} +
+ ) +}) + +interface ChangesWalkthroughSheetProps { + sessionId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** The walkthrough as a right-side sheet: full screen on mobile, a drawer over the page on desktop. */ +export function ChangesWalkthroughSheet({ sessionId, open, onOpenChange }: ChangesWalkthroughSheetProps) { + const close = useCallback(() => onOpenChange(false), [onOpenChange]) + + return ( + + + + + ) +} diff --git a/frontend/src/components/source-control/SourceControlPanel.tsx b/frontend/src/components/source-control/SourceControlPanel.tsx index 6f574aa0a..df27ce523 100644 --- a/frontend/src/components/source-control/SourceControlPanel.tsx +++ b/frontend/src/components/source-control/SourceControlPanel.tsx @@ -42,16 +42,22 @@ interface SourceControlPanelProps { repoName?: string } +interface SourceControlContentProps { + repoId: number + isOpen: boolean + currentBranch: string + compact: boolean +} + type Tab = 'changes' | 'commits' | 'branches' | 'stash' type View = 'default' | 'commit-detail' -export function SourceControlPanel({ +export function SourceControlContent({ repoId, isOpen, - onClose, currentBranch, - repoName, -}: SourceControlPanelProps) { + compact, +}: SourceControlContentProps) { const [activeTab, setActiveTab] = useState('changes') const [selectedFile, setSelectedFile] = useState<{path: string, staged: boolean} | undefined>() const [currentView, setCurrentView] = useState('default') @@ -65,7 +71,6 @@ export function SourceControlPanel({ queryFn: () => getRepo(repoId), enabled: isOpen, }) - const isMobile = useMobile() const displayBranch = repo?.currentBranch || repo?.branch || currentBranch useRefreshOnOpen(isOpen, () => { invalidateRepoGitCaches(queryClient, repoId) }) @@ -111,8 +116,8 @@ export function SourceControlPanel({ const changesCount = status?.files.length || 0 const stagedCount = status?.files.filter(f => f.staged).length || 0 - const content = ( -
+ return ( +
@@ -188,7 +193,7 @@ export function SourceControlPanel({ setGitError(null)} /> )} - {!((currentView === 'commit-detail' && selectedCommitFile) || (isMobile && selectedFile && activeTab === 'changes')) && ( + {!((currentView === 'commit-detail' && selectedCommitFile) || (compact && selectedFile && activeTab === 'changes')) && (
{tabs.map((tab) => { const Icon = tab.icon @@ -197,14 +202,14 @@ export function SourceControlPanel({ key={tab.id} className={cn( 'flex items-center gap-1.5 py-2 text-sm whitespace-nowrap transition-colors border-b-2 -mb-px', - isMobile ? 'flex-1 justify-center px-2' : 'px-3', + compact ? 'flex-1 justify-center px-2' : 'px-3', activeTab === tab.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-accent' )} onClick={() => setActiveTab(tab.id)} > - {!isMobile && } + {!compact && } {tab.label} {tab.id === 'changes' && changesCount > 0 && ( @@ -217,15 +222,15 @@ export function SourceControlPanel({
)} -
+
{activeTab === 'changes' && ( @@ -234,7 +239,7 @@ export function SourceControlPanel({ onFileSelect={(path, staged) => setSelectedFile({ path, staged })} onClearFileSelection={() => setSelectedFile(undefined)} selectedFile={selectedFile} - isMobile={isMobile} + isMobile={compact} onError={handleGitError} /> )} @@ -261,7 +266,7 @@ export function SourceControlPanel({ )}
- {selectedFile && !isMobile && currentView === 'default' && ( + {selectedFile && !compact && currentView === 'default' && (
setSelectedFile(undefined)} /> @@ -271,6 +276,16 @@ export function SourceControlPanel({
) +} + +export function SourceControlPanel({ + repoId, + isOpen, + onClose, + currentBranch, + repoName, +}: SourceControlPanelProps) { + const isMobile = useMobile() return ( !open && onClose()}> @@ -302,7 +317,12 @@ export function SourceControlPanel({ )}
- {content} +
diff --git a/frontend/src/components/source-control/index.ts b/frontend/src/components/source-control/index.ts index 27d83d9d5..9611420a6 100644 --- a/frontend/src/components/source-control/index.ts +++ b/frontend/src/components/source-control/index.ts @@ -1,4 +1,4 @@ -export { SourceControlPanel } from './SourceControlPanel' +export { SourceControlPanel, SourceControlContent } from './SourceControlPanel' export { ChangesTab } from './ChangesTab' export { CommitsTab } from './CommitsTab' export { BranchesTab } from './BranchesTab' diff --git a/frontend/src/components/terminal/TerminalPanel.tsx b/frontend/src/components/terminal/TerminalPanel.tsx index 8f402259f..62113afc4 100644 --- a/frontend/src/components/terminal/TerminalPanel.tsx +++ b/frontend/src/components/terminal/TerminalPanel.tsx @@ -24,7 +24,13 @@ interface TerminalPanelProps { onClose: () => void } -export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPanelProps) { +interface TerminalWorkspaceProps { + repoId: number + directory: string | undefined + isOpen: boolean +} + +export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspaceProps) { const isMobile = useMobile() const { searchParams, updateParams } = useUrlParams() const openPreview = useOpenPreview() @@ -109,6 +115,112 @@ export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPa openUrlFromManager(uri, { openPreview }) }, [openPreview]) + return ( +
+
+ {terminals.map((terminal) => ( +
+ + +
+ ))} + +
+ +
+ {terminals.length === 0 ? ( +
+ {error && !isCreating ? `Could not load terminals: ${error.message}` : isLoading || isCreating ? 'Starting terminal...' : 'No terminals'} +
+ ) : ( + + Loading terminal... +
+ } + > + {terminals.map((terminal) => ( + { + if (handle) viewHandlesRef.current.set(terminal.id, handle) + else viewHandlesRef.current.delete(terminal.id) + }} + repoId={repoId} + directory={directory} + ptyID={terminal.id} + active={terminal.id === activeTerminalId} + ctrlArmed={ctrlArmed} + onCtrlConsumed={handleCtrlConsumed} + onOpenLink={handleOpenLink} + onExited={() => { void refetch() }} + /> + ))} + + )} +
+ + {isMobile && terminals.length > 0 && ( + + )} + + { if (!open) setPendingClose(null) }} + onConfirm={() => { + if (pendingClose) performRemove(pendingClose) + setPendingClose(null) + }} + onCancel={() => setPendingClose(null)} + title="Close Terminal" + description="Stop the running process?" + confirmLabel="Stop" + pendingLabel="Stopping..." + isPending={isRemoving} + /> +
+ ) +} + +export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPanelProps) { + const isMobile = useMobile() + return ( { if (!open) onClose() }}> -
- {terminals.map((terminal) => ( -
- - -
- ))} - -
- -
- {terminals.length === 0 ? ( -
- {error && !isCreating ? `Could not load terminals: ${error.message}` : isLoading || isCreating ? 'Starting terminal...' : 'No terminals'} -
- ) : ( - - Loading terminal... -
- } - > - {terminals.map((terminal) => ( - { - if (handle) viewHandlesRef.current.set(terminal.id, handle) - else viewHandlesRef.current.delete(terminal.id) - }} - repoId={repoId} - directory={directory} - ptyID={terminal.id} - active={terminal.id === activeTerminalId} - ctrlArmed={ctrlArmed} - onCtrlConsumed={handleCtrlConsumed} - onOpenLink={handleOpenLink} - onExited={() => { void refetch() }} - /> - ))} - - )} -
- - {isMobile && terminals.length > 0 && ( - - )} - - { if (!open) setPendingClose(null) }} - onConfirm={() => { - if (pendingClose) performRemove(pendingClose) - setPendingClose(null) - }} - onCancel={() => setPendingClose(null)} - title="Close Terminal" - description="Stop the running process?" - confirmLabel="Stop" - pendingLabel="Stopping..." - isPending={isRemoving} - /> + ) diff --git a/frontend/src/components/ui/side-drawer.test.tsx b/frontend/src/components/ui/side-drawer.test.tsx index 588493fa6..fe594230e 100644 --- a/frontend/src/components/ui/side-drawer.test.tsx +++ b/frontend/src/components/ui/side-drawer.test.tsx @@ -62,6 +62,23 @@ describe('SideDrawer', () => { expect(handleClose).not.toHaveBeenCalled() }) + it('closes only the topmost drawer on Escape when drawers are stacked', () => { + const closeOuter = vi.fn() + const closeInner = vi.fn() + render( + <> + +
Outer
+
+ +
Inner
+
+ , + ) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(closeInner).toHaveBeenCalledTimes(1) + expect(closeOuter).not.toHaveBeenCalled() + }) }) describe('SideDrawerHeader', () => { diff --git a/frontend/src/components/ui/side-drawer.tsx b/frontend/src/components/ui/side-drawer.tsx index fa97e2e84..870ed9d6a 100644 --- a/frontend/src/components/ui/side-drawer.tsx +++ b/frontend/src/components/ui/side-drawer.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { cn } from '@/lib/utils' import { MODAL_TRANSITION_MS } from '@/lib/utils' import { X } from 'lucide-react' +const openDrawerStack: symbol[] = [] + export interface SideDrawerProps { isOpen: boolean onClose: () => void @@ -33,23 +35,34 @@ export function SideDrawer({ } }, [isOpen]) + const onCloseRef = useRef(onClose) + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + useEffect(() => { + if (!isOpen) return + + const token = Symbol('side-drawer') + openDrawerStack.push(token) + const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && isOpen && !e.defaultPrevented) { - onClose() - } + if (e.key !== 'Escape' || e.defaultPrevented || openDrawerStack.at(-1) !== token) return + e.preventDefault() + onCloseRef.current() } - if (isOpen) { - document.addEventListener('keydown', handleEscape) - document.body.style.overflow = 'hidden' - } + document.addEventListener('keydown', handleEscape) + document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleEscape) - document.body.style.overflow = 'unset' + openDrawerStack.splice(openDrawerStack.indexOf(token), 1) + if (openDrawerStack.length === 0) { + document.body.style.overflow = 'unset' + } } - }, [isOpen, onClose]) + }, [isOpen]) if (!isOpen && !shouldRender) return null diff --git a/frontend/src/components/ui/sidebar.test.tsx b/frontend/src/components/ui/sidebar.test.tsx index fbe7ce17a..59dada0b4 100644 --- a/frontend/src/components/ui/sidebar.test.tsx +++ b/frontend/src/components/ui/sidebar.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { Sidebar, SidebarCollapsibleSection, SidebarSection, SidebarItem, SidebarCollapseToggle } from './sidebar' +import { Sidebar, SidebarSection, SidebarItem, SidebarCollapseToggle } from './sidebar' import { TooltipProvider } from './tooltip' import { Folder } from 'lucide-react' @@ -58,58 +58,6 @@ describe('SidebarSection', () => { }) }) -describe('SidebarCollapsibleSection', () => { - it('renders label and children when expanded', () => { - render( - -
Content
-
- ) - - expect(screen.getByRole('button', { name: 'Sessions' })).toHaveAttribute('aria-expanded', 'true') - expect(screen.getByText('Content')).toBeInTheDocument() - }) - - it('hides children when collapsed', () => { - render( - -
Content
-
- ) - - expect(screen.getByRole('button', { name: 'Sessions' })).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Content')).not.toBeInTheDocument() - }) - - it('calls onToggle when the header is clicked', () => { - const handleToggle = vi.fn() - - render( - -
Content
-
- ) - - fireEvent.click(screen.getByRole('button', { name: 'Menu' })) - expect(handleToggle).toHaveBeenCalledTimes(1) - }) - - it('applies contentClassName to the content wrapper', () => { - render( - -
Content
-
- ) - - expect(screen.getByText('Content').parentElement).toHaveClass('overflow-y-auto') - }) -}) - describe('SidebarItem', () => { it('fires onClick when clicked', () => { const handleClick = vi.fn() diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index 6631dc388..f03a2ab66 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -1,6 +1,5 @@ -import { useId } from 'react' import type { LucideIcon } from 'lucide-react' -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react' +import { ChevronLeft, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -57,46 +56,6 @@ export function SidebarSection({ label, collapsed, children }: SidebarSectionPro ) } -export interface SidebarCollapsibleSectionProps { - label: string - collapsed: boolean - onToggle: () => void - className?: string - contentClassName?: string - children: React.ReactNode -} - -export function SidebarCollapsibleSection({ - label, - collapsed, - onToggle, - className, - contentClassName, - children, -}: SidebarCollapsibleSectionProps) { - const contentId = useId() - - return ( -
- - {!collapsed && ( -
- {children} -
- )} -
- ) -} - export interface SidebarItemProps { icon: LucideIcon label: string diff --git a/frontend/src/hooks/useChangeWalkthrough.test.tsx b/frontend/src/hooks/useChangeWalkthrough.test.tsx index d71672c72..2439cbaa9 100644 --- a/frontend/src/hooks/useChangeWalkthrough.test.tsx +++ b/frontend/src/hooks/useChangeWalkthrough.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { changeWalkthroughQueryKey, useChangeWalkthrough, useGenerateChangeWalkthrough } from './useChangeWalkthrough' -import type { ChangeWalkthrough } from '@opencode-manager/shared/schemas' +import type { ChangeWalkthrough, ChangeWalkthroughState } from '@opencode-manager/shared/schemas' const mocks = vi.hoisted(() => ({ getChangeWalkthrough: vi.fn(), @@ -25,6 +25,10 @@ const walkthroughForA: ChangeWalkthrough = { createdAt: 1, } +function state(overrides: Partial = {}): ChangeWalkthroughState { + return { walkthrough: null, currentDiffHash: 'hash-a', stale: false, generating: false, error: null, ...overrides } +} + function deferred() { let resolve!: (value: T) => void const promise = new Promise((res) => { @@ -43,9 +47,9 @@ describe('useGenerateChangeWalkthrough', () => { vi.clearAllMocks() }) - it('caches a completed generation under the originating session when the session changes mid-flight', async () => { + it('caches the returned state under the originating session when the session changes mid-flight', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - const pending = deferred() + const pending = deferred() mocks.generateChangeWalkthrough.mockReturnValue(pending.promise) const { result, rerender } = renderHook( @@ -63,51 +67,45 @@ describe('useGenerateChangeWalkthrough', () => { rerender({ sessionId: 'ses_B' }) await act(async () => { - pending.resolve(walkthroughForA) + pending.resolve(state({ generating: true })) }) await waitFor(() => { - expect(queryClient.getQueryData(changeWalkthroughQueryKey('ses_A'))).toEqual({ - walkthrough: walkthroughForA, - currentDiffHash: 'hash-a', - stale: false, - }) + expect(queryClient.getQueryData(changeWalkthroughQueryKey('ses_A'))).toEqual(state({ generating: true })) }) expect(queryClient.getQueryData(changeWalkthroughQueryKey('ses_B'))).toBeUndefined() }) +}) - it('refetches the state query after generation so server-computed staleness wins', async () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - mocks.getChangeWalkthrough - .mockResolvedValueOnce({ walkthrough: null, currentDiffHash: null, stale: false }) - .mockResolvedValueOnce({ walkthrough: walkthroughForA, currentDiffHash: 'hash-a', stale: true }) - mocks.generateChangeWalkthrough.mockResolvedValue(walkthroughForA) - - const { result } = renderHook( - () => ({ - state: useChangeWalkthrough('ses_A', true), - generate: useGenerateChangeWalkthrough('ses_A'), - }), - { wrapper: createWrapper(queryClient) }, - ) +describe('useChangeWalkthrough', () => { + beforeEach(() => { + vi.clearAllMocks() + }) - await waitFor(() => { - expect(mocks.getChangeWalkthrough).toHaveBeenCalledTimes(1) - }) + it('polls while a generation is running and stops once it finishes', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mocks.getChangeWalkthrough + .mockResolvedValueOnce(state({ generating: true })) + .mockResolvedValueOnce(state({ walkthrough: walkthroughForA })) - act(() => { - result.current.generate.mutate({}) - }) + const { result } = renderHook(() => useChangeWalkthrough('ses_A', true), { wrapper: createWrapper(queryClient) }) - await waitFor(() => { - expect(mocks.getChangeWalkthrough).toHaveBeenCalledTimes(2) - }) - await waitFor(() => { - expect(result.current.state.data).toEqual({ - walkthrough: walkthroughForA, - currentDiffHash: 'hash-a', - stale: true, + await waitFor(() => expect(result.current.data?.generating).toBe(true)) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) }) - }) + + await waitFor(() => expect(result.current.data?.walkthrough).toEqual(walkthroughForA)) + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(mocks.getChangeWalkthrough).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } }) }) diff --git a/frontend/src/hooks/useChangeWalkthrough.ts b/frontend/src/hooks/useChangeWalkthrough.ts index b23876c0e..8438606b9 100644 --- a/frontend/src/hooks/useChangeWalkthrough.ts +++ b/frontend/src/hooks/useChangeWalkthrough.ts @@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { generateChangeWalkthrough, getChangeWalkthrough } from '@/api/changeWalkthroughs' import type { GenerateChangeWalkthroughRequest } from '@opencode-manager/shared/schemas' +const GENERATING_POLL_INTERVAL_MS = 2_000 + export function changeWalkthroughQueryKey(sessionId: string) { return ['change-walkthrough', sessionId] as const } @@ -12,6 +14,7 @@ export function useChangeWalkthrough(sessionId: string | undefined, enabled: boo queryFn: () => getChangeWalkthrough(sessionId!), enabled: enabled && !!sessionId, staleTime: 30_000, + refetchInterval: (query) => (query.state.data?.generating ? GENERATING_POLL_INTERVAL_MS : false), }) } @@ -19,15 +22,13 @@ export function useGenerateChangeWalkthrough(sessionId: string | undefined) { const queryClient = useQueryClient() return useMutation({ - mutationFn: (request: GenerateChangeWalkthroughRequest) => - generateChangeWalkthrough(sessionId!, request), - onSuccess: (walkthrough) => { - queryClient.setQueryData(changeWalkthroughQueryKey(walkthrough.sessionId), { - walkthrough, - currentDiffHash: walkthrough.diffHash, - stale: false, - }) - queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(walkthrough.sessionId) }) + mutationFn: async (request: GenerateChangeWalkthroughRequest) => { + const targetSessionId = sessionId! + const state = await generateChangeWalkthrough(targetSessionId, request) + return { sessionId: targetSessionId, state } + }, + onSuccess: ({ sessionId: targetSessionId, state }) => { + queryClient.setQueryData(changeWalkthroughQueryKey(targetSessionId), state) }, }) } diff --git a/frontend/src/hooks/useOpenNavItem.ts b/frontend/src/hooks/useOpenNavItem.ts new file mode 100644 index 000000000..023f98146 --- /dev/null +++ b/frontend/src/hooks/useOpenNavItem.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import type { MoreDrawerItem } from '@/components/navigation/moreDrawerItems' +import { getPathWithReturnTo } from '@/lib/navigation' +import { openDialogParam } from './useDialogParam' +import { useUrlParams } from './useUrlParams' + +/** Opens a navigation item's route or dialog; returns false for items it does not handle. */ +export function useOpenNavItem(): (item: MoreDrawerItem) => boolean { + const location = useLocation() + const navigate = useNavigate() + const { updateParams } = useUrlParams() + + return useCallback((item: MoreDrawerItem) => { + if (item.to) { + navigate(item.key === 'schedules' + ? getPathWithReturnTo(item.to, `${location.pathname}${location.search}`) + : item.to) + return true + } + if (item.dialog) { + openDialogParam(updateParams, item.dialog) + return true + } + return false + }, [location.pathname, location.search, navigate, updateParams]) +} diff --git a/frontend/src/hooks/useSidebarCollapsed.test.tsx b/frontend/src/hooks/useSidebarCollapsed.test.tsx index 08595cfbd..0aed5cefd 100644 --- a/frontend/src/hooks/useSidebarCollapsed.test.tsx +++ b/frontend/src/hooks/useSidebarCollapsed.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useSidebarCollapsed, useSidebarSections } from './useSidebarCollapsed' +import { useSidebarCollapsed } from './useSidebarCollapsed' const localStorageMock = { getItem: vi.fn(), @@ -74,120 +74,4 @@ describe('sidebar collapse hooks', () => { expect(result.current[0]).toBe(false) }) }) - - describe('useSidebarSections', () => { - const sections = ['sessions', 'menu'] as const - - it('opens every section by default when no stored value', () => { - localStorageMock.getItem.mockReturnValue(null) - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(result.current.isSectionOpen('sessions')).toBe(true) - expect(result.current.isSectionOpen('menu')).toBe(true) - }) - - it('restores closed sections from storage', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions'])) - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(localStorageMock.getItem).toHaveBeenCalledWith('oc:sidebar:collapsed:closed-sections') - expect(result.current.isSectionOpen('sessions')).toBe(false) - expect(result.current.isSectionOpen('menu')).toBe(true) - }) - - it('ignores unknown sections from storage', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions', 'other'])) - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(result.current.isSectionOpen('sessions')).toBe(false) - expect(result.current.isSectionOpen('menu')).toBe(true) - }) - - it('opens every section when the stored value is malformed JSON', () => { - localStorageMock.getItem.mockReturnValue('not-json{{') - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(result.current.isSectionOpen('sessions')).toBe(true) - expect(result.current.isSectionOpen('menu')).toBe(true) - }) - - it('opens every section when the stored value is not an array', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify('menu')) - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(result.current.isSectionOpen('sessions')).toBe(true) - expect(result.current.isSectionOpen('menu')).toBe(true) - }) - - it('closes a section and persists it without affecting the others', () => { - localStorageMock.getItem.mockReturnValue(null) - - const { result } = renderHook(() => useSidebarSections(sections)) - - act(() => { - result.current.toggleSection('sessions') - }) - - expect(result.current.isSectionOpen('sessions')).toBe(false) - expect(result.current.isSectionOpen('menu')).toBe(true) - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'oc:sidebar:collapsed:closed-sections', - JSON.stringify(['sessions']), - ) - }) - - it('reopens a closed section when toggled again', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions'])) - - const { result } = renderHook(() => useSidebarSections(sections)) - - act(() => { - result.current.toggleSection('sessions') - }) - - expect(result.current.isSectionOpen('sessions')).toBe(true) - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'oc:sidebar:collapsed:closed-sections', - JSON.stringify([]), - ) - }) - - it('defaults open and keeps toggling in memory when storage throws', () => { - localStorageMock.getItem.mockImplementationOnce(() => { - throw new Error('SecurityError') - }) - localStorageMock.setItem.mockImplementationOnce(() => { - throw new Error('QuotaExceededError') - }) - - const { result } = renderHook(() => useSidebarSections(sections)) - - expect(result.current.isSectionOpen('sessions')).toBe(true) - - act(() => { - result.current.toggleSection('sessions') - }) - - expect(result.current.isSectionOpen('sessions')).toBe(false) - }) - - it('tracks each section independently', () => { - localStorageMock.getItem.mockReturnValue(null) - - const { result } = renderHook(() => useSidebarSections(sections)) - - act(() => { - result.current.toggleSection('sessions') - result.current.toggleSection('menu') - }) - - expect(result.current.isSectionOpen('sessions')).toBe(false) - expect(result.current.isSectionOpen('menu')).toBe(false) - }) - }) }) diff --git a/frontend/src/hooks/useSidebarCollapsed.ts b/frontend/src/hooks/useSidebarCollapsed.ts index 785259fc9..3ae6662ae 100644 --- a/frontend/src/hooks/useSidebarCollapsed.ts +++ b/frontend/src/hooks/useSidebarCollapsed.ts @@ -1,7 +1,6 @@ import { useState, useCallback } from 'react' const STORAGE_KEY = 'oc:sidebar:collapsed' -const CLOSED_SECTIONS_STORAGE_KEY = `${STORAGE_KEY}:closed-sections` function readStorage(key: string): string | null { try { @@ -46,46 +45,6 @@ function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => v return [value, toggle] } -function readStoredClosedSections(sections: readonly T[]): T[] { - const stored = readStorage(CLOSED_SECTIONS_STORAGE_KEY) - if (stored === null) { - return [] - } - try { - const parsed = JSON.parse(stored) - if (!Array.isArray(parsed)) { - return [] - } - const known = new Set(sections) - return parsed.filter((value): value is T => typeof value === 'string' && known.has(value)) - } catch { - return [] - } -} - export function useSidebarCollapsed(): [boolean, () => void] { return usePersistentBoolean(STORAGE_KEY, false) } - -export function useSidebarSections( - sections: readonly T[], -): { isSectionOpen: (section: T) => boolean; toggleSection: (section: T) => void } { - const [closedSections, setClosedSections] = useState(() => readStoredClosedSections(sections)) - - const toggleSection = useCallback((section: T) => { - setClosedSections((prev) => { - const next = prev.includes(section) - ? prev.filter((item) => item !== section) - : [...prev, section] - writeStorage(CLOSED_SECTIONS_STORAGE_KEY, next) - return next - }) - }, []) - - const isSectionOpen = useCallback( - (section: T) => !closedSections.includes(section), - [closedSections], - ) - - return { isSectionOpen, toggleSection } -} diff --git a/frontend/src/hooks/useToolPanel.ts b/frontend/src/hooks/useToolPanel.ts new file mode 100644 index 000000000..29cc045f4 --- /dev/null +++ b/frontend/src/hooks/useToolPanel.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect } from 'react' +import { useUrlParams } from './useUrlParams' + +const PANEL_PARAM = 'panel' + +const PANEL_TOOLS = ['sourceControl', 'files', 'terminal', 'walkthrough', 'preview', 'mcp', 'actions', 'skills', 'schedules'] as const + +export type PanelTool = (typeof PANEL_TOOLS)[number] + +const TOOL_OWNED_PARAMS: Partial> = { + terminal: ['terminal', 'terminalDirectory'], + preview: ['previewPort', 'previewPath'], + schedules: ['scheduleTab', 'scheduleDialog', 'jobId', 'runId'], +} + +const PANEL_ONLY_TOOLS: ReadonlySet = new Set(['schedules']) + +export function isPanelTool(value: string | null): value is PanelTool { + return value !== null && (PANEL_TOOLS as readonly string[]).includes(value) +} + +function clearToolParams(params: URLSearchParams, keep?: PanelTool): void { + for (const [tool, owned] of Object.entries(TOOL_OWNED_PARAMS)) { + if (tool === keep) continue + for (const param of owned) params.delete(param) + } +} + +export interface ToolPanelState { + activeTool: PanelTool | null + toggleTool: (tool: PanelTool) => void + closePanel: () => void +} + +/** + * URL state of the docked desktop tool panel (`?panel=`). While docked, a tool opened through the + * shared `?dialog=` param is moved into the panel; when not docked, an open panel tool falls back to its dialog, + * or closes when it has none. + */ +export function useToolPanel(docked: boolean): ToolPanelState { + const { searchParams, updateParams } = useUrlParams() + const panelParam = searchParams.get(PANEL_PARAM) + const dialogParam = searchParams.get('dialog') + const activeTool = docked && isPanelTool(panelParam) ? panelParam : null + + useEffect(() => { + if (docked && isPanelTool(dialogParam)) { + updateParams((params) => { + params.delete('dialog') + params.set(PANEL_PARAM, dialogParam) + clearToolParams(params, dialogParam) + }, 'replace') + return + } + if (!docked && isPanelTool(panelParam)) { + updateParams((params) => { + params.delete(PANEL_PARAM) + if (PANEL_ONLY_TOOLS.has(panelParam)) { + clearToolParams(params) + return + } + if (!params.has('dialog')) params.set('dialog', panelParam) + }, 'replace') + } + }, [docked, dialogParam, panelParam, updateParams]) + + const toggleTool = useCallback((tool: PanelTool) => { + updateParams((params) => { + if (params.get(PANEL_PARAM) === tool) { + params.delete(PANEL_PARAM) + clearToolParams(params) + return + } + params.set(PANEL_PARAM, tool) + clearToolParams(params, tool) + }, 'push') + }, [updateParams]) + + const closePanel = useCallback(() => { + updateParams((params) => { + params.delete(PANEL_PARAM) + clearToolParams(params) + }, 'replace') + }, [updateParams]) + + return { activeTool, toggleTool, closePanel } +} diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 69c7e5cec..671bd4f29 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -17,6 +17,9 @@ import { TerminalPanel } from "@/components/terminal/TerminalPanel" import { PreviewPanel } from "@/components/preview/PreviewPanel" import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog" import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup" +import { ToolSidePanel } from "@/components/navigation/ToolSidePanel" +import { useMobile } from "@/hooks/useMobile" +import { useToolPanel } from "@/hooks/useToolPanel" import { Plus } from "lucide-react" export function AssistantRedirect() { @@ -29,6 +32,8 @@ export function AssistantRedirect() { const [terminalOpen, setTerminalOpen] = useTerminalDialogParam() const [previewOpen, setPreviewOpen] = useDialogParam('preview') const [resetPermissionsOpen, setResetPermissionsOpen] = useDialogParam('resetPermissions') + const docked = !useMobile() + const toolPanel = useToolPanel(docked) const { data: repo, isLoading: repoLoading, error: repoError } = useQuery({ queryKey: ["repo", repoId], @@ -48,7 +53,8 @@ export function AssistantRedirect() { } return ( -
+
+
Assistant @@ -79,11 +85,11 @@ export function AssistantRedirect() {
{assistantDirectory && ( <> - setFileBrowserOpen(false)} basePath={repo?.localPath} repoName="Assistant" repoId={repoId} /> - + setFileBrowserOpen(false)} basePath={repo?.localPath} repoName="Assistant" repoId={repoId} /> + {assistantDirectory ? ( ) : ( )} - setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName="Assistant" /> - setTerminalOpen(false)} /> - setPreviewOpen(false)} directory={assistantDirectory} /> + setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName="Assistant" /> + setTerminalOpen(false)} /> + setPreviewOpen(false)} directory={assistantDirectory} /> )}
+ {docked && ( + + )} +
) } diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index 18917b586..817a8f09d 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -20,6 +20,9 @@ import { useSSE } from "@/hooks/useSSE"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useOpenTerminal, useTerminalDialogParam, useTerminalDirectoryParam } from "@/hooks/useOpenTerminal"; import { useWorktreeTab } from "@/hooks/useWorktreeTab"; +import { useMobile } from "@/hooks/useMobile"; +import { useToolPanel } from "@/hooks/useToolPanel"; +import { ToolSidePanel } from "@/components/navigation/ToolSidePanel"; import { WorktreeTabs } from "@/components/repo/WorktreeTabs"; import { WorktreeSessionGroups } from "@/components/repo/WorktreeSessionGroups"; import { Button } from "@/components/ui/button"; @@ -51,6 +54,8 @@ export function RepoDetail() { const { activeTab, setActiveTab } = useWorktreeTab(); const openTerminal = useOpenTerminal(); const terminalDirectory = useTerminalDirectoryParam(); + const docked = !useMobile(); + const toolPanel = useToolPanel(docked); const { data: repo, isLoading: repoLoading } = useQuery({ queryKey: ["repo", repoId], @@ -211,8 +216,9 @@ export function RepoDetail() { const isWorktree = repo.isWorktree || false; return ( +
@@ -297,7 +303,7 @@ export function RepoDetail() { /> setFileBrowserOpen(false)} basePath={repo.localPath} repoName={displayName} @@ -306,7 +312,7 @@ export function RepoDetail() { /> @@ -314,19 +320,19 @@ export function RepoDetail() { setSourceControlOpen(false)} currentBranch={currentBranch} repoName={repoName} @@ -335,12 +341,12 @@ export function RepoDetail() { setTerminalOpen(false)} /> setPreviewOpen(false)} directory={baseDirectory} /> @@ -359,6 +365,19 @@ export function RepoDetail() { onOpenChange={setMultiRunOpen} />
+ {docked && ( + + )} +
); } diff --git a/frontend/src/pages/Repos.tsx b/frontend/src/pages/Repos.tsx index 9857de84b..00ce2ad46 100644 --- a/frontend/src/pages/Repos.tsx +++ b/frontend/src/pages/Repos.tsx @@ -8,18 +8,24 @@ import { Button } from "@/components/ui/button"; import { Plus, FolderOpen, CalendarClock } from "lucide-react"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; import { useDialogParam } from "@/hooks/useDialogParam"; +import { useMobile } from "@/hooks/useMobile"; +import { useToolPanel } from "@/hooks/useToolPanel"; +import { ToolSidePanel } from "@/components/navigation/ToolSidePanel"; export function Repos() { const navigate = useNavigate(); const [addRepoOpen, setAddRepoOpen] = useState(false); const [fileBrowserOpen, setFileBrowserOpen] = useDialogParam('files'); + const docked = !useMobile(); + const toolPanel = useToolPanel(docked); const handleCloseFileBrowser = () => { setFileBrowserOpen(false); }; return ( -
+
+
OpenCode @@ -61,12 +67,14 @@ export function Repos() {
+ {docked && } +
); } diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 7fd85e05b..4d66584d6 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -1,136 +1,25 @@ -import { useEffect, useMemo, useState } from 'react' import { useLocation, useParams } from 'react-router-dom' -import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' -import { - useAllSchedules, - useCancelRepoScheduleRun, - useClearRepoScheduleRuns, - useCreateRepoSchedule, - useDeleteRepoSchedule, - useDeleteRepoScheduleRun, - useRepoSchedule, - useRepoScheduleRuns, - useRunRepoSchedule, - useScheduleWorktrees, - useUpdateRepoSchedule, -} from '@/hooks/useSchedules' import { useRepoActivity } from '@/hooks/useRepoActivity' import { useScheduleTarget } from '@/hooks/useScheduleTarget' import { useScheduleUrlState } from '@/hooks/useScheduleUrlState' -import { ScheduleJobDialog, ScheduleJobsTable, ScheduleListToolbar, JobDetailTab, RunHistoryTab, ScheduleRunRemovalDialog, ScheduleTabMenu, ScheduleRepoSwitcher } from '@/components/schedules' -import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' -import type { ScheduleJobWithRepo } from '@/api/schedules' +import { ScheduleRepoSwitcher } from '@/components/schedules' +import { RepoSchedulesContent } from '@/components/schedules/RepoSchedulesContent' import { Header } from '@/components/ui/header' import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { DeleteDialog } from '@/components/ui/delete-dialog' import { getReturnToPath } from '@/lib/navigation' -import { CalendarClock, Loader2, Plus } from 'lucide-react' +import { Loader2, Plus } from 'lucide-react' export function Schedules() { const { id } = useParams<{ id: string }>() const location = useLocation() const repoId = id ? Number(id) : undefined - - const { - scheduleTab, - setScheduleTab, - dialog, - jobId, - runId, - openNewJob, - openEditJob, - openDeleteJob, - closeDialog, - selectRun, - selectJobAndView, - selectJobAndCloseDialog, - replaceUrlParams, - } = useScheduleUrlState() - - const repoScheduleTab = scheduleTab === 'prompts' ? 'jobs' : scheduleTab + const { openNewJob } = useScheduleUrlState() const { scheduleTarget, isLoading: scheduleTargetLoading } = useScheduleTarget(repoId) useRepoActivity(repoId ?? 0, Boolean(scheduleTarget) && scheduleTarget?.kind === 'repo') - const { data: allSchedules, isLoading: jobsLoading } = useAllSchedules() - const jobs = useMemo( - () => allSchedules?.filter((job) => job.repoId === repoId), - [allSchedules, repoId], - ) - const { data: selectedJob, isFetching: isJobFetching } = useRepoSchedule(repoId, jobId) - const { data: runs, isLoading: runsLoading } = useRepoScheduleRuns(repoId, jobId, 30) - const { data: scheduleWorktrees = [] } = useScheduleWorktrees(repoId, jobId) - - const createMutation = useCreateRepoSchedule() - const updateMutation = useUpdateRepoSchedule() - const deleteMutation = useDeleteRepoSchedule() - const runMutation = useRunRepoSchedule() - const cancelRunMutation = useCancelRepoScheduleRun() - const clearRunsMutation = useClearRepoScheduleRuns() - const deleteRunMutation = useDeleteRepoScheduleRun() - - const [jobSearch, setJobSearch] = useState('') - const [clearRunsOpen, setClearRunsOpen] = useState(false) - const [runToDelete, setRunToDelete] = useState(null) - - const clearableRuns = useMemo(() => (runs ?? []).filter((run) => run.status !== 'running'), [runs]) - const affectedClearWorktreeCount = useMemo( - () => scheduleWorktrees.filter((worktree) => worktree.runId !== null && !worktree.inUse).length, - [scheduleWorktrees], - ) - const runToDeleteWorktreeCount = useMemo( - () => (runToDelete !== null && scheduleWorktrees.some((worktree) => worktree.runId === runToDelete) ? 1 : 0), - [scheduleWorktrees, runToDelete], - ) - - useEffect(() => { - if (scheduleTab === 'prompts') { - setScheduleTab('jobs') - } - }, [scheduleTab, setScheduleTab]) - - const editingJob = useMemo( - () => (dialog === 'edit' && jobId !== null ? jobs?.find((j) => j.id === jobId) : undefined), - [dialog, jobId, jobs], - ) - - useEffect(() => { - if (jobs === undefined) return - - if (!jobs.length) { - if (jobId !== null || scheduleTab !== 'jobs') { - replaceUrlParams((p) => { - p.delete('jobId') - p.delete('scheduleTab') - }) - } - return - } - - const stillExists = jobId !== null && jobs.some((job) => job.id === jobId) - if (!stillExists) { - const newId = jobs[0]?.id ?? null - if (newId !== jobId || scheduleTab !== 'jobs') { - replaceUrlParams((p) => { - if (newId === null) p.delete('jobId') - else p.set('jobId', String(newId)) - p.delete('scheduleTab') - }) - } - } - }, [jobs, jobId, scheduleTab, replaceUrlParams]) - - useEffect(() => { - if (runs === undefined) return - if (runId === null) return - if (!runs.some((run) => run.id === runId)) selectRun(null) - }, [runs, runId, selectRun]) - - const runningRun = useMemo(() => runs?.find((run) => run.status === 'running') ?? null, [runs]) - - if (scheduleTargetLoading || jobsLoading) { + if (scheduleTargetLoading) { return (
@@ -147,114 +36,8 @@ export function Schedules() {
) } - const hasJobs = (jobs?.length ?? 0) > 0 - const backHref = getReturnToPath(location.search, scheduleTarget.backHref) - - const handleCreate = (data: CreateScheduleJobRequest) => { - createMutation.mutate({ repoId: repoId!, data }, { - onSuccess: (job) => { - selectJobAndCloseDialog(job.id) - }, - }) - } - - const handleUpdate = (data: CreateScheduleJobRequest) => { - if (dialog !== 'edit' || jobId === null) { - return - } - - updateMutation.mutate({ - repoId: repoId!, - jobId, - data: toUpdateScheduleRequest(data), - }, { - onSuccess: () => { - closeDialog() - }, - }) - } - - const handleDelete = () => { - if (dialog !== 'delete' || jobId === null) { - return - } - - const deletedJobId = jobId - deleteMutation.mutate({ repoId: repoId!, jobId: deletedJobId }, { - onSuccess: () => { - closeDialog() - }, - }) - } - const handleToggleEnabled = (job: ScheduleJob) => { - updateMutation.mutate({ - repoId: repoId!, - jobId: job.id, - data: { enabled: !job.enabled }, - }) - } - - const handleRunNow = (job: ScheduleJob) => { - runMutation.mutate({ repoId: repoId!, jobId: job.id }, { - onSuccess: (run) => { - selectRun(run.id) - }, - }) - } - - const handleCancelRun = () => { - const target = runId !== null ? runs?.find((run) => run.id === runId) ?? null : null - if (!target || target.status !== 'running') { - return - } - - cancelRunMutation.mutate({ - repoId: repoId!, - jobId: target.jobId, - runId: target.id, - }, { - onSuccess: (run) => { - selectRun(run.id) - }, - }) - } - - const handleCancelJobRun = (job: ScheduleJobWithRepo) => { - if (!job.lastRun) { - return - } - - cancelRunMutation.mutate({ - repoId: repoId!, - jobId: job.id, - runId: job.lastRun.id, - }) - } - - const handleClearHistory = (worktrees?: ScheduleRunWorktreesMode) => { - if (jobId === null) { - return - } - - clearRunsMutation.mutate({ repoId: repoId!, jobId, worktrees }, { - onSuccess: () => setClearRunsOpen(false), - }) - } - - const handleConfirmDeleteRun = (worktrees?: ScheduleRunWorktreesMode) => { - if (jobId === null || runToDelete === null) { - return - } - - deleteRunMutation.mutate({ repoId: repoId!, jobId, runId: runToDelete, worktrees }, { - onSuccess: () => setRunToDelete(null), - }) - } - - const handleSelectJob = (id: number) => { - selectJobAndView(id) - } + const backHref = getReturnToPath(location.search, scheduleTarget.backHref) return (
@@ -277,134 +60,7 @@ export function Schedules() {
-
- {!hasJobs ? ( -
- - -
- -
-
-

No schedules yet

-

Create a schedule for this repo to automate recurring agent work, then inspect runs, logs, and sessions here.

-
- -
-
-
- ) : ( - <> - {repoScheduleTab === 'jobs' && ( -
- -
-
- matchesScheduleJobSearch(job, jobSearch))} - showRepo={false} - selectedJobId={jobId} - onOpen={(job) => handleSelectJob(job.id)} - onRunNow={handleRunNow} - onToggleEnabled={handleToggleEnabled} - onEdit={(job) => openEditJob(job.id)} - onDelete={(job) => openDeleteJob(job.id)} - onCancelRun={handleCancelJobRun} - runPending={runMutation.isPending} - cancelPending={cancelRunMutation.isPending} - /> -
-
-
- )} - {repoScheduleTab === 'detail' && ( - openEditJob(job.id)} - onDelete={openDeleteJob} - onToggleEnabled={() => { if (selectedJob) handleToggleEnabled(selectedJob) }} - onRunNow={() => { if (selectedJob) handleRunNow(selectedJob) }} - updatePending={updateMutation.isPending} - runPending={runMutation.isPending} - runningRun={Boolean(runningRun)} - isJobFetching={isJobFetching} - /> - )} - {repoScheduleTab === 'runs' && ( - setClearRunsOpen(true)} - clearHistoryPending={clearRunsMutation.isPending} - onDeleteRun={(id) => setRunToDelete(id)} - deleteRunPending={deleteRunMutation.isPending} - /> - )} - - )} -
- - {hasJobs && ( -
- setScheduleTab(tab)} - /> -
- )} - - { - if (!open) closeDialog() - }} - job={editingJob} - repoId={repoId} - isSaving={createMutation.isPending || updateMutation.isPending} - onSubmit={dialog === 'edit' ? handleUpdate : handleCreate} - /> - - !open && closeDialog()} - onConfirm={handleDelete} - onCancel={() => closeDialog()} - title="Delete Schedule" - description={DELETE_SCHEDULE_DESCRIPTION} - isDeleting={deleteMutation.isPending} - /> - - !open && setClearRunsOpen(false)} - title="Clear run history" - description={ - <>This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone. - } - affectedWorktreeCount={affectedClearWorktreeCount} - isPending={clearRunsMutation.isPending} - onCancel={() => setClearRunsOpen(false)} - onConfirm={handleClearHistory} - /> - - !open && setRunToDelete(null)} - title="Delete run" - description="This permanently deletes this run. This cannot be undone." - affectedWorktreeCount={runToDeleteWorktreeCount} - isPending={deleteRunMutation.isPending} - onCancel={() => setRunToDelete(null)} - onConfirm={handleConfirmDeleteRun} - /> +
) } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 9adc03f1a..a007870c3 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -49,6 +49,7 @@ import type { PageCommandActions } from "@/lib/builtinCommands"; import { useRedoMessage, useUndoMessage } from "@/hooks/useUndoMessage"; import { usePermissions, useForms } from "@/contexts/EventContext"; import type { FormInfo, SessionMessageInfo } from "@opencode-manager/shared/opencode"; +import type { SkillFileInfo } from "@opencode-manager/shared"; import { formatOpenCodeModelRef } from "@opencode-manager/shared/opencode"; import { FormPrompt } from "@/components/session/FormPrompt"; import { MinimizedFormIndicator } from "@/components/session/MinimizedFormIndicator"; @@ -64,7 +65,9 @@ import { useTerminalDialogParam } from "@/hooks/useOpenTerminal"; import { SessionMoreButton } from "@/components/navigation/SessionMoreButton"; import { SideQuestionDialog } from "@/components/session/SideQuestionDialog"; import { SessionMessagePickerDialog } from "@/components/session/SessionMessagePickerDialog"; -import { ChangesWalkthroughDialog } from "@/components/session/ChangesWalkthroughDialog"; +import { ChangesWalkthroughSheet } from "@/components/session/ChangesWalkthroughSheet"; +import { ToolSidePanel } from "@/components/navigation/ToolSidePanel"; +import { useToolPanel } from "@/hooks/useToolPanel"; const OLDER_HISTORY_SCROLL_THRESHOLD_PX = 200 @@ -141,6 +144,8 @@ export function SessionDetail() { const [forkPickerLoading, setForkPickerLoading] = useState(false); const isMobile = useMobile(); + const docked = !isMobile; + const sidePanel = useToolPanel(docked); const { keyboardHeight } = useVisualViewport(); const inputBottomOffset = isMobile ? keyboardHeight : 0; const promptOverlayObserverRef = useRef(null); @@ -306,6 +311,7 @@ export function SessionDetail() { const handleShowMcpDialog = useCallback(() => setMcpDialogOpen(true), [setMcpDialogOpen]); const handleShowSkillsDialog = useCallback(() => setSkillsDialogOpen(true), [setSkillsDialogOpen]); const handleShowWalkthrough = useCallback(() => setWalkthroughOpen(true), [setWalkthroughOpen]); + const handleSkillLoaded = useCallback((skill: SkillFileInfo) => showToast.success(`Loaded skill: ${skill.name}`), []); const handleConnectProvider = useCallback(() => setSettingsTab('providers'), [setSettingsTab]); const handleMinimizeForm = useCallback((form: FormInfo) => { @@ -468,7 +474,7 @@ export function SessionDetail() { undo: handleUndo, redo: handleRedo, fork: openForkPicker, - toggleSidebar: () => setFileBrowserOpen(!fileBrowserOpen), + toggleSidebar: () => (docked ? sidePanel.toggleTool('files') : setFileBrowserOpen(!fileBrowserOpen)), toggleMode: () => { const modeButton = document.querySelector( "[data-toggle-mode]", @@ -508,10 +514,12 @@ export function SessionDetail() { } }, [sessionId, updateSessionAsync]); - const handleFileBrowserClose = useCallback(() => { - setFileBrowserOpen(false) - setSelectedFilePath(undefined) - }, [setFileBrowserOpen]); + const handleFileBrowserClose = useCallback(() => setFileBrowserOpen(false), [setFileBrowserOpen]); + + const filesVisible = fileBrowserOpen || sidePanel.activeTool === 'files'; + useEffect(() => { + if (!filesVisible) setSelectedFilePath(undefined); + }, [filesVisible]); const handleChildSessionClick = useCallback((childSessionId: string) => { navigate(`/repos/${repoId}/sessions/${childSessionId}${sessionRouteSuffix}`) @@ -714,7 +722,8 @@ export function SessionDetail() {
-
+
+
{repoLoading || sessionLoading || messagesLoading ? ( @@ -818,6 +827,20 @@ export function SessionDetail() {
)}
+ {docked && ( + + )} +
{/* Sessions Dialog */} @@ -865,7 +888,7 @@ export function SessionDetail() { )} showToast.success(`Loaded skill: ${skill.name}`)} + onSkillLoaded={handleSkillLoaded} /> )} {sessionId && ( - )} @@ -902,14 +925,14 @@ export function SessionDetail() { )} setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName={workspaceDisplayName} @@ -918,12 +941,12 @@ export function SessionDetail() { setTerminalOpen(false)} /> setPreviewOpen(false)} directory={sessionDirectory} /> diff --git a/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx b/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx index b5963c299..b3bedcfc8 100644 --- a/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx +++ b/frontend/src/pages/__tests__/AssistantRedirect.preview.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -129,20 +129,24 @@ describe('AssistantRedirect preview routing', () => { mocks.createPreviewSession.mockResolvedValue({ token: 'tok', previewPort: 5004, publicUrl: null }) }) - it('opens the preview panel when a terminal localhost link is followed', async () => { + it('opens preview in the docked panel when a terminal localhost link is followed', async () => { const user = userEvent.setup() renderAssistantRedirect() await user.click(await screen.findByRole('button', { name: 'local-link' })) - const iframe = await screen.findByTitle('Preview') + const iframe = await waitFor(() => { + const element = document.querySelector('iframe[title="Preview"]') + expect(element).not.toBeNull() + return element as HTMLIFrameElement + }) expect(iframe.getAttribute('src')).toContain('token=tok') expect(iframe.getAttribute('src')).toContain('path=%2Fdashboard') expect(mocks.createPreviewSession).toHaveBeenCalledWith(5173) expect(screen.queryByTestId('terminal-view')).not.toBeInTheDocument() const location = screen.getByTestId('location').textContent ?? '' - expect(location).toContain('dialog=preview') + expect(location).toContain('panel=preview') expect(location).toContain('previewPort=5173') expect(location).toContain('previewPath=%2Fdashboard') }) @@ -156,8 +160,8 @@ describe('AssistantRedirect preview routing', () => { expect(openSpy).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer') expect(mocks.createPreviewSession).not.toHaveBeenCalled() - expect(screen.queryByTitle('Preview')).not.toBeInTheDocument() + expect(document.querySelector('iframe[title="Preview"]')).toBeNull() expect(screen.getByTestId('terminal-view')).toBeInTheDocument() - expect(screen.getByTestId('location').textContent).toContain('dialog=terminal') + expect(screen.getByTestId('location').textContent).toContain('panel=terminal') }) }) diff --git a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx index 203b27e1e..cfc63b64f 100644 --- a/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx +++ b/frontend/src/pages/__tests__/RepoDetail.worktree-setup.test.tsx @@ -55,6 +55,9 @@ vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalPanel: ({ directory, isOpen }: { directory?: string; isOpen: boolean }) => (
), + TerminalWorkspace: ({ directory }: { directory?: string }) => ( +
+ ), })) vi.mock('@/components/session/SessionList', () => ({ SessionList: () => null })) @@ -147,9 +150,9 @@ describe('RepoDetail worktree setup', () => { expect(screen.getByTestId('location')).toHaveTextContent('repoTab=workspaces'), ) const location = screen.getByTestId('location').textContent ?? '' - expect(location).toContain('dialog=terminal') + expect(location).toContain('panel=terminal') expect(location).toContain('terminal=pty-new') - expect(screen.getByTestId('terminal-panel')).toHaveAttribute( + expect(await screen.findByTestId('terminal-workspace')).toHaveAttribute( 'data-directory', newWorkspaceDirectory, ) @@ -170,7 +173,8 @@ describe('RepoDetail worktree setup', () => { await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('repoTab=workspaces'), ) - expect(screen.getByTestId('location')).not.toHaveTextContent('dialog=terminal') + expect(screen.getByTestId('location')).not.toHaveTextContent('terminal') + expect(screen.queryByTestId('terminal-workspace')).not.toBeInTheDocument() expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-open', 'false') }) diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index 8e7bbbee7..3950cb99c 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -202,7 +202,11 @@ vi.mock('@/hooks/useSessionGoals', () => ({ useCancelSessionGoal: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), })) vi.mock('@/components/session/SideQuestionDialog', () => ({ SideQuestionDialog: vi.fn(() => null) })) -vi.mock('@/components/session/ChangesWalkthroughDialog', () => ({ ChangesWalkthroughDialog: vi.fn(() => null) })) +vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ ChangesWalkthroughSheet: vi.fn(() => null) })) +vi.mock('@/components/navigation/ToolSidePanel', () => ({ ToolSidePanel: vi.fn(() => null) })) +vi.mock('@/hooks/useToolPanel', () => ({ + useToolPanel: vi.fn(() => ({ activeTool: null, toggleTool: vi.fn(), closePanel: vi.fn() })), +})) vi.mock('@/components/session/SessionList', () => ({ SessionList: vi.fn(() => null) })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: vi.fn(() => null) })) vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpDialog: vi.fn(() => null) })) diff --git a/shared/src/schemas/change-walkthroughs.ts b/shared/src/schemas/change-walkthroughs.ts index b1ca94dbf..6aca878ca 100644 --- a/shared/src/schemas/change-walkthroughs.ts +++ b/shared/src/schemas/change-walkthroughs.ts @@ -43,10 +43,20 @@ export const ChangeWalkthroughSchema = z.object({ export type ChangeWalkthrough = z.infer; +export const WalkthroughGenerationErrorSchema = z.object({ + message: z.string(), + code: z.string().optional(), + details: z.unknown().optional(), +}); + +export type WalkthroughGenerationError = z.infer; + export const ChangeWalkthroughStateSchema = z.object({ walkthrough: ChangeWalkthroughSchema.nullable(), currentDiffHash: z.string().nullable(), stale: z.boolean(), + generating: z.boolean(), + error: WalkthroughGenerationErrorSchema.nullable(), }); export type ChangeWalkthroughState = z.infer; From a9331cb484f61bcab52e5101db0a8e2136699b60 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:18:01 -0400 Subject: [PATCH 2/4] refactor(mobile): group MoreDrawer items and remove command/file mention entries --- .../message/PromptInput.command.test.tsx | 11 +- .../message/PromptInput.goal.test.tsx | 3 - .../message/PromptInput.mention.test.tsx | 3 - .../message/PromptInput.stt.test.tsx | 30 --- .../src/components/message/PromptInput.tsx | 25 +-- .../components/navigation/MoreDrawer.test.tsx | 55 ++--- .../src/components/navigation/MoreDrawer.tsx | 212 ++++++++---------- frontend/src/pages/SessionDetail.tsx | 9 - .../__tests__/SessionDetail.commands.test.tsx | 2 +- .../SessionDetail.form-prompt.test.tsx | 2 +- .../SessionDetail.scroll-floating.test.tsx | 2 +- frontend/src/stores/uiStateStore.ts | 17 -- 12 files changed, 114 insertions(+), 257 deletions(-) diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index 3c49ba583..122ef04f9 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput, type PromptInputHandle } from './PromptInput' -import { useUIState } from '@/stores/uiStateStore' import { BUILTIN_COMMANDS } from '@/lib/builtinCommands' import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils' @@ -209,8 +208,6 @@ describe('PromptInput command submission', () => { mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) suggestionProps.isOpen = false suggestionProps.selectedIndex = 0 - useUIState.getState().clearPendingPromptCommand() - useUIState.getState().clearPendingPromptFile() }) afterEach(() => { @@ -269,13 +266,13 @@ describe('PromptInput command submission', () => { }) it('sends parsed command attachments and offsets to runCommand without injecting the selected agent', async () => { + mocks.useFileSearch.mockReturnValue({ files: ['src/App.tsx'] }) const { container } = renderComponent() - act(() => { - useUIState.getState().selectPromptFile('src/App.tsx') - }) - const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '@App' } }) + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => expect(input).toHaveValue('@App.tsx ')) await attachImage(container) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index 6268cdb56..9c9c3d1d2 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { useUIState } from '@/stores/uiStateStore' import { createCommandActionsMock, stubMatchMedia } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ @@ -194,8 +193,6 @@ describe('PromptInput goal mode', () => { mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: mocks.setAgent })) mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) - useUIState.getState().clearPendingPromptCommand() - useUIState.getState().clearPendingPromptFile() }) afterEach(() => { diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index b348fd4e1..22bc670af 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { useUIState } from '@/stores/uiStateStore' import { createCommandActionsMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ @@ -194,8 +193,6 @@ describe('PromptInput agent mention submission', () => { mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) suggestionProps.isOpen = false suggestionProps.selectedIndex = 0 - useUIState.getState().clearPendingPromptCommand() - useUIState.getState().clearPendingPromptFile() }) it('sends an @agent mention as an attachment without switching the session agent', async () => { diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index 7db04e553..b7a786f3c 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' -import { useUIState } from '@/stores/uiStateStore' import { createCommandActionsMock } from '@/test/test-utils' const createTestQueryClient = () => new QueryClient({ @@ -211,8 +210,6 @@ describe('PromptInput STT Gesture Tests', () => { mocks.useUserBash.mockImplementation((selector) => selector({ addUserBashCommand: vi.fn() })) mocks.useSessionAgentStore.mockImplementation((selector) => selector({ setAgent: mockSetAgent })) mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} })) - useUIState.getState().clearPendingPromptCommand() - useUIState.getState().clearPendingPromptFile() }) const renderComponent = (sttOverrides: Partial = {}) => { @@ -421,33 +418,6 @@ describe('PromptInput STT Gesture Tests', () => { expect(screen.getAllByTitle('Stop').length).toBeGreaterThan(0) }) - it('inserts a command selected from the mobile drawer', async () => { - renderComponent() - - act(() => { - useUIState.getState().selectPromptCommand({ - name: 'help', - description: 'Show help', - }) - }) - - await waitFor(() => { - expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('/help ') - }) - }) - - it('inserts a file selected from the mobile drawer', async () => { - renderComponent() - - act(() => { - useUIState.getState().selectPromptFile('src/App.tsx') - }) - - await waitFor(() => { - expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('@App.tsx ') - }) - }) - it('quick tap starts recording through click only', async () => { mockStartRecording.mockResolvedValue(true) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index a914e01bb..441211a41 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -10,7 +10,6 @@ import { useSTT } from '@/hooks/useSTT' import { useUserBash } from '@/stores/userBashStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' -import { useUIState } from '@/stores/uiStateStore' import { useSendErrorStore } from '@/stores/sendErrorStore' import { useRecentCommandsStore } from '@/stores/recentCommandsStore' import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' @@ -166,10 +165,6 @@ export const PromptInput = memo(forwardRef( const promptRef = useRef(prompt) const attachedFilesRef = useRef(attachedFiles) const imageAttachmentsRef = useRef(imageAttachments) - const pendingPromptCommand = useUIState((state) => state.pendingPromptCommand) - const pendingPromptFile = useUIState((state) => state.pendingPromptFile) - const clearPendingPromptCommand = useUIState((state) => state.clearPendingPromptCommand) - const clearPendingPromptFile = useUIState((state) => state.clearPendingPromptFile) const { isRecording, @@ -582,16 +577,10 @@ export const PromptInput = memo(forwardRef( handleSubmitRef.current() }, [prompt]) - useEffect(() => { - if (!pendingPromptCommand) return - handleCommandSelect(pendingPromptCommand.command) - clearPendingPromptCommand() - }, [pendingPromptCommand, handleCommandSelect, clearPendingPromptCommand]) - - const insertFileMention = useCallback((filePath: string, range: { start: number, end: number } | null = mentionRange) => { + const insertFileMention = useCallback((filePath: string, range: { start: number, end: number }) => { const filename = getFilename(filePath) - const beforeMention = range ? prompt.slice(0, range.start) : `${prompt}${prompt.trim() ? ' ' : ''}` - const afterMention = range ? prompt.slice(range.end) : '' + const beforeMention = prompt.slice(0, range.start) + const afterMention = prompt.slice(range.end) const newPrompt = beforeMention + '@' + filename + ' ' + afterMention setPrompt(newPrompt) @@ -612,13 +601,7 @@ export const PromptInput = memo(forwardRef( }) focusPromptAt(beforeMention.length + filename.length + 2) - }, [directory, mentionRange, prompt, focusPromptAt]) - - useEffect(() => { - if (!pendingPromptFile) return - insertFileMention(pendingPromptFile.path, null) - clearPendingPromptFile() - }, [pendingPromptFile, insertFileMention, clearPendingPromptFile]) + }, [directory, prompt, focusPromptAt]) const handleMentionSelect = (item: MentionItem) => { if (!mentionRange || !textareaRef.current) return diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index ebfd892bb..83d085d92 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -1,29 +1,17 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, within } from '@testing-library/react' import { beforeEach, describe, it, expect, vi } from 'vitest' import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MoreDrawer } from './MoreDrawer' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' -import { useCommands } from '@/hooks/useCommands' -import { useUIState } from '@/stores/uiStateStore' import { getRepo } from '@/api/repos' vi.mock('@/hooks/useAuth') vi.mock('@/hooks/useServerHealth') -vi.mock('@/hooks/useCommands') vi.mock('@/api/repos', () => ({ getRepo: vi.fn(), })) -vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ - FileBrowserSheet: ({ isOpen, basePath, onFileSelect }: { isOpen: boolean; basePath: string; onFileSelect: (file: { path: string }) => void }) => ( - isOpen ? ( -
- -
- ) : null - ), -})) vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { @@ -102,17 +90,6 @@ describe('MoreDrawer', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(useNavigate).mockReturnValue(vi.fn()) - vi.mocked(useCommands).mockReturnValue({ - commands: [{ name: 'help', description: 'Show help' }], - recentNames: [], - loading: false, - error: null, - searchCommands: vi.fn(), - findCommand: vi.fn(), - }) - useUIState.getState().clearPendingPromptCommand() - useUIState.getState().clearPendingPromptFile() - useUIState.getState().setActivePromptFileBasePath(null) vi.mocked(getRepo).mockResolvedValue({ id: 1, localPath: 'wrong-repo', @@ -192,33 +169,27 @@ describe('MoreDrawer', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument() }) - it('shows session commands and selects a command', () => { + it('leaves commands and file mentions to the prompt input', () => { mockAuth() mockServerHealth() - const handleClose = vi.fn() - renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1', routePath: '/repos/:id/sessions/:sessionId', onClose: handleClose }) - - fireEvent.click(screen.getByText('Commands')) - expect(screen.queryByText('/help')).not.toBeInTheDocument() - fireEvent.click(screen.getByText('help')) + renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1', routePath: '/repos/:id/sessions/:sessionId' }) - expect(useUIState.getState().pendingPromptCommand?.command.name).toBe('help') - expect(handleClose).toHaveBeenCalled() + expect(screen.queryByText('Commands')).not.toBeInTheDocument() + expect(screen.queryByText('Mention File')).not.toBeInTheDocument() }) - it('opens file browser and selects a file mention', () => { + it('groups session items into workspace and project sections', () => { mockAuth() mockServerHealth() - const handleClose = vi.fn() - useUIState.getState().setActivePromptFileBasePath('repo') - renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1', routePath: '/repos/:id/sessions/:sessionId', onClose: handleClose }) + renderMoreDrawer({ initialEntry: '/repos/1/sessions/session-1', routePath: '/repos/:id/sessions/:sessionId' }) - fireEvent.click(screen.getByText('Mention File')) - expect(screen.getByTestId('mention-file-browser')).toHaveAttribute('data-base-path', 'repo') - fireEvent.click(screen.getByText('App.tsx')) + const sectionLabels = (name: string) => + within(screen.getByRole('region', { name })).getAllByRole('button').map((button) => button.textContent) - expect(useUIState.getState().pendingPromptFile?.path).toBe('src/App.tsx') - expect(handleClose).toHaveBeenCalled() + expect(sectionLabels('Workspace')).toEqual(['Files', 'Source Control', 'Terminal', 'Walkthrough', 'Preview']) + expect(sectionLabels('Project')).toEqual(['MCP', 'Skills', 'Reset Permissions', 'Schedules', 'Actions']) + expect(screen.getAllByText('Settings')).toHaveLength(1) + expect(screen.getAllByText('Logout')).toHaveLength(1) }) it('shows Assistant instead of the source repo on assistant routes', () => { diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 70433cf46..965fdb766 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -1,21 +1,17 @@ import { useNavigate, useLocation, useParams } from 'react-router-dom' -import { useState, useRef, useEffect } from 'react' -import { ChevronDown, ChevronRight, Command as CommandIcon, FileText, FolderGit2, X, GitBranch } from 'lucide-react' +import { useRef, useEffect, type ReactNode } from 'react' +import { ChevronRight, FolderGit2, X, GitBranch, type LucideIcon } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' -import { useCommands } from '@/hooks/useCommands' import { useUrlParams } from '@/hooks/useUrlParams' -import { useUIState } from '@/stores/uiStateStore' import { useQuery } from '@tanstack/react-query' import { getRepo } from '@/api/repos' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { SideDrawer, SideDrawerContent } from '@/components/ui/side-drawer' -import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' -import { buildMoreItems, buildNavModel, isSessionDetailPath } from './moreDrawerItems' +import { buildMoreItems, buildNavModel, isSessionDetailPath, type MoreDrawerItem } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' -import { getRepoDisplayName } from '@/lib/utils' +import { cn, getRepoDisplayName } from '@/lib/utils' import { getPathWithReturnTo, isAssistantPath } from '@/lib/navigation' -import type { CommandInfo } from '@opencode-manager/shared/opencode' import { useMobileTabBar } from '@/hooks/useMobileTabBar' interface MoreDrawerProps { @@ -23,13 +19,54 @@ interface MoreDrawerProps { onClose: () => void } +const ACCOUNT_ITEM_KEYS: ReadonlySet = new Set(['settings', 'logout']) + +const ITEM_GROUPS: ReadonlyArray<{ label: string; keys: ReadonlySet }> = [ + { label: 'Workspace', keys: new Set(['files', 'source-control', 'terminal', 'preview', 'walkthrough']) }, + { label: 'Project', keys: new Set(['mcp', 'skills', 'actions', 'schedules', 'all-schedules', 'reset-permissions']) }, +] + +function MenuSection({ label, children }: { label?: string; children: ReactNode }) { + return ( +
+ {label && ( +

{label}

+ )} + {children} +
+ ) +} + +interface MenuRowProps { + icon: LucideIcon + label: string + onClick: () => void + danger?: boolean + trailing?: boolean +} + +function MenuRow({ icon: Icon, label, onClick, danger, trailing }: MenuRowProps) { + return ( + + ) +} + export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const navigate = useNavigate() const location = useLocation() const { id } = useParams<{ id: string }>() const repoId = id ? Number(id) : null - const [commandsOpen, setCommandsOpen] = useState(false) - const [mentionFileBrowserOpen, setMentionFileBrowserOpen] = useState(false) const swipeRef = useRef(null) const { bind } = useSwipeBack(onClose, { enabled: isOpen, suspendsRouteSwipe: true }) const { searchParams, updateParams } = useUrlParams() @@ -38,10 +75,6 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const isSessionDetail = isSessionDetailPath(location.pathname) const isAssistantRoute = isAssistantPath(location.pathname) const isAssistantSession = isSessionDetail && searchParams.get('assistant') === '1' - const { commands } = useCommands({ enabled: isSessionDetail }) - const activePromptFileBasePath = useUIState((state) => state.activePromptFileBasePath) - const selectPromptCommand = useUIState((state) => state.selectPromptCommand) - const selectPromptFile = useUIState((state) => state.selectPromptFile) const { open: openMobileSheet } = useMobileTabBar() useEffect(() => { @@ -80,7 +113,15 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { } } - const handleItemClick = (item: ReturnType[0]) => { + const handleItemClick = (item: MoreDrawerItem) => { + if (item.key === 'settings') { + handleSettingsClick() + return + } + if (item.key === 'logout') { + void handleLogoutClick() + return + } if (item.to) { const to = item.key === 'schedules' ? getPathWithReturnTo(item.to, `${location.pathname}${location.search}`) @@ -94,30 +135,10 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { } } - const handleCommandClick = (command: CommandInfo) => { - selectPromptCommand(command) - onClose() - } - - const getPromptFilePath = (path: string) => { - if (!activePromptFileBasePath) return path - - const normalizedPath = path.replace(/^\.\//, '') - const normalizedBasePath = activePromptFileBasePath.replace(/^\.\//, '').replace(/\/+$/, '') - const basePrefix = `${normalizedBasePath}/` - - return normalizedPath.startsWith(basePrefix) - ? normalizedPath.slice(basePrefix.length) - : normalizedPath - } - - const handleFileClick = (path: string) => { - selectPromptFile(getPromptFilePath(path)) - setMentionFileBrowserOpen(false) - onClose() - } - const items = buildMoreItems(location.pathname) + const itemsInGroup = (keys: ReadonlySet) => items.filter((item) => keys.has(item.key)) + const groupedKeys = new Set([...ACCOUNT_ITEM_KEYS, ...ITEM_GROUPS.flatMap(({ keys }) => [...keys])]) + const navigationItems = items.filter((item) => !groupedKeys.has(item.key)) const assistantCta = isSessionDetail && !isAssistantSession ? buildNavModel(location.pathname).primary.find((cta) => cta.key === 'assistant') : undefined @@ -161,97 +182,44 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) {
)}
- - {isSessionDetail && ( -
- - {assistantCta?.to && ( - - )} - - {commandsOpen && ( -
- {commands.map((command) => ( - - ))} -
- )} - -
- )} - {items.map((item) => ( + + + {isSessionDetail && ( + openMobileSheet('repos')} trailing /> + )} + {assistantCta?.to && ( + navigate(assistantCta.to!)} /> + )} + {navigationItems.map((item) => ( + handleItemClick(item)} /> + ))} + + {ITEM_GROUPS.map(({ label, keys }) => { + const groupItems = itemsInGroup(keys) + if (groupItems.length === 0) return null + return ( + + {groupItems.map((item) => ( + handleItemClick(item)} /> + ))} + + ) + })} + +
+ {itemsInGroup(ACCOUNT_ITEM_KEYS).map((item) => ( ))} - +
- setMentionFileBrowserOpen(false)} - basePath={activePromptFileBasePath ?? ''} - onFileSelect={(file) => handleFileClick(file.path)} - /> ) } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index a007870c3..954918ce9 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -272,7 +272,6 @@ export function SessionDetail() { const { modelRef } = useModelSelection(sessionDirectory, modelSelectionSession); const setSessionStatus = useSessionStatus((state) => state.setStatus); const isEditingMessage = useUIState((state) => state.isEditingMessage); - const setActivePromptFileBasePath = useUIState((state) => state.setActivePromptFileBasePath); const { isEnabled: ttsEnabled } = useTTS(); const { syncForSession: syncPermissionsForSession } = usePermissions(); const { getForSession: getFormForSession, reply: replyToForm, cancel: cancelForm, syncForSession: syncFormsForSession } = useForms(); @@ -292,14 +291,6 @@ export function SessionDetail() { const isStreamingResponse = hasIncompleteMessages && isSessionActive; const workspaceBasePath = repo?.localPath; - useEffect(() => { - setActivePromptFileBasePath(sessionDirectory ? workspaceBasePath ?? null : null) - - return () => { - setActivePromptFileBasePath(null) - } - }, [sessionDirectory, setActivePromptFileBasePath, workspaceBasePath]) - useAutoPlayLastResponse({ sessionId: sessionId ?? '', lastAssistantMessage, diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index 3950cb99c..2f772ede0 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -147,7 +147,7 @@ vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ vi.mock('@/stores/uiStateStore', () => ({ useUIState: vi.fn((selector?: (state: Record) => unknown) => typeof selector === 'function' - ? selector({ isEditingMessage: false, setActivePromptFileBasePath: vi.fn() }) + ? selector({ isEditingMessage: false }) : false ), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx index 46d539ab3..6926575d3 100644 --- a/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.form-prompt.test.tsx @@ -107,7 +107,7 @@ vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ vi.mock('@/stores/uiStateStore', () => ({ useUIState: vi.fn((selector?: (state: Record) => unknown) => typeof selector === 'function' - ? selector({ isEditingMessage: false, setActivePromptFileBasePath: vi.fn() }) + ? selector({ isEditingMessage: false }) : false ), })) diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx index b586ef39d..761323a78 100644 --- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx @@ -102,7 +102,7 @@ vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ vi.mock('@/stores/uiStateStore', () => ({ useUIState: vi.fn((selector?: (state: Record) => unknown) => typeof selector === 'function' - ? selector({ isEditingMessage: mocks.uiState.isEditingMessage, setActivePromptFileBasePath: vi.fn() }) + ? selector({ isEditingMessage: mocks.uiState.isEditingMessage }) : false ), })) diff --git a/frontend/src/stores/uiStateStore.ts b/frontend/src/stores/uiStateStore.ts index 73328ca3b..30e4e255d 100644 --- a/frontend/src/stores/uiStateStore.ts +++ b/frontend/src/stores/uiStateStore.ts @@ -1,28 +1,11 @@ import { create } from 'zustand' -import type { CommandInfo } from '@opencode-manager/shared/opencode' interface UIStateStore { isEditingMessage: boolean - activePromptFileBasePath: string | null - pendingPromptCommand: { id: number; command: CommandInfo } | null - pendingPromptFile: { id: number; path: string } | null setIsEditingMessage: (isEditing: boolean) => void - setActivePromptFileBasePath: (basePath: string | null) => void - selectPromptCommand: (command: CommandInfo) => void - clearPendingPromptCommand: () => void - selectPromptFile: (path: string) => void - clearPendingPromptFile: () => void } export const useUIState = create((set) => ({ isEditingMessage: false, - activePromptFileBasePath: null, - pendingPromptCommand: null, - pendingPromptFile: null, setIsEditingMessage: (isEditing: boolean) => set({ isEditingMessage: isEditing }), - setActivePromptFileBasePath: (basePath: string | null) => set({ activePromptFileBasePath: basePath }), - selectPromptCommand: (command: CommandInfo) => set({ pendingPromptCommand: { id: Date.now(), command } }), - clearPendingPromptCommand: () => set({ pendingPromptCommand: null }), - selectPromptFile: (path: string) => set({ pendingPromptFile: { id: Date.now(), path } }), - clearPendingPromptFile: () => set({ pendingPromptFile: null }), })) From cbdaf164d9974a7d77c15ee44024d670e625ca13 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:39:09 -0400 Subject: [PATCH 3/4] fix(generate-text): resolve default model from global config catalog --- .../src/services/opencode/generate-text.ts | 22 +++++-- backend/test/helpers/stub-opencode-client.ts | 12 ++++ .../test/routes/change-walkthroughs.test.ts | 2 + backend/test/routes/repo-git.test.ts | 4 +- .../test/services/change-walkthroughs.test.ts | 2 + .../services/opencode/generate-text.test.ts | 64 ++++++++++++++++--- shared/src/config/env.ts | 3 +- 7 files changed, 95 insertions(+), 14 deletions(-) diff --git a/backend/src/services/opencode/generate-text.ts b/backend/src/services/opencode/generate-text.ts index 7308b89b3..8c2c8fe87 100644 --- a/backend/src/services/opencode/generate-text.ts +++ b/backend/src/services/opencode/generate-text.ts @@ -1,5 +1,7 @@ import type { ModelRef } from '@opencode-manager/shared/opencode' +import { getOpenCodeGlobalConfigPath } from '@opencode-manager/shared/config/env' import type { OpenCodeClient } from './client' +import { resolveOpenCodeModel } from '../opencode-models' export class GenerateTextTimeoutError extends Error { constructor() { @@ -23,12 +25,24 @@ export async function generateTextWithTimeout( }) try { - const { text } = await Promise.race([ - client.api.generate.text(input, { signal: controller.signal }), - timeout, - ]) + const { text } = await Promise.race([generateText(client, input, controller.signal), timeout]) return text } finally { if (timer) clearTimeout(timer) } } + +async function generateText( + client: OpenCodeClient, + input: { prompt: string; model?: ModelRef }, + signal: AbortSignal, +): Promise<{ text: string }> { + const model = input.model ?? await resolveGenerateModel(client, signal) + return client.api.generate.text({ prompt: input.prompt, model }, { signal }) +} + +/** OpenCode serves generation from its global config location, so the model must be resolved there once its catalog has loaded. */ +async function resolveGenerateModel(client: OpenCodeClient, signal: AbortSignal): Promise { + const { providerID, id, variant } = await resolveOpenCodeModel(client, getOpenCodeGlobalConfigPath(), { signal }) + return variant ? { providerID, id, variant } : { providerID, id } +} diff --git a/backend/test/helpers/stub-opencode-client.ts b/backend/test/helpers/stub-opencode-client.ts index 89268a2ec..c3a8f461c 100644 --- a/backend/test/helpers/stub-opencode-client.ts +++ b/backend/test/helpers/stub-opencode-client.ts @@ -2,6 +2,18 @@ import { vi } from 'vitest' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' +/** OpenCode API stubs for a catalog that has finished loading, so model resolution succeeds on the first poll. */ +export function stubLoadedModelCatalog() { + const model = { providerID: 'openai', id: 'gpt-5-mini', enabled: true } + return { + config: { get: vi.fn(async () => []) }, + model: { + list: vi.fn(async () => ({ data: [model] })), + default: vi.fn(async () => ({ data: model })), + }, + } +} + export function createStubOpenCodeClient(overrides: Partial = {}): OpenCodeClient { return { api: { diff --git a/backend/test/routes/change-walkthroughs.test.ts b/backend/test/routes/change-walkthroughs.test.ts index 24bff31a4..6b6ac8272 100644 --- a/backend/test/routes/change-walkthroughs.test.ts +++ b/backend/test/routes/change-walkthroughs.test.ts @@ -7,6 +7,7 @@ import { allMigrations } from '../../src/db/migrations' import { ChangeWalkthroughService } from '../../src/services/change-walkthroughs' import { createChangeWalkthroughRoutes } from '../../src/routes/change-walkthroughs' import type { OpenCodeClient } from '../../src/services/opencode/client' +import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client' const SESSION_ID = 'ses_walkthrough' @@ -32,6 +33,7 @@ function createFakeClient(sessions: Record) { const client = { api: { + ...stubLoadedModelCatalog(), session: { get: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] diff --git a/backend/test/routes/repo-git.test.ts b/backend/test/routes/repo-git.test.ts index 0ffa43628..781e180a7 100644 --- a/backend/test/routes/repo-git.test.ts +++ b/backend/test/routes/repo-git.test.ts @@ -4,6 +4,7 @@ import type { Database } from 'bun:sqlite' import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import { createRepoGitRoutes } from '../../src/routes/repo-git' +import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client' import { createGitService, GitService } from '../../src/services/git/GitService' import * as db from '../../src/db/queries' @@ -33,6 +34,7 @@ vi.mock('../../src/utils/process', () => ({ vi.mock('@opencode-manager/shared/config/env', () => ({ getReposPath: vi.fn(() => '/repos'), getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), + getOpenCodeGlobalConfigPath: vi.fn(() => '/tmp/test-workspace/.config/opencode'), ENV: { OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, SERVER: { PORT: 5001, HOST: '0.0.0.0', CORS_ORIGIN: '*', NODE_ENV: 'test' }, @@ -89,7 +91,7 @@ describe('Repo Git Routes', () => { } as unknown as GitAuthService generateTextMock = vi.fn() mockOpenCodeClient = { - api: { generate: { text: generateTextMock } }, + api: { ...stubLoadedModelCatalog(), generate: { text: generateTextMock } }, } as unknown as OpenCodeClient app = createRepoGitRoutes( mockDatabase, diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index e9ef40450..80d3c2f14 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -13,6 +13,7 @@ import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { SSEEvent } from '../../src/services/sse-aggregator' +import { stubLoadedModelCatalog } from '../helpers/stub-opencode-client' import { ChangeWalkthroughError, ChangeWalkthroughService, @@ -58,6 +59,7 @@ function createFakeClient(sessions: Record) { const client = { api: { + ...stubLoadedModelCatalog(), session: { get: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] diff --git a/backend/test/services/opencode/generate-text.test.ts b/backend/test/services/opencode/generate-text.test.ts index 7fdafdf22..bd0cc711e 100644 --- a/backend/test/services/opencode/generate-text.test.ts +++ b/backend/test/services/opencode/generate-text.test.ts @@ -1,20 +1,40 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import type { ModelRef } from '@opencode-manager/shared/opencode' +import { getOpenCodeGlobalConfigPath } from '@opencode-manager/shared/config/env' +import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../../src/services/opencode/client' import { GenerateTextTimeoutError, generateTextWithTimeout } from '../../../src/services/opencode/generate-text' +import { MODEL_LOAD_POLL_MS } from '../../../src/services/opencode-models' interface GenerateCall { input: { prompt: string; model?: ModelRef } signal: AbortSignal | undefined } -function makeClient(impl: (call: GenerateCall) => Promise<{ text: string }>): { +const CATALOG_MODEL = { providerID: 'openai', id: 'gpt-5-mini', enabled: true } as ModelInfo + +function makeClient( + impl: (call: GenerateCall) => Promise<{ text: string }>, + catalogs: ModelInfo[][] = [[CATALOG_MODEL]], +): { client: OpenCodeClient calls: GenerateCall[] + modelList: ReturnType } { const calls: GenerateCall[] = [] + let listCall = 0 + const catalogAt = () => catalogs[Math.min(listCall, catalogs.length - 1)] ?? [] + const modelList = vi.fn(async () => { + const data = catalogAt() + listCall += 1 + return { data } + }) const client = { api: { + config: { get: vi.fn(async () => []) }, + model: { + list: modelList, + default: vi.fn(async () => ({ data: catalogs[Math.min(listCall - 1, catalogs.length - 1)]?.[0] ?? null })), + }, generate: { text: async (input: GenerateCall['input'], options?: { signal?: AbortSignal }) => { const call: GenerateCall = { input, signal: options?.signal } @@ -25,7 +45,7 @@ function makeClient(impl: (call: GenerateCall) => Promise<{ text: string }>): { }, } as unknown as OpenCodeClient - return { client, calls } + return { client, calls, modelList } } describe('generateTextWithTimeout', () => { @@ -33,23 +53,38 @@ describe('generateTextWithTimeout', () => { vi.useRealTimers() }) - it('returns the generated text and passes the prompt with an abort signal', async () => { - const { client, calls } = makeClient(async () => ({ text: 'hello' })) + it('resolves a model from the global config catalog when none is provided', async () => { + const { client, calls, modelList } = makeClient(async () => ({ text: 'hello' })) await expect(generateTextWithTimeout(client, { prompt: 'write' }, 1000)).resolves.toBe('hello') + expect(modelList).toHaveBeenCalledWith({ location: { directory: getOpenCodeGlobalConfigPath() } }, expect.anything()) expect(calls).toHaveLength(1) - expect(calls[0]?.input).toEqual({ prompt: 'write' }) + expect(calls[0]?.input).toEqual({ prompt: 'write', model: { providerID: 'openai', id: 'gpt-5-mini' } }) expect(calls[0]?.signal).toBeInstanceOf(AbortSignal) }) - it('forwards the model when provided', async () => { - const { client, calls } = makeClient(async () => ({ text: 'hello' })) + it('waits for the model catalog to load before generating', async () => { + vi.useFakeTimers() + const { client, calls } = makeClient(async () => ({ text: 'hello' }), [[], [CATALOG_MODEL]]) + + const promise = generateTextWithTimeout(client, { prompt: 'write' }, 10_000) + await vi.advanceTimersByTimeAsync(0) + expect(calls).toHaveLength(0) + + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS) + await expect(promise).resolves.toBe('hello') + expect(calls[0]?.input.model).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) + }) + + it('forwards the model when provided without reading the catalog', async () => { + const { client, calls, modelList } = makeClient(async () => ({ text: 'hello' })) const model: ModelRef = { providerID: 'anthropic', id: 'claude-sonnet-4' } await generateTextWithTimeout(client, { prompt: 'write', model }, 1000) expect(calls[0]?.input).toEqual({ prompt: 'write', model }) + expect(modelList).not.toHaveBeenCalled() }) it('rejects with GenerateTextTimeoutError and aborts the request after the timeout', async () => { @@ -65,6 +100,19 @@ describe('generateTextWithTimeout', () => { expect(calls[0]?.signal?.aborted).toBe(true) }) + it('times out while the model catalog is still loading', async () => { + vi.useFakeTimers() + const { client, calls } = makeClient(async () => ({ text: 'never' }), [[]]) + + const promise = generateTextWithTimeout(client, { prompt: 'write' }, 50) + const assertion = expect(promise).rejects.toBeInstanceOf(GenerateTextTimeoutError) + + await vi.advanceTimersByTimeAsync(50) + await assertion + + expect(calls).toHaveLength(0) + }) + it('propagates a client error without waiting for the timeout', async () => { const { client } = makeClient(async () => { throw new Error('model unavailable') diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index 31d4e24d9..cea888754 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -153,7 +153,8 @@ export const getAssistantOpenCodeDir = () => path.join(getAssistantModePath(), A export const getOpenCodeConfigHome = () => path.join(ENV.WORKSPACE.BASE_PATH, '.config') export const getOpenCodeStateHome = () => path.join(ENV.WORKSPACE.BASE_PATH, '.opencode', 'state') export const getOpenCodeHealthWatchPath = () => path.join(getOpenCodeStateHome(), 'health-watch') -export const getOpenCodeGlobalSkillsPath = () => path.join(getOpenCodeConfigHome(), 'opencode', 'skills') +export const getOpenCodeGlobalConfigPath = () => path.join(getOpenCodeConfigHome(), 'opencode') +export const getOpenCodeGlobalSkillsPath = () => path.join(getOpenCodeGlobalConfigPath(), 'skills') export const getOpenCodeDataPath = () => path.join(getOpenCodeStateHome(), 'opencode') export const getOpenCodeModelStatePath = () => path.join(getOpenCodeDataPath(), 'model.json') export const getOpenCodeToolOutputPath = () => path.join(getOpenCodeDataPath(), 'tool-output') From 09630a779410a1030cd5c1175126762ba25769a0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:57:29 -0400 Subject: [PATCH 4/4] feat(walkthrough): dock change walkthrough in the desktop tool panel Share walkthrough state through a context provider so navigation and regenerate controls render in the panel chrome, generate walkthroughs with the session's selected model, and dedupe a favorite that is also the default model in model sections. --- backend/src/services/change-walkthroughs.ts | 6 +- .../test/services/change-walkthroughs.test.ts | 28 +- .../model/ModelQuickSelect.test.tsx | 18 +- .../navigation/ToolSidePanel.test.tsx | 8 +- .../components/navigation/ToolSidePanel.tsx | 62 +++- .../session/ChangesWalkthroughSheet.test.tsx | 16 +- .../session/ChangesWalkthroughSheet.tsx | 349 ++++++++++++------ frontend/src/lib/modelSections.test.ts | 59 ++- frontend/src/lib/modelSections.ts | 41 +- 9 files changed, 425 insertions(+), 162 deletions(-) diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts index e1ee6046e..6ea59169b 100644 --- a/backend/src/services/change-walkthroughs.ts +++ b/backend/src/services/change-walkthroughs.ts @@ -374,7 +374,11 @@ export class ChangeWalkthroughService { let responseText: string try { - responseText = await generateTextWithTimeout(this.openCodeClient, { prompt }, this.timeoutMs) + responseText = await generateTextWithTimeout( + this.openCodeClient, + { prompt, model: session.model }, + this.timeoutMs, + ) } catch (error) { if (error instanceof GenerateTextTimeoutError) { throw new ChangeWalkthroughError('Generating the change walkthrough timed out', 504, { diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index 80d3c2f14..7da63d8f2 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' -import type { FileDiffInfo, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { FileDiffInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' import { WALKTHROUGH_DIFF_MAX_CHARS, WALKTHROUGH_HUNK_MAX_CHARS, @@ -55,6 +55,7 @@ interface FakeSession { function createFakeClient(sessions: Record) { const generateCalls: string[] = [] + const generateModels: Array = [] let generateImpl: () => Promise = async () => modelReply([]) const client = { @@ -88,8 +89,9 @@ function createFakeClient(sessions: Record) { }), }, generate: { - text: vi.fn(async (input: { prompt: string }) => { + text: vi.fn(async (input: { prompt: string; model?: ModelRef }) => { generateCalls.push(input.prompt) + generateModels.push(input.model) return { text: await generateImpl() } }), }, @@ -100,6 +102,7 @@ function createFakeClient(sessions: Record) { return { client, generateCalls, + generateModels, setGenerateImpl: (impl: () => Promise) => { generateImpl = impl }, @@ -440,6 +443,27 @@ describe('ChangeWalkthroughService', () => { expect(fake.generateCalls).toHaveLength(2) }) + it('generates with the session selected model', async () => { + sessions[SESSION_ID]!.info = { + id: SESSION_ID, + title: 'Title', + model: { providerID: 'anthropic', id: 'claude-sonnet-4' }, + } as SessionInfo + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + + await service.generate(SESSION_ID, {}) + + expect(fake.generateModels[0]).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4' }) + }) + + it('falls back to the resolved default when the session has no model', async () => { + fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: ['f0h0', 'f0h1', 'f1h0'] }])) + + await service.generate(SESSION_ID, {}) + + expect(fake.generateModels[0]).toEqual({ providerID: 'openai', id: 'gpt-5-mini' }) + }) + it('coalesces concurrent generation into one model call', async () => { let resolveGenerate: (text: string) => void = () => {} const gate = new Promise((resolve) => { diff --git a/frontend/src/components/model/ModelQuickSelect.test.tsx b/frontend/src/components/model/ModelQuickSelect.test.tsx index 87dbd9f1e..3ceed76f8 100644 --- a/frontend/src/components/model/ModelQuickSelect.test.tsx +++ b/frontend/src/components/model/ModelQuickSelect.test.tsx @@ -70,6 +70,7 @@ interface SelectionOptions { recent?: Selection[] model?: Selection | null info?: ModelInfo + defaultModel?: string } function setModelSelection(options: SelectionOptions = {}) { @@ -99,8 +100,8 @@ function setModelSelection(options: SelectionOptions = {}) { mocks.useModelSections.mockReturnValue({ providers, - sections: buildModelSections(providers, modelState), - defaultModel: null, + sections: buildModelSections(providers, modelState, options.defaultModel), + defaultModel: options.defaultModel ?? null, modelState, isLoading: false, }) @@ -224,6 +225,19 @@ describe('ModelQuickSelect quick view', () => { expect(section.textContent!.indexOf('Claude Sonnet 4')).toBeLessThan(section.textContent!.indexOf('GPT-5')) }) + it('keeps a favorite listed when it is also the default model', () => { + setModelSelection({ + favorite: [selection('anthropic', 'claude-sonnet-4'), selection('openai', 'gpt-5')], + defaultModel: 'openai/gpt-5', + }) + + render() + + const section = sectionFor('Favorites') + expect(section.textContent).toContain('Claude Sonnet 4') + expect(section.textContent).toContain('GPT-5') + }) + it('lists the valid recents that are not favorites', () => { setModelSelection({ favorite: [selection('anthropic', 'claude-sonnet-4')], diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index f2570e6b4..02db7bcfc 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' import { MemoryRouter, useLocation } from 'react-router-dom' import { ToolSidePanel } from './ToolSidePanel' import { useToolPanel } from '@/hooks/useToolPanel' @@ -22,7 +23,10 @@ vi.mock('@/components/repo/RepoMcpDialog', () => ({ RepoMcpContent: () =>
m vi.mock('@/components/repo/RepoActionsDialog', () => ({ RepoActionsContent: () =>
actions-tool
})) vi.mock('@/components/repo/RepoSkillsDialog', () => ({ RepoSkillsContent: () =>
skills-tool
})) vi.mock('@/components/session/ChangesWalkthroughSheet', () => ({ - ChangesWalkthroughView: ({ sessionId }: { sessionId: string }) =>
walkthrough-tool {sessionId}
, + ChangesWalkthroughProvider: ({ children }: { children: ReactNode }) => <>{children}, + ChangesWalkthroughNav: () => null, + ChangesWalkthroughRegenerate: () => null, + ChangesWalkthroughView: () =>
walkthrough-tool
, })) function LocationProbe() { @@ -108,7 +112,7 @@ describe('ToolSidePanel', () => { it('moves a panel tool opened as a dialog into the docked panel', async () => { renderAt('?dialog=walkthrough') - expect(await screen.findByText('walkthrough-tool ses_1')).toBeInTheDocument() + expect(await screen.findByText('walkthrough-tool')).toBeInTheDocument() expect(search().get('panel')).toBe('walkthrough') expect(search().has('dialog')).toBe(false) }) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index 87e2657ab..be80d5dbd 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -3,7 +3,12 @@ import { useLocation } from 'react-router-dom' import { FileBrowser } from '@/components/file-browser/FileBrowser' import { SourceControlContent } from '@/components/source-control' import { TerminalWorkspace } from '@/components/terminal/TerminalPanel' -import { ChangesWalkthroughView } from '@/components/session/ChangesWalkthroughSheet' +import { + ChangesWalkthroughNav, + ChangesWalkthroughProvider, + ChangesWalkthroughRegenerate, + ChangesWalkthroughView, +} from '@/components/session/ChangesWalkthroughSheet' import { PreviewWorkspace } from '@/components/preview/PreviewPanel' import { RepoMcpContent } from '@/components/repo/RepoMcpDialog' import { RepoActionsContent } from '@/components/repo/RepoActionsDialog' @@ -110,7 +115,7 @@ export function ToolSidePanel({ } if (tool === 'preview') return if (tool === 'mcp') return repoDirectory ? : null - if (tool === 'walkthrough') return sessionId ? : null + if (tool === 'walkthrough') return sessionId ? : null if (repoId === undefined) return null switch (tool) { case 'sourceControl': @@ -137,26 +142,43 @@ export function ToolSidePanel({ } } + const panelContent = + activeTool && activeLabel ? ( + + ) : null + return ( <> - {activeTool && activeLabel ? ( - + {panelContent ? ( + activeTool === 'walkthrough' && sessionId ? ( + + {panelContent} + + ) : ( + panelContent + ) ) : null} diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx index 5d7dd5f71..221a26cf8 100644 --- a/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx @@ -137,7 +137,21 @@ describe('ChangesWalkthroughSheet', () => { await screen.findByText('Changes have been updated since this walkthrough was generated'), ).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: /regenerate/i })) + await user.click(screen.getByRole('button', { name: 'Regenerate' })) + + await waitFor(() => { + expect(mocks.generateChangeWalkthrough).toHaveBeenCalledWith('ses_1', { regenerate: true }) + }) + }) + + it('regenerates from the header without a stale walkthrough', async () => { + const user = userEvent.setup() + mocks.getChangeWalkthrough.mockResolvedValue(state()) + mocks.generateChangeWalkthrough.mockResolvedValue(state()) + renderSheet() + + await screen.findByText('Stop 1 of 2') + await user.click(screen.getByRole('button', { name: 'Regenerate walkthrough' })) await waitFor(() => { expect(mocks.generateChangeWalkthrough).toHaveBeenCalledWith('ses_1', { regenerate: true }) diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.tsx index 2c7a7ef75..44ac695ba 100644 --- a/frontend/src/components/session/ChangesWalkthroughSheet.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.tsx @@ -1,4 +1,5 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createContext, memo, useCallback, useContext, useEffect, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' import { ChevronLeft, ChevronRight, Loader2, RefreshCw } from 'lucide-react' import { SideDrawer, SideDrawerHeader } from '@/components/ui/side-drawer' import { Button } from '@/components/ui/button' @@ -9,7 +10,12 @@ import { useChangeWalkthrough, useGenerateChangeWalkthrough } from '@/hooks/useC import { GIT_STATUS_COLORS, GIT_STATUS_LABELS } from '@/lib/git-status-styles' import { cn } from '@/lib/utils' import { WalkthroughOmittedFileSchema } from '@opencode-manager/shared/schemas' -import type { WalkthroughHunk, WalkthroughOmittedFile } from '@opencode-manager/shared/schemas' +import type { + ChangeWalkthrough, + WalkthroughHunk, + WalkthroughOmittedFile, + WalkthroughStop, +} from '@opencode-manager/shared/schemas' interface WalkthroughErrorLike { message?: string @@ -64,13 +70,39 @@ function OmittedFiles({ files }: { files: WalkthroughOmittedFile[] }) { ) } -interface ChangesWalkthroughViewProps { +interface ChangesWalkthroughContextValue { + isLoading: boolean + walkthrough: ChangeWalkthrough | null + stale: boolean + generating: boolean + error: unknown + contextLimitFiles: WalkthroughOmittedFile[] + generate: () => void + regenerate: () => void + stops: WalkthroughStop[] + stopIndex: number + selectedStop: WalkthroughStop | null + selectedHunks: WalkthroughHunk[] + selectStop: (index: number) => void + detailRef: RefObject +} + +const ChangesWalkthroughContext = createContext(null) + +function useChangesWalkthrough(): ChangesWalkthroughContextValue { + const value = useContext(ChangesWalkthroughContext) + if (!value) throw new Error('useChangesWalkthrough must be used within a ChangesWalkthroughProvider') + return value +} + +interface ChangesWalkthroughProviderProps { sessionId: string active: boolean + children: ReactNode } -/** Generates and steps through a session's change walkthrough; the caller supplies the surrounding chrome. */ -export const ChangesWalkthroughView = memo(function ChangesWalkthroughView({ sessionId, active }: ChangesWalkthroughViewProps) { +/** Loads a session's change walkthrough and shares its state with the surrounding chrome and body. */ +export function ChangesWalkthroughProvider({ sessionId, active, children }: ChangesWalkthroughProviderProps) { const stateQuery = useChangeWalkthrough(sessionId, active) const generate = useGenerateChangeWalkthrough(sessionId) const resetGenerate = generate.reset @@ -92,11 +124,7 @@ export const ChangesWalkthroughView = memo(function ChangesWalkthroughView({ ses setStopIndex(0) }, [active, sessionId, walkthrough?.createdAt]) - const hunksById = useMemo( - () => new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []), - [walkthrough], - ) - + const hunksById = new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []) const stops = walkthrough?.stops ?? [] const clampedIndex = stops.length > 0 ? Math.min(stopIndex, stops.length - 1) : 0 const selectedStop = stops[clampedIndex] ?? null @@ -109,124 +137,192 @@ export const ChangesWalkthroughView = memo(function ChangesWalkthroughView({ ses detailRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' }) }, []) + const value: ChangesWalkthroughContextValue = { + isLoading: stateQuery.isLoading, + walkthrough, + stale, + generating, + error, + contextLimitFiles, + generate: () => generate.mutate({}), + regenerate: () => generate.mutate({ regenerate: true }), + stops, + stopIndex: clampedIndex, + selectedStop, + selectedHunks, + selectStop, + detailRef, + } + + return {children} +} + +/** Previous/next stop controls for the walkthrough chrome; renders nothing until a walkthrough has stops. */ +export const ChangesWalkthroughNav = memo(function ChangesWalkthroughNav() { + const { stops, stopIndex, selectStop } = useChangesWalkthrough() + + if (stops.length === 0) return null + return ( -
-
- {error ? ( -
-

{walkthroughErrorMessage(error)}

- {contextLimitFiles.length > 0 ? : null} -
- ) : null} +
+ + + Stop {stopIndex + 1} of {stops.length} + + +
+ ) +}) - {generating ? ( -
- - Generating walkthrough… this can take a minute or two. -
- ) : null} +/** Regenerates the current walkthrough from the chrome; renders nothing until one exists. */ +export const ChangesWalkthroughRegenerate = memo(function ChangesWalkthroughRegenerate() { + const { walkthrough, generating, regenerate } = useChangesWalkthrough() + + if (!walkthrough) return null - {stateQuery.isLoading ? ( -
- + return ( + + ) +}) + +/** Renders the walkthrough body; the provider and its chrome supply the surrounding panel. */ +export const ChangesWalkthroughView = memo(function ChangesWalkthroughView() { + const { + isLoading, + walkthrough, + stale, + generating, + error, + contextLimitFiles, + generate, + regenerate, + stops, + stopIndex, + selectedStop, + selectedHunks, + selectStop, + detailRef, + } = useChangesWalkthrough() + + return ( +
+ {error ? ( +
+

{walkthroughErrorMessage(error)}

+ {contextLimitFiles.length > 0 ? : null} +
+ ) : null} + + {generating ? ( +
+ + Generating walkthrough… this can take a minute or two. +
+ ) : null} + + {isLoading ? ( +
+ +
+ ) : !walkthrough ? ( + generating ? null : ( +
+

+ Generate a step-by-step walkthrough of the changes in this session. +

+
- ) : !walkthrough ? ( - generating ? null : ( -
-

- Generate a step-by-step walkthrough of the changes in this session. + ) + ) : ( +

+ {stale && !generating ? ( +
+

+ Changes have been updated since this walkthrough was generated

- +
- ) - ) : ( -
- {stale && !generating ? ( -
-

- Changes have been updated since this walkthrough was generated -

- -
- ) : null} - - - - {stops.length > 0 ? ( -
    - {stops.map((stop, index) => ( -
  1. - -
  2. - ))} -
- ) : null} - - {selectedStop ? ( -
-

- {clampedIndex + 1}. {selectedStop.title} -

- - {selectedHunks.map((hunk) => ( -
-
- - {hunk.file} - - {hunk.truncated ? truncated : null} - - {GIT_STATUS_LABELS[hunk.status]} - -
-
- -
+ ) : null} + + + + {stops.length > 0 ? ( +
    + {stops.map((stop, index) => ( +
  1. + +
  2. + ))} +
+ ) : null} + + {selectedStop ? ( +
+

+ {stopIndex + 1}. {selectedStop.title} +

+ + {selectedHunks.map((hunk) => ( +
+
+ + {hunk.file} + + {hunk.truncated ? truncated : null} + + {GIT_STATUS_LABELS[hunk.status]} + +
+
+
- ))} -
- ) : null} +
+ ))} +
+ ) : null} - {walkthrough.omittedFiles.length > 0 ? : null} -
- )} -
- - {stops.length > 0 ? ( -
- - - Stop {clampedIndex + 1} of {stops.length} - - + {walkthrough.omittedFiles.length > 0 ? : null}
- ) : null} + )}
) }) @@ -243,8 +339,19 @@ export function ChangesWalkthroughSheet({ sessionId, open, onOpenChange }: Chang return ( - - + + + + + + } + /> + + ) } diff --git a/frontend/src/lib/modelSections.test.ts b/frontend/src/lib/modelSections.test.ts index 9dcde0e02..056955f1e 100644 --- a/frontend/src/lib/modelSections.test.ts +++ b/frontend/src/lib/modelSections.test.ts @@ -97,6 +97,26 @@ describe('buildModelSections', () => { expect(valuesOf(sections, 'provider:openai')).toContain('openai/gpt-5') }) + it('keeps a favorite in the favorites section when it is also the default model', () => { + const sections = buildModelSections( + providers, + { + favorite: [ + { providerID: 'openai', modelID: 'gpt-5' }, + { providerID: 'anthropic', modelID: 'claude-sonnet' }, + ], + recent: [{ providerID: 'openai', modelID: 'gpt-5' }], + variant: {}, + }, + 'openai/gpt-5', + ) + + expect(valuesOf(sections, 'favorites')).toEqual(['openai/gpt-5', 'anthropic/claude-sonnet']) + expect(valuesOf(sections, 'default')).toEqual(['openai/gpt-5']) + expect(sections.some((section) => section.key === 'recent')).toBe(false) + expect(valuesOf(sections, 'provider:openai')).not.toContain('openai/gpt-5') + }) + it('lists each favorite once and excludes it from the recent section', () => { const sections = buildModelSections(providers, { favorite: [{ providerID: 'anthropic', modelID: 'claude-sonnet' }], @@ -196,7 +216,22 @@ describe('filterModelSections', () => { it('returns the sections unchanged for an empty query', () => { const sections = buildModelSections(providers, undefined) - expect(filterModelSections(sections, ' ')).toBe(sections) + expect(filterModelSections(sections, ' ')).toEqual(sections) + }) + + it('lists a favorite that is also the default only once', () => { + const sections = buildModelSections( + providers, + { favorite: [{ providerID: 'openai', modelID: 'gpt-5' }], recent: [], variant: {} }, + 'openai/gpt-5', + ) + + const unfiltered = filterModelSections(sections, '') + expect(unfiltered.some((section) => section.key === 'favorites')).toBe(false) + expect(valuesOf(unfiltered, 'default')).toEqual(['openai/gpt-5']) + + const searched = filterModelSections(sections, 'gpt-5') + expect(searched[0].options.map((option) => option.value)).toEqual(['openai/gpt-5']) }) }) @@ -229,6 +264,28 @@ describe('toModelComboboxOptions', () => { }) }) + it('hides the favorite copy of the default model behind the Default entry', () => { + const sections = buildModelSections( + providers, + { + favorite: [ + { providerID: 'openai', modelID: 'gpt-5' }, + { providerID: 'anthropic', modelID: 'claude-sonnet' }, + ], + recent: [], + variant: {}, + }, + 'openai/gpt-5', + ) + + const options = toModelComboboxOptions(sections, 'openai/gpt-5') + + expect(options.filter((option) => option.label.includes('GPT-5'))).toEqual([ + { value: '', label: 'Default: GPT-5', description: undefined, group: 'Default' }, + ]) + expect(options[1]?.value).toBe('anthropic/claude-sonnet') + }) + it('leaves the default ref untouched when no default is resolved', () => { const sections = buildModelSections(providers, undefined, 'openai/gpt-5') diff --git a/frontend/src/lib/modelSections.ts b/frontend/src/lib/modelSections.ts index bfb40746a..fb7a10a38 100644 --- a/frontend/src/lib/modelSections.ts +++ b/frontend/src/lib/modelSections.ts @@ -60,6 +60,16 @@ export function buildModelSections( const pinnedValues = new Set() + const pinOptions = (selections: ModelSelection[] = []) => + selections.flatMap((selection) => { + const option = optionsByValue.get(providerModelRef({ id: selection.providerID }, { id: selection.modelID })) + if (!option || pinnedValues.has(option.value)) return [] + pinnedValues.add(option.value) + return [option] + }) + + const favoriteOptions = pinOptions(modelState?.favorite) + const defaultOption = (() => { if (!defaultModel) return undefined const parsed = parseOpenCodeModelRef(defaultModel) @@ -71,15 +81,6 @@ export function buildModelSections( return createOption(provider, model, defaultModel) })() - const pinOptions = (selections: ModelSelection[] = []) => - selections.flatMap((selection) => { - const option = optionsByValue.get(providerModelRef({ id: selection.providerID }, { id: selection.modelID })) - if (!option || pinnedValues.has(option.value)) return [] - pinnedValues.add(option.value) - return [option] - }) - - const favoriteOptions = pinOptions(modelState?.favorite) const recentOptions = pinOptions(modelState?.recent) const sections: ModelSection[] = [ ...(defaultOption ? [{ key: 'default', title: 'Default', options: [defaultOption] }] : []), @@ -93,6 +94,21 @@ export function buildModelSections( return sections.filter((section) => section.options.length > 0) } +/** Keeps the first option for each value, so a favorite that is also the default lists once under Default. */ +function uniqueModelSections(sections: ModelSection[]): ModelSection[] { + const seen = new Set() + return sections + .map((section) => ({ + ...section, + options: section.options.filter((option) => { + if (seen.has(option.value)) return false + seen.add(option.value) + return true + }), + })) + .filter((section) => section.options.length > 0) +} + function compareSearchMatches(a: ModelOption, b: ModelOption): number { return compareCatalogModels( { name: a.label, free: a.model.free, released: a.model.released }, @@ -101,13 +117,14 @@ function compareSearchMatches(a: ModelOption, b: ModelOption): number { } export function filterModelSections(sections: ModelSection[], query: string): ModelSection[] { + const unique = uniqueModelSections(sections) const terms = query.toLowerCase().split(/\s+/).filter(Boolean) - if (terms.length === 0) return sections + if (terms.length === 0) return unique const favoriteValues = new Set( sections.find((section) => section.key === 'favorites')?.options.map((option) => option.value) ?? [], ) - const matches = sections + const matches = unique .flatMap((section) => section.options) .filter((option) => terms.every((term) => option.searchText.includes(term))) .sort(compareSearchMatches) @@ -120,7 +137,7 @@ export function filterModelSections(sections: ModelSection[], query: string): Mo } export function toModelComboboxOptions(sections: ModelSection[], defaultRef?: string | null): ComboboxOption[] { - return sections.flatMap((section) => + return uniqueModelSections(sections).flatMap((section) => section.options.map((option) => { const isDefault = defaultRef != null && option.value === defaultRef return {