diff --git a/backend/src/index.ts b/backend/src/index.ts index b5c6d4ed5..fc34df66d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -341,7 +341,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService, changeWalkthroughService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService, upgradeWebSocket)) const protectedApi = new Hono() diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 9d58bafe8..b664ef95f 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -19,11 +19,13 @@ import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' import { createSessionGoalRoutes } from '../session-goals' import { createMultiRunRoutes } from '../multi-runs' +import { createChangeWalkthroughRoutes } from '../change-walkthroughs' import type { SessionPermissionModeService } from '../../services/session-permission-modes' import type { RepoWorkspaceService } from '../../services/repo-workspace' import type { GitAuthService } from '../../services/git-auth' import type { SessionGoalService } from '../../services/session-goals' import type { MultiRunService } from '../../services/multi-runs' +import type { ChangeWalkthroughService } from '../../services/change-walkthroughs' export function createInternalRoutes( db: Database, @@ -36,6 +38,7 @@ export function createInternalRoutes( gitAuthService: GitAuthService, sessionGoals: SessionGoalService, multiRuns: MultiRunService, + changeWalkthroughService: ChangeWalkthroughService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) @@ -54,6 +57,7 @@ export function createInternalRoutes( app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) app.route('/sessions', createInternalSessionRoutes(db, openCodeClient, permissionModes, repoWorkspaces)) app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) + app.route('/change-walkthroughs', createChangeWalkthroughRoutes(changeWalkthroughService)) app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) app.route('/sandbox', createInternalSandboxRoutes(db)) return app diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index de6691193..23b494262 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -12,12 +12,11 @@ import { fileExists, ensureDirectoryExists, } from './file-operations' -import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY } from '@opencode-manager/shared/schemas' +import { ASSISTANT_NOTIFICATION_LIMITS, AssistantSettingsPatchSchema, OpenCodeConfigSchema, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_LEADER_KEY, MANAGER_TOOL_NAME } from '@opencode-manager/shared/schemas' import { MANAGER_COLOR_THEME_ID, OPENCODE_THEMES } from '@opencode-manager/shared/themes' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH, ASSISTANT_OPENCODE_DIR_NAME } from '@opencode-manager/shared/utils' import { getAssistantModePath, getReposPath } from '@opencode-manager/shared/config/env' import type { Database } from 'bun:sqlite' -import { MANAGER_TOOL_NAME } from './opencode-manager-tool-plugin' import { ensureAssistantRepo } from '../db/queries' diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts index 470c1e952..0b3745771 100644 --- a/backend/src/services/change-walkthroughs.ts +++ b/backend/src/services/change-walkthroughs.ts @@ -1297,14 +1297,25 @@ export class ChangeWalkthroughService { } } + /** + * Resolves the model for one generation, waiting once for OpenCode's lazily-loaded global catalog so an explicit + * walkthrough model is registered before the first call. A failed wait is swallowed because generation proceeds and + * lets OpenCode surface its own error; the wait is per generation, never per stop. + */ private async resolveWalkthroughModel(session: SessionInfo): Promise { const configured = this.settingsService.getSettings().preferences.walkthroughModel?.trim() const parsed = configured ? parseOpenCodeModelRef(configured) : undefined const preferred = parsed ?? session.model if (preferred) { + try { + await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath(), { + preferredModel: formatOpenCodeModelRef(preferred), + }) + } catch { + return preferred + } return preferred } - let resolved try { resolved = await resolveOpenCodeModel(this.openCodeClient, getOpenCodeGlobalConfigPath()) diff --git a/backend/src/services/opencode-manager-tool-plugin.ts b/backend/src/services/opencode-manager-tool-plugin.ts index cb9990ea9..50b7e3825 100644 --- a/backend/src/services/opencode-manager-tool-plugin.ts +++ b/backend/src/services/opencode-manager-tool-plugin.ts @@ -1,7 +1,5 @@ import { z } from 'zod' -import { ASSISTANT_NOTIFICATION_LIMITS } from '@opencode-manager/shared/schemas' - -export const MANAGER_TOOL_NAME = 'ocm' +import { ASSISTANT_NOTIFICATION_LIMITS, MANAGER_TOOL_ACTIONS, MANAGER_TOOL_NAME, WalkthroughSourceSchema } from '@opencode-manager/shared/schemas' const MANAGER_TOOL_REQUEST_TIMEOUT_MS = 60000 @@ -40,8 +38,6 @@ export const MANAGER_TOOL_ALLOWED_ROUTES = [ const MANAGER_TOOL_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const -const MANAGER_TOOL_ACTION_NAMES = ['send_notification', 'request'] as const - const ManagerToolNotificationParamsSchema = z .object({ title: z.string().min(1).max(ASSISTANT_NOTIFICATION_LIMITS.TITLE_MAX).describe('The notification title.'), @@ -62,9 +58,17 @@ const ManagerToolRequestParamsSchema = z .strict() .describe('Call an allow-listed OpenCode Manager internal API route.') -const MANAGER_TOOL_ACTION_PARAMS_SCHEMAS: Record<(typeof MANAGER_TOOL_ACTION_NAMES)[number], z.ZodType> = { +const ManagerToolWalkthroughParamsSchema = z + .object({ + source: WalkthroughSourceSchema.optional().describe('The change source to walk through. Defaults to the changes in this session.'), + }) + .strict() + .describe("Request the change Walkthrough for this session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user can open it from this tool call.") + +const MANAGER_TOOL_ACTION_PARAMS_SCHEMAS: Record<(typeof MANAGER_TOOL_ACTIONS)[number], z.ZodType> = { send_notification: ManagerToolNotificationParamsSchema, request: ManagerToolRequestParamsSchema, + open_walkthrough: ManagerToolWalkthroughParamsSchema, } export function parseAllowedRoute(route: string): { method: string; path: string } { @@ -92,9 +96,13 @@ function buildManagerToolInputJsonSchema(): Record { const jsonSchema: Record = z.toJSONSchema( z .object({ - action: z.enum(MANAGER_TOOL_ACTION_NAMES).describe('The OpenCode Manager action to perform.'), + action: z.enum(MANAGER_TOOL_ACTIONS).describe('The OpenCode Manager action to perform.'), params: z - .union([MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.send_notification, MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.request]) + .union([ + MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.send_notification, + MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.request, + MANAGER_TOOL_ACTION_PARAMS_SCHEMAS.open_walkthrough, + ]) .describe('The parameters for the chosen action.'), }) .strict(), @@ -104,7 +112,7 @@ function buildManagerToolInputJsonSchema(): Record { } function buildManagerToolActionRequiredKeys(): Record { - return Object.fromEntries(MANAGER_TOOL_ACTION_NAMES.map((name) => [name, requiredKeysOf(MANAGER_TOOL_ACTION_PARAMS_SCHEMAS[name])])) + return Object.fromEntries(MANAGER_TOOL_ACTIONS.map((name) => [name, requiredKeysOf(MANAGER_TOOL_ACTION_PARAMS_SCHEMAS[name])])) } function buildManagerToolDescription(): string { @@ -114,6 +122,7 @@ function buildManagerToolDescription(): string { 'Actions:', '- send_notification: send a push notification to every device the user has registered.', '- request: call an allow-listed internal API route to read and manage settings, the OpenCode configuration file, repos (list, inspect, clone from a git URL with POST /repos and body { repoUrl, branch?, directoryName? }), OpenCode workspaces, sessions (list, create, follow up, read the latest reply, fork), and schedules.', + "- open_walkthrough: request the change Walkthrough for the current session, generating it if needed. It opens in the user's Manager window when this session is open on desktop; otherwise the user opens it from this tool call. Use when the user asks to walk through, review or explain the changes.", 'Allowed request routes:', ] .concat(MANAGER_TOOL_ALLOWED_ROUTES.map((route) => `- ${route}`)) @@ -129,7 +138,7 @@ var ALLOWED_MATCHERS = ${JSON.stringify(buildRouteMatchers())}.map(function (mat return { method: matcher.method, pattern: new RegExp(matcher.source) } }) -var ACTION_NAMES = ${JSON.stringify(MANAGER_TOOL_ACTION_NAMES)} +var ACTION_NAMES = ${JSON.stringify(MANAGER_TOOL_ACTIONS)} var ACTION_REQUIRED_KEYS = ${JSON.stringify(buildManagerToolActionRequiredKeys())} @@ -221,6 +230,21 @@ var ACTIONS = { return text || 'The request succeeded with an empty response body.' }, }, + open_walkthrough: { + run: async function (params, context) { + var body = params.source === undefined ? undefined : { source: params.source } + var state = await postInternalApi('/change-walkthroughs/' + encodeURIComponent(context.sessionID), body, context.signal) + var hint = ' It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.' + if (state.generating === true) { + return 'Requested the walkthrough; it is generating.' + hint + } + var stops = state.walkthrough && Array.isArray(state.walkthrough.stops) ? state.walkthrough.stops.length : 0 + if (state.walkthrough) { + return 'Requested the walkthrough (' + stops + ' stops).' + hint + } + return 'Requested the walkthrough.' + hint + }, + }, } function assertParams(actionName, params) { diff --git a/backend/test/helpers/internal-test-app.ts b/backend/test/helpers/internal-test-app.ts index 8802456ff..21150fe15 100644 --- a/backend/test/helpers/internal-test-app.ts +++ b/backend/test/helpers/internal-test-app.ts @@ -13,6 +13,7 @@ type InternalTestAppOverrides = Partial<{ gitAuthService: CreateInternalRoutesParams[7] sessionGoals: CreateInternalRoutesParams[8] multiRuns: CreateInternalRoutesParams[9] + changeWalkthroughService: CreateInternalRoutesParams[10] }> export function createInternalTestApp( @@ -30,5 +31,6 @@ export function createInternalTestApp( overrides.gitAuthService ?? ({} as CreateInternalRoutesParams[7]), overrides.sessionGoals ?? ({} as CreateInternalRoutesParams[8]), overrides.multiRuns ?? ({} as CreateInternalRoutesParams[9]), + overrides.changeWalkthroughService ?? ({} as CreateInternalRoutesParams[10]), ) } diff --git a/backend/test/helpers/opencode-plugin-context.ts b/backend/test/helpers/opencode-plugin-context.ts index 9fc264700..8ba18cb49 100644 --- a/backend/test/helpers/opencode-plugin-context.ts +++ b/backend/test/helpers/opencode-plugin-context.ts @@ -25,7 +25,7 @@ export type GeneratedTool = { name: string description?: string input?: unknown - execute?: (input: unknown, context: { signal: AbortSignal }) => Promise + execute?: (input: unknown, context: { signal: AbortSignal; sessionID?: string }) => Promise [key: string]: unknown } diff --git a/backend/test/helpers/stub-opencode-client.ts b/backend/test/helpers/stub-opencode-client.ts index c3a8f461c..c86daa53d 100644 --- a/backend/test/helpers/stub-opencode-client.ts +++ b/backend/test/helpers/stub-opencode-client.ts @@ -1,15 +1,17 @@ import { vi } from 'vitest' -import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import type { ModelInfo, 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 } +export function stubLoadedModelCatalog(models: ModelInfo[] = [ + { providerID: 'openai', id: 'gpt-5-mini', enabled: true } as ModelInfo, +]) { + const defaultModel = models[0] ?? null return { config: { get: vi.fn(async () => []) }, model: { - list: vi.fn(async () => ({ data: [model] })), - default: vi.fn(async () => ({ data: model })), + list: vi.fn(async () => ({ data: models })), + default: vi.fn(async () => ({ data: defaultModel })), }, } } diff --git a/backend/test/routes/internal-change-walkthroughs.test.ts b/backend/test/routes/internal-change-walkthroughs.test.ts new file mode 100644 index 000000000..98c6eb08a --- /dev/null +++ b/backend/test/routes/internal-change-walkthroughs.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' +import type { ChangeWalkthroughService } from '../../src/services/change-walkthroughs' +import { createInternalTestApp } from '../helpers/internal-test-app' + +const SESSION_ID = 'ses_walkthrough' + +describe('internal/change-walkthroughs routes', () => { + let db: Database + let app: Hono + let token: string + let startGeneration: ReturnType + let getState: ReturnType + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + + startGeneration = vi.fn(async () => ({ + walkthrough: null, + currentDiffHash: 'hash', + stale: false, + generating: true, + error: null, + })) + getState = vi.fn(async () => ({ + walkthrough: null, + currentDiffHash: 'hash', + stale: false, + generating: false, + error: null, + })) + + app = new Hono() + app.route( + '/api/internal', + createInternalTestApp(db, { changeWalkthroughService: { startGeneration, getState } as unknown as ChangeWalkthroughService }), + ) + token = getOrCreateInternalToken(db) + }) + + it('POST /api/internal/change-walkthroughs/:id returns 401 without a bearer token', async () => { + const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, { method: 'POST' }) + + expect(res.status).toBe(401) + expect(startGeneration).not.toHaveBeenCalled() + }) + + it('POST /api/internal/change-walkthroughs/:id starts generation with the session id and source', async () => { + const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ source: { kind: 'staged' } }), + }) + + expect(res.status).toBe(202) + expect(startGeneration).toHaveBeenCalledTimes(1) + expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, { source: { kind: 'staged' } }) + await expect(res.json()).resolves.toMatchObject({ generating: true }) + }) + + it('POST /api/internal/change-walkthroughs/:id accepts an empty body', async () => { + const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(202) + expect(startGeneration).toHaveBeenCalledWith(SESSION_ID, {}) + }) + + it('GET /api/internal/change-walkthroughs/:id reads the requested source', async () => { + const res = await app.request(`/api/internal/change-walkthroughs/${SESSION_ID}?source=staged`, { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(getState).toHaveBeenCalledWith(SESSION_ID, { kind: 'staged' }, undefined) + }) +}) diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index c24ad1693..1cc13528b 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { Database } from 'bun:sqlite' -import type { FileDiffInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { FileDiffInfo, ModelInfo, ModelRef, SessionInfo, SessionMessageInfo } from '@opencode-manager/shared/opencode' import { MECHANICAL_TEXT_BUDGET, WALKTHROUGH_DIFF_MAX_CHARS, @@ -152,7 +152,11 @@ function createFakeClient(sessions: Record) { const client = { api: { - ...stubLoadedModelCatalog(), + ...stubLoadedModelCatalog([ + { providerID: 'openai', id: 'gpt-5-mini', enabled: true }, + { providerID: 'anthropic', id: 'claude-sonnet-4', enabled: true }, + { providerID: 'openai', id: 'gpt-5', enabled: true }, + ] as ModelInfo[]), session: { get: vi.fn(async ({ sessionID }: { sessionID: string }) => { const config = sessions[sessionID] @@ -1169,13 +1173,14 @@ describe('ChangeWalkthroughService', () => { it('returns the stored walkthrough without a model call when changes are unchanged', async () => { fake.setGenerateImpl(async () => modelReply([{ title: 'A', explanation: 'x', hunkIds: THREE_HUNK_IDS }])) const first = await service.generate(SESSION_ID, {}) + const modelListCallsAfterFirst = vi.mocked(fake.client.api.model.list).mock.calls.length const second = await service.generate(SESSION_ID, {}) expect(second.created).toBe(false) expect(second.walkthrough).toEqual(first.walkthrough) expect(fake.generateCalls).toHaveLength(1) - expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(1) + expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(modelListCallsAfterFirst) }) it('calls the model again when regenerate is set', async () => { @@ -1233,14 +1238,37 @@ describe('ChangeWalkthroughService', () => { expect(fake.generateModels[0]).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4' }) }) - it('resolves the default model once per generation', async () => { + it('reuses the resolved default model for every stop', async () => { + sessions[SESSION_ID]!.changes = LARGE_CHANGES + fake.setGenerateImpl((prompt) => + Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY), + ) + + await service.generate(SESSION_ID, {}) + + expect(fake.generateModels).toHaveLength(4) + expect( + fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'), + ).toBe(true) + }) + + it('waits for an explicit model once per generation, not per stop', async () => { sessions[SESSION_ID]!.changes = LARGE_CHANGES + sessions[SESSION_ID]!.info = { + id: SESSION_ID, + title: 'Title', + model: { providerID: 'openai', id: 'gpt-5-mini' }, + } as SessionInfo fake.setGenerateImpl((prompt) => Promise.resolve(prompt.includes(PLAN_MARKER) ? LARGE_PLAN_REPLY : EXPLANATION_REPLY), ) await service.generate(SESSION_ID, {}) + expect(fake.generateModels).toHaveLength(4) + expect( + fake.generateModels.every((model) => model?.providerID === 'openai' && model.id === 'gpt-5-mini'), + ).toBe(true) expect(vi.mocked(fake.client.api.model.list)).toHaveBeenCalledTimes(1) }) @@ -1762,6 +1790,7 @@ describe('ChangeWalkthroughService', () => { expect(state.generating).toBe(true) expect(state.error).toBeNull() + await vi.waitFor(() => expect(fake.generateCalls.length).toBeGreaterThan(2)) resolveGenerate(coveringReply) await vi.waitFor(async () => { expect((await service.getState(SESSION_ID)).generating).toBe(false) diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 63e446ffe..e826cbca0 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -5,8 +5,8 @@ import http from 'http' import type { AddressInfo } from 'net' import path from 'path' import os from 'os' -import { ASSISTANT_NOTIFICATION_LIMITS, AssistantNotificationRequestSchema } from '@opencode-manager/shared/schemas' -import { MANAGER_TOOL_NAME, MANAGER_TOOL_ALLOWED_ROUTES, parseAllowedRoute } from '../../src/services/opencode-manager-tool-plugin' +import { ASSISTANT_NOTIFICATION_LIMITS, AssistantNotificationRequestSchema, MANAGER_TOOL_NAME } from '@opencode-manager/shared/schemas' +import { MANAGER_TOOL_ALLOWED_ROUTES, parseAllowedRoute } from '../../src/services/opencode-manager-tool-plugin' import { installManagedPlugins, getOpenCodePluginDir } from '../../src/services/opencode/plugin-registry' import { loadGeneratedPlugin, type GeneratedTool } from '../helpers/opencode-plugin-context' import { resolveOpenCode2Binary, runOpenCodeStandalone } from '../helpers/opencode-binary' @@ -17,7 +17,9 @@ type JsonSchema = { required?: string[] additionalProperties?: boolean anyOf?: JsonSchema[] + oneOf?: JsonSchema[] enum?: string[] + const?: string maxLength?: number minLength?: number } @@ -41,9 +43,23 @@ function requestParamsSchema(tool: GeneratedTool): JsonSchema { return toolInputSchema(tool).properties?.params?.anyOf?.[1] ?? {} } -async function runTool(tool: GeneratedTool, input: unknown): Promise { - const result = (await tool.execute!(input, { signal: new AbortController().signal })) as { content: string } - return result.content +function walkthroughParamsSchema(tool: GeneratedTool): JsonSchema { + return toolInputSchema(tool).properties?.params?.anyOf?.[2] ?? {} +} + +async function runToolResult( + tool: GeneratedTool, + input: unknown, + context: { sessionID?: string } = {}, +): Promise<{ content: string; metadata?: Record }> { + return (await tool.execute!(input, { signal: new AbortController().signal, sessionID: context.sessionID })) as { + content: string + metadata?: Record + } +} + +async function runTool(tool: GeneratedTool, input: unknown, context: { sessionID?: string } = {}): Promise { + return (await runToolResult(tool, input, context)).content } function jsonResponse(body: unknown, { ok = true, status = 200 } = {}) { @@ -92,14 +108,32 @@ describe('ocm-manager plugin', () => { expect(schema.type).toBe('object') expect(schema.required).toEqual(['action', 'params']) expect(schema.additionalProperties).toBe(false) - expect(schema.properties?.action?.enum).toEqual(['send_notification', 'request']) - expect(schema.properties?.params?.anyOf).toHaveLength(2) + expect(schema.properties?.action?.enum).toEqual(['send_notification', 'request', 'open_walkthrough']) + expect(schema.properties?.params?.anyOf).toHaveLength(3) expect(notificationParamsSchema(tool).required).toEqual(['title', 'body']) expect(requestParamsSchema(tool).required).toEqual(['method', 'path']) + expect(walkthroughParamsSchema(tool).required).toBeUndefined() expect(notificationParamsSchema(tool).additionalProperties).toBe(false) expect(requestParamsSchema(tool).additionalProperties).toBe(false) + expect(walkthroughParamsSchema(tool).additionalProperties).toBe(false) expect(notificationParamsSchema(tool).properties?.priority?.enum).toEqual(['normal', 'high']) expect(requestParamsSchema(tool).properties?.method?.enum).toEqual(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + expect(walkthroughParamsSchema(tool).properties?.source?.oneOf?.map((variant) => variant.properties?.kind?.const)).toEqual([ + 'session', + 'uncommitted', + 'staged', + 'unstaged', + 'branch', + 'pullRequest', + ]) + }) + + it('lists the open_walkthrough action in the description', async () => { + const tool = await loadTool(configHome) + + expect(tool.description).toContain('open_walkthrough') + expect(tool.description).toContain('request the change Walkthrough for the current session') + expect(tool.description).toContain('when this session is open on desktop') }) it('enforces the notification limits the internal API enforces', async () => { @@ -191,6 +225,56 @@ describe('ocm-manager plugin', () => { expect(fetchMock).not.toHaveBeenCalled() }) + it('opens the walkthrough for the current session with the requested source', async () => { + const fetchMock = jsonResponse({ walkthrough: { stops: [{}, {}] }, currentDiffHash: 'h', stale: false, generating: false, error: null }) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + const result = await runToolResult( + tool, + { action: 'open_walkthrough', params: { source: { kind: 'staged' } } }, + { sessionID: 'ses_abc' }, + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/change-walkthroughs/ses_abc') + expect(init.method).toBe('POST') + expect(init.headers.Authorization).toBe('Bearer secret-token') + expect(init.headers['content-type']).toBe('application/json') + expect(JSON.parse(init.body)).toEqual({ source: { kind: 'staged' } }) + expect(result.content).toBe( + 'Requested the walkthrough (2 stops). It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', + ) + expect(result.metadata).toBeUndefined() + }) + + it('reports that the walkthrough is generating', async () => { + vi.stubGlobal('fetch', jsonResponse({ walkthrough: null, currentDiffHash: 'h', stale: false, generating: true, error: null })) + const tool = await loadTool(configHome) + + const result = await runToolResult(tool, { action: 'open_walkthrough', params: {} }, { sessionID: 'ses_abc' }) + + expect(result.content).toBe( + 'Requested the walkthrough; it is generating. It opens in the Manager window when this session is open on desktop; otherwise the user can open it from this tool call.', + ) + expect(result.metadata).toBeUndefined() + }) + + it('opens the walkthrough without a body when no source is given', async () => { + const fetchMock = jsonResponse({ walkthrough: null, currentDiffHash: 'h', stale: false, generating: true, error: null }) + vi.stubGlobal('fetch', fetchMock) + const tool = await loadTool(configHome) + + await runTool(tool, { action: 'open_walkthrough', params: {} }, { sessionID: 'ses_abc' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] ?? [] + expect(url).toBe('http://localhost:5003/api/internal/change-walkthroughs/ses_abc') + expect(init.headers['content-type']).toBeUndefined() + expect(init.body).toBeUndefined() + }) + it('sends an allow-listed GET request with a query string and no body', async () => { const fetchMock = jsonResponse({ userId: 'default', theme: 'dark' }) vi.stubGlobal('fetch', fetchMock) @@ -297,6 +381,8 @@ describe('ocm-manager plugin', () => { ['POST', '/multi-runs'], ['POST', '/multi-runs/1/fusions'], ['POST', '/multi-runs/1/entries/2/discard'], + ['GET', '/change-walkthroughs/x'], + ['POST', '/change-walkthroughs/x'], ] as const for (const [method, path] of deniedRoutes) { @@ -522,7 +608,7 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s expect(managerTool?.function?.parameters).toMatchObject({ type: 'object', properties: { - action: { type: 'string', enum: ['send_notification', 'request'] }, + action: { type: 'string', enum: ['send_notification', 'request', 'open_walkthrough'] }, params: { anyOf: [ { @@ -545,6 +631,13 @@ describe.skipIf(SHIPPED_OPENCODE_BIN === null)('ocm-manager plugin against the s body: { type: 'object' }, }, }, + { + type: 'object', + additionalProperties: false, + properties: { + source: expect.anything(), + }, + }, ], }, }, diff --git a/frontend/src/components/message/ExplorationGroup.tsx b/frontend/src/components/message/ExplorationGroup.tsx index 99ab3957d..4920e73eb 100644 --- a/frontend/src/components/message/ExplorationGroup.tsx +++ b/frontend/src/components/message/ExplorationGroup.tsx @@ -4,6 +4,7 @@ import type { SessionMessageAssistantTool } from '@opencode-manager/shared/openc import { useSettings } from '@/hooks/useSettings' import { useToolCallPermission } from '@/contexts/EventContext' import { explorationLabel, isExplorationComplete } from '@/lib/explorationGroups' +import type { WalkthroughSource } from '@opencode-manager/shared/schemas' import { MessagePart } from './MessagePart' interface ExplorationGroupProps { @@ -12,6 +13,7 @@ interface ExplorationGroupProps { directory?: string onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void + onOpenWalkthrough?: (source?: WalkthroughSource) => void } type MemberProps = Omit & { part: SessionMessageAssistantTool } diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 4d2d08b8e..79444f9e3 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -4,6 +4,7 @@ import { TextPart } from './TextPart' import { ToolCallPart } from './ToolCallPart' import { useSettings } from '@/hooks/useSettings' import type { ShellNoticeOutcome } from '@/lib/backgroundWork' +import type { WalkthroughSource } from '@opencode-manager/shared/schemas' type AssistantContentPart = SessionMessageAssistant['content'][number] @@ -14,9 +15,10 @@ interface MessagePartProps { shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void + onOpenWalkthrough?: (source?: WalkthroughSource) => void } -export const MessagePart = memo(function MessagePart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: MessagePartProps) { +export const MessagePart = memo(function MessagePart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick, onOpenWalkthrough }: MessagePartProps) { const { preferences } = useSettings() const simpleChatMode = preferences?.simpleChatMode ?? false const showReasoning = preferences?.showReasoning ?? false @@ -47,6 +49,7 @@ export const MessagePart = memo(function MessagePart({ part, messageID, director shellOutcome={shellOutcome} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} + onOpenWalkthrough={onOpenWalkthrough} /> ) default: diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index d4246d696..2e13bac3d 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -24,6 +24,7 @@ import { CopyButton } from '@/components/ui/copy-button' import { TTSButton } from '@/components/ui/tts-button' import { backgroundShellID, collectBackgroundParts, type ShellNoticeOutcome } from '@/lib/backgroundWork' import { groupExplorationParts, type AssistantContentItem } from '@/lib/explorationGroups' +import type { WalkthroughSource } from '@opencode-manager/shared/schemas' import { ExplorationGroup } from './ExplorationGroup' function getMessageText(message: SessionMessageInfo): string { @@ -324,6 +325,7 @@ interface MessageRowProps { directory?: string onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void + onOpenWalkthrough?: (source?: WalkthroughSource) => void model?: string simpleChatMode: boolean showReasoning: boolean @@ -344,6 +346,7 @@ const MessageRow = memo(function MessageRow({ directory, onFileClick, onChildSessionClick, + onOpenWalkthrough, model, simpleChatMode, showReasoning, @@ -452,6 +455,7 @@ const MessageRow = memo(function MessageRow({ shellOutcome={shellOutcomeFor(part)} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} + onOpenWalkthrough={onOpenWalkthrough} /> ))} @@ -494,6 +498,7 @@ const MessageRow = memo(function MessageRow({ directory={directory} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} + onOpenWalkthrough={onOpenWalkthrough} /> ) : ( )} @@ -570,6 +576,7 @@ interface MessageThreadProps { pending: SessionInboxInfo[] onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void + onOpenWalkthrough?: (source?: WalkthroughSource) => void onUndoMessage?: (restoredPrompt: string) => void model?: string isSessionBusy?: boolean @@ -582,6 +589,7 @@ export const MessageThread = memo(function MessageThread({ pending, onFileClick, onChildSessionClick, + onOpenWalkthrough, onUndoMessage, model, isSessionBusy = false, @@ -649,6 +657,7 @@ export const MessageThread = memo(function MessageThread({ directory={directory} onFileClick={onFileClick} onChildSessionClick={onChildSessionClick} + onOpenWalkthrough={onOpenWalkthrough} model={model} simpleChatMode={simpleChatMode} showReasoning={showReasoning} diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index b98a330fb..1ef4c72ac 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import type { PermissionRequest, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' @@ -265,3 +265,119 @@ describe('ToolCallPart background indicator', () => { expect(screen.queryByText('background')).not.toBeInTheDocument() }) }) + +describe('ToolCallPart open walkthrough action', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + }) + + const openWalkthrough = (status: 'running' | 'completed'): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_ocm', + name: 'ocm', + time: status === 'completed' ? { created: 1, ran: 2, completed: 3 } : { created: 1, ran: 2 }, + state: + status === 'completed' + ? { + status: 'completed', + input: { action: 'open_walkthrough', params: {} }, + content: [{ type: 'text', text: 'Walkthrough opened' }], + } + : { + status: 'running', + input: { action: 'open_walkthrough', params: {} }, + metadata: {}, + }, + }) + + it('renders an Open walkthrough button and calls the opener', () => { + const onOpenWalkthrough = vi.fn() + + renderWithProviders( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open walkthrough' })) + + expect(onOpenWalkthrough).toHaveBeenCalledTimes(1) + }) + + it('passes the requested source to the opener', () => { + const onOpenWalkthrough = vi.fn() + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_ocm', + name: 'ocm', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { action: 'open_walkthrough', params: { source: { kind: 'staged' } } }, + content: [{ type: 'text', text: 'Walkthrough opened' }], + }, + } + + renderWithProviders( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open walkthrough' })) + + expect(onOpenWalkthrough).toHaveBeenCalledWith({ kind: 'staged' }) + }) + + it('passes no source when the call invalidates one', () => { + const onOpenWalkthrough = vi.fn() + const part: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_ocm', + name: 'ocm', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { action: 'open_walkthrough', params: { source: { kind: 'nonsense' } } }, + content: [{ type: 'text', text: 'Walkthrough opened' }], + }, + } + + renderWithProviders( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open walkthrough' })) + + expect(onOpenWalkthrough).toHaveBeenCalledWith(undefined) + }) + + it('does not render the button while the call is still running', () => { + renderWithProviders( + , + ) + + expect(screen.queryByRole('button', { name: 'Open walkthrough' })).not.toBeInTheDocument() + }) + + it('does not render the button for another ocm action', () => { + const otherAction: SessionMessageAssistantTool = { + type: 'tool', + id: 'call_ocm', + name: 'ocm', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { action: 'something_else' }, + content: [{ type: 'text', text: 'ok' }], + }, + } + + renderWithProviders() + + expect(screen.queryByRole('button', { name: 'Open walkthrough' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 5e8d327c1..a9dc8af61 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -10,6 +10,7 @@ import { useShell } from '@/hooks/useSessionShells' import { detectFileReferences } from '@/lib/fileReferences' import { ExternalLink, Loader2, Shield } from 'lucide-react' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { CopyButton } from '@/components/ui/copy-button' import { BackgroundTaskStatusIcon } from '@/components/session/BackgroundTaskStatusIcon' import { @@ -26,6 +27,8 @@ import { type ShellNoticeOutcome, } from '@/lib/backgroundWork' import { getToolInputPath, getToolSpecificRender } from './FileToolRender' +import { readOpenWalkthroughCall } from '@/lib/walkthroughTool' +import type { WalkthroughSource } from '@opencode-manager/shared/schemas' const DISPLAY_LIMIT = 30_000 const DISPLAY_HEAD_LENGTH = 20_000 @@ -61,6 +64,7 @@ interface ToolCallPartProps { shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void + onOpenWalkthrough?: (source?: WalkthroughSource) => void } function toolInput(part: SessionMessageAssistantTool): Record | undefined { @@ -113,7 +117,7 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi return
{parts}
} -export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: ToolCallPartProps) { +export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick, onOpenWalkthrough }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() const isSubagent = part.name === 'subagent' @@ -139,6 +143,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct const isWaitingPermission = part.state.status === 'running' && pendingPermission !== null const outputRef = useRef(null) const input = toolInput(part) + const openWalkthrough = readOpenWalkthroughCall(part) const rawCommand = part.name === 'shell' && typeof input?.command === 'string' ? input.command : undefined @@ -349,36 +354,49 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct return (
- - - {isWaitingPermission ? 'awaiting permission' : isBackgroundShell ? lifecycleLabel(shellStatus) : part.state.status} - - + {openWalkthrough ? ( + + ) : null} +
{expanded && (
diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index 26ae41bc5..0debe3135 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -10,6 +10,7 @@ import { ChangesWalkthroughRegenerate, ChangesWalkthroughSourcePicker, ChangesWalkthroughView, + type WalkthroughSourceRequest, } from '@/components/session/ChangesWalkthroughSheet' import { PreviewWorkspace } from '@/components/preview/PreviewPanel' import { RepoMcpContent } from '@/components/repo/RepoMcpDialog' @@ -36,6 +37,7 @@ interface ToolSidePanelProps { currentBranch?: string selectedFilePath?: string onSkillLoaded?: (skill: SkillFileInfo) => void + walkthroughSourceRequest?: WalkthroughSourceRequest } interface RailButtonProps { @@ -89,6 +91,7 @@ export function ToolSidePanel({ currentBranch = 'main', selectedFilePath, onSkillLoaded, + walkthroughSourceRequest, }: ToolSidePanelProps) { const { activeTool, toggleTool, closePanel } = panel const location = useLocation() @@ -190,7 +193,12 @@ export function ToolSidePanel({ <> {panelContent ? ( activeTool === 'walkthrough' && sessionId ? ( - + {panelContent} ) : ( diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx index 8035fb052..e9e991ced 100644 --- a/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.test.tsx @@ -383,6 +383,90 @@ describe('ChangesWalkthroughSheet', () => { expect(mocks.getChangeWalkthrough).not.toHaveBeenCalledWith('ses_2', { kind: 'staged' }) }) + it('applies an external source request to the walkthrough source', async () => { + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null })) + renderSheet({ sourceRequest: { sessionId: 'ses_1', source: { kind: 'staged' } } }) + + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'staged' }) + }) + expect(screen.getByRole('combobox', { name: 'Changes to walk through' })).toHaveTextContent('Staged') + }) + + it('applies a pull request request with its number and base', async () => { + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null })) + renderSheet({ + sourceRequest: { sessionId: 'ses_1', source: { kind: 'pullRequest', number: 12 } }, + }) + + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'pullRequest', number: 12 }) + }) + expect(screen.getByLabelText('Pull request number')).toHaveValue(12) + }) + + it('re-applies a repeated request for the same source', async () => { + const user = userEvent.setup() + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null })) + const { rerender, onOpenChange } = renderSheet({ + sourceRequest: { sessionId: 'ses_1', source: { kind: 'staged' } }, + }) + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'staged' }) + }) + + await user.click(screen.getByRole('combobox', { name: 'Changes to walk through' })) + await user.click(await screen.findByRole('option', { name: 'Unstaged' })) + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'unstaged' }) + }) + + rerender( + , + ) + + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'staged' }) + }) + expect(screen.getByRole('combobox', { name: 'Changes to walk through' })).toHaveTextContent('Staged') + }) + + it('leaves the current selection when a request carries no source', async () => { + const user = userEvent.setup() + mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null })) + const { rerender, onOpenChange } = renderSheet({ + sourceRequest: { sessionId: 'ses_1', source: { kind: 'staged' } }, + }) + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'staged' }) + }) + + await user.click(screen.getByRole('combobox', { name: 'Changes to walk through' })) + await user.click(await screen.findByRole('option', { name: 'Unstaged' })) + await waitFor(() => { + expect(mocks.getChangeWalkthrough).toHaveBeenCalledWith('ses_1', { kind: 'unstaged' }) + }) + mocks.getChangeWalkthrough.mockClear() + + rerender( + , + ) + + expect(screen.getByRole('combobox', { name: 'Changes to walk through' })).toHaveTextContent('Unstaged') + expect(mocks.getChangeWalkthrough).not.toHaveBeenCalledWith('ses_1', { kind: 'session' }) + expect(mocks.getChangeWalkthrough).not.toHaveBeenCalledWith('ses_1', { kind: 'staged' }) + }) + it('clears a generation error when the source changes', async () => { const user = userEvent.setup() mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null })) @@ -427,12 +511,44 @@ describe('ChangesWalkthroughSheet', () => { expect(await screen.findByText('Overview')).toBeInTheDocument() }) - it('shows progress while the server is generating instead of the generate button', async () => { + it('shows a skeleton and planning status while the server is generating without stops', async () => { mocks.getChangeWalkthrough.mockResolvedValue(state({ walkthrough: null, generating: true })) renderSheet() - expect(await screen.findByText(/Generating walkthrough/)).toBeInTheDocument() + expect(await screen.findByText('Planning the walkthrough…')).toBeInTheDocument() + expect(screen.getByTestId('walkthrough-skeleton')).toHaveAttribute('aria-hidden', 'true') expect(screen.queryByRole('button', { name: 'Generate walkthrough' })).not.toBeInTheDocument() + expect(screen.queryByText(/Generating walkthrough/)).not.toBeInTheDocument() + }) + + it('shows a skeleton while the walkthrough state is loading', async () => { + mocks.getChangeWalkthrough.mockImplementation(() => new Promise(() => {})) + renderSheet() + + expect(await screen.findByText('Loading the walkthrough…')).toBeInTheDocument() + expect(screen.getByTestId('walkthrough-skeleton')).toBeInTheDocument() + }) + + it('shows a stop body skeleton while its explanation is pending', async () => { + const user = userEvent.setup() + mocks.getChangeWalkthrough.mockResolvedValue( + state({ + generating: true, + walkthrough: { + ...walkthrough, + stops: [ + { ...walkthrough.stops[0], status: 'ready' }, + { ...walkthrough.stops[1], status: 'pending', explanation: '' }, + ], + }, + }), + ) + renderSheet() + + await user.click(await screen.findByRole('button', { name: /2\. Wire it up/ })) + + expect(await screen.findByText('Explaining this stop…')).toBeInTheDocument() + expect(screen.getByTestId('walkthrough-skeleton')).toBeInTheDocument() }) it('renders ready and pending stops while generating', async () => { diff --git a/frontend/src/components/session/ChangesWalkthroughSheet.tsx b/frontend/src/components/session/ChangesWalkthroughSheet.tsx index e7d3ff95d..dbbad664b 100644 --- a/frontend/src/components/session/ChangesWalkthroughSheet.tsx +++ b/frontend/src/components/session/ChangesWalkthroughSheet.tsx @@ -18,6 +18,7 @@ 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 { DEFAULT_WALKTHROUGH_SOURCE, describeWalkthroughSource, @@ -237,14 +238,29 @@ const WalkthroughSourceInputs = memo(function WalkthroughSourceInputs() { ) }) +/** + * A one-shot request to open the walkthrough on a given source. A new request object re-applies, so a + * repeated request for the same source takes effect; `source` undefined leaves the selection alone. + */ +export interface WalkthroughSourceRequest { + sessionId: string + source?: WalkthroughSource +} + interface ChangesWalkthroughProviderProps { sessionId: string active: boolean + sourceRequest?: WalkthroughSourceRequest 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) { +export function ChangesWalkthroughProvider({ + sessionId, + active, + sourceRequest, + children, +}: ChangesWalkthroughProviderProps) { const [source, setSource] = useState(DEFAULT_WALKTHROUGH_SOURCE) const [sourceKind, setSourceKind] = useState(DEFAULT_WALKTHROUGH_SOURCE.kind) const [baseInput, setBaseInput] = useState('') @@ -277,6 +293,20 @@ export function ChangesWalkthroughProvider({ sessionId, active, children }: Chan scrollRef.current?.scrollTo?.({ top: 0 }) }, [active, sessionId, sourceKey, walkthrough?.createdAt]) + useEffect(() => { + if (!sourceRequest) return + const requested = sourceRequest.source + if (!requested) return + setSource(requested) + setSourceKind(requested.kind) + if (requested.kind === 'branch' || requested.kind === 'pullRequest') { + setBaseInput(requested.base ?? '') + } + if (requested.kind === 'pullRequest') { + setNumberInput(String(requested.number)) + } + }, [sourceRequest]) + const hunksById = new Map(walkthrough?.hunks.map((hunk) => [hunk.id, hunk]) ?? []) const stops = walkthrough?.stops ?? [] const clampedIndex = stopIndex === null || stops.length === 0 ? null : Math.min(stopIndex, stops.length - 1) @@ -395,6 +425,67 @@ export const ChangesWalkthroughRegenerate = memo(function ChangesWalkthroughRege ) }) +const SKELETON_BAR = 'rounded bg-muted animate-pulse motion-reduce:animate-none' + +function WalkthroughSkeletonBar({ className }: { className?: string }) { + return
+} + +function WalkthroughHunkSkeleton() { + const diffLineWidths = ['w-3/4', 'w-1/2', 'w-5/6', 'w-2/3', 'w-1/3', 'w-4/5'] + return ( +
+
+ +
+ + + +
+
+
+ {diffLineWidths.map((width, row) => ( +
+ + +
+ ))} +
+
+ ) +} + +/** Placeholder that mirrors the walkthrough layout while it loads or generates; the overview variant adds the title and stop list. */ +function WalkthroughSkeleton({ variant = 'overview' }: { variant?: 'overview' | 'body' }) { + return ( +