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..6ea59169b 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,9 +370,15 @@ export class ChangeWalkthroughService { const prompt = buildWalkthroughPrompt({ title, hunks }) + onModelStart() + 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/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 44a5e07fd..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' @@ -28,10 +29,11 @@ interface FakeSession { function createFakeClient(sessions: Record) { const generateCalls: string[] = [] - let reply = MODEL_REPLY + let generateImpl: () => Promise = async () => MODEL_REPLY const client = { api: { + ...stubLoadedModelCatalog(), session: { get: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] @@ -62,7 +64,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 +74,8 @@ function createFakeClient(sessions: Record) { return { client, generateCalls, - setReply: (next: string) => { - reply = next + setGenerateImpl: (impl: () => Promise) => { + generateImpl = impl }, } } @@ -102,10 +104,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 +125,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 +185,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 +247,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/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 5f394da76..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, @@ -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, @@ -54,10 +55,12 @@ interface FakeSession { function createFakeClient(sessions: Record) { const generateCalls: string[] = [] + const generateModels: Array = [] let generateImpl: () => Promise = async () => modelReply([]) const client = { api: { + ...stubLoadedModelCatalog(), session: { get: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] @@ -86,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() } }), }, @@ -98,6 +102,7 @@ function createFakeClient(sessions: Record) { return { client, generateCalls, + generateModels, setGenerateImpl: (impl: () => Promise) => { generateImpl = impl }, @@ -383,6 +388,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 () => { @@ -436,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) => { @@ -526,6 +554,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/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/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/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/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/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) => ( ({ 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 44103ca18..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 } 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,25 +19,62 @@ 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() const { logout } = useAuth() const { data: health } = useServerHealth() - const isSessionDetail = /^\/repos\/\d+\/sessions\/[^/]+$/.test(location.pathname) + 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/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx new file mode 100644 index 000000000..02db7bcfc --- /dev/null +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -0,0 +1,232 @@ +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' + +vi.mock('@/components/file-browser/FileBrowser', () => ({ + 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', () => ({ + ChangesWalkthroughProvider: ({ children }: { children: ReactNode }) => <>{children}, + ChangesWalkthroughNav: () => null, + ChangesWalkthroughRegenerate: () => null, + ChangesWalkthroughView: () =>
walkthrough-tool
, +})) + +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')).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..be80d5dbd --- /dev/null +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -0,0 +1,197 @@ +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 { + 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' +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 + } + } + + const panelContent = + activeTool && activeLabel ? ( + + ) : null + + return ( + <> + {panelContent ? ( + activeTool === 'walkthrough' && sessionId ? ( + + {panelContent} + + ) : ( + panelContent + ) + ) : 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 75% rename from frontend/src/components/session/ChangesWalkthroughDialog.test.tsx rename to frontend/src/components/session/ChangesWalkthroughSheet.test.tsx index 8cea08bc5..221a26cf8 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,27 +130,62 @@ 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'), ).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 }) + }) + }) + + 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 +194,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 +205,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 +216,7 @@ describe('ChangesWalkthroughDialog', () => { }, }), ) - renderDialog() + renderSheet() await user.click(await screen.findByRole('button', { name: 'Generate walkthrough' })) @@ -202,7 +237,7 @@ describe('ChangesWalkthroughDialog', () => { }, }), ) - const { container } = renderDialog() + const { container } = renderSheet() expect(await screen.findByText(/Summary/)).toBeInTheDocument() expect(await screen.findByText(/Explanation/)).toBeInTheDocument() @@ -218,22 +253,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..44ac695ba --- /dev/null +++ b/frontend/src/components/session/ChangesWalkthroughSheet.tsx @@ -0,0 +1,357 @@ +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' +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 { + ChangeWalkthrough, + WalkthroughHunk, + WalkthroughOmittedFile, + WalkthroughStop, +} 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 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 +} + +/** 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 + 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 = 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 + 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' }) + }, []) + + 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 ( +
+ + + Stop {stopIndex + 1} of {stops.length} + + +
+ ) +}) + +/** 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 + + 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. +

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

+ Changes have been updated since this walkthrough was generated +

+ +
+ ) : 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} + + {walkthrough.omittedFiles.length > 0 ? : 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/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 { 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..954918ce9 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); @@ -267,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(); @@ -287,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, @@ -306,6 +302,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 +465,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 +505,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 +713,8 @@ export function SessionDetail() {
-
+
+
{repoLoading || sessionLoading || messagesLoading ? ( @@ -818,6 +818,20 @@ export function SessionDetail() {
)}
+ {docked && ( + + )} +
{/* Sessions Dialog */} @@ -865,7 +879,7 @@ export function SessionDetail() { )} showToast.success(`Loaded skill: ${skill.name}`)} + onSkillLoaded={handleSkillLoaded} /> )} {sessionId && ( - )} @@ -902,14 +916,14 @@ export function SessionDetail() { )} setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName={workspaceDisplayName} @@ -918,12 +932,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..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 ), })) @@ -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/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 }), })) 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') 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;