diff --git a/app/desktop/src/__tests__/edgeClient.test.ts b/app/desktop/src/__tests__/edgeClient.test.ts index b0dbe5964..052358fd9 100644 --- a/app/desktop/src/__tests__/edgeClient.test.ts +++ b/app/desktop/src/__tests__/edgeClient.test.ts @@ -15,6 +15,8 @@ import { fetchRunDiff, fetchArtifacts, fetchPreviews, + applyRunDiff, + applyAllRunDiffs, } from '../api/edgeClient'; import { createDesktopPlatform } from '../platform/desktopPlatform'; import { mapEdgeAgentsToWorkbenchAgents } from '../platform/edgeCapabilityMapper'; @@ -709,4 +711,92 @@ describe('edgeClient', () => { ); }); }); + + describe('diff apply write-back (#1817)', () => { + it('posts a single hunk decision to Edge /v1/runs/{runId}/apply with snake_case fields', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + code: 'OK', + data: { + runId: 'run_abc123', + filePath: 'src/app.ts', + hunkIndex: 2, + accepted: true, + applied: true, + }, + }), + } as Response); + + const result = await applyRunDiff('run_abc123', { + filePath: 'src/app.ts', + hunkIndex: 2, + accepted: true, + workDir: '/work/project', + }); + + expect(result.applied).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/runs\/run_abc123\/apply$/), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + file_path: 'src/app.ts', + hunk_index: 2, + accepted: true, + workDir: '/work/project', + }), + }), + ); + }); + + it('posts batch decisions to Edge /v1/runs/{runId}/apply-all', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + code: 'OK', + data: { runId: 'run_abc123', applied: 2 }, + }), + } as Response); + + const result = await applyAllRunDiffs('run_abc123', { + decisions: [ + { filePath: 'src/app.ts', hunkIndex: 0, accepted: true }, + { filePath: 'src/app.ts', hunkIndex: 1, accepted: false }, + ], + workDir: '/work/project', + }); + + expect(result.applied).toBe(2); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/runs\/run_abc123\/apply-all$/), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + decisions: [ + { file_path: 'src/app.ts', hunk_index: 0, accepted: true }, + { file_path: 'src/app.ts', hunk_index: 1, accepted: false }, + ], + workDir: '/work/project', + }), + }), + ); + }); + + it('surfaces Edge apply failures as errors for the UI toast path', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: () => Promise.resolve({ error: { code: 'workspace_not_allowed', message: 'workdir not allowed' } }), + } as Response); + + await expect(applyRunDiff('run_abc123', { + filePath: 'src/app.ts', + hunkIndex: 0, + accepted: true, + workDir: '/forbidden', + })).rejects.toThrow('workdir not allowed'); + }); + }); }); diff --git a/app/desktop/src/api/edgeClient.ts b/app/desktop/src/api/edgeClient.ts index ec1b31cde..99ae0e9f6 100644 --- a/app/desktop/src/api/edgeClient.ts +++ b/app/desktop/src/api/edgeClient.ts @@ -27,6 +27,8 @@ import { AgentInfoSchema, RunInfoSchema, RunDiffSchema, + ApplyRunDiffResponseSchema, + ApplyAllRunDiffsResponseSchema, ArtifactSchema, PreviewSchema, ThreadInfoSchema, @@ -307,6 +309,65 @@ export async function fetchRunDiff(runId: string): Promise { return safeParse(RunDiffSchema, unwrapEdgeResponse(await res.json()), 'runDiff'); } +export interface ApplyRunDiffRequest { + filePath: string; + hunkIndex: number; + accepted: boolean; + workDir: string; +} + +export interface ApplyRunDiffResponse { + runId: string; + filePath: string; + hunkIndex: number; + accepted: boolean; + applied: boolean; +} + +export interface ApplyAllRunDiffsRequest { + decisions: Array>; + workDir: string; +} + +export interface ApplyAllRunDiffsResponse { + runId: string; + applied: number; +} + +/** Write one hunk accept/reject decision back into the run workdir. */ +export async function applyRunDiff(runId: string, request: ApplyRunDiffRequest): Promise { + const res = await edgeFetch(`${BASE}/v1/runs/${encodeURIComponent(runId)}/apply`, { + method: 'POST', + ...edgeDevRequestInit({}, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ + file_path: request.filePath, + hunk_index: request.hunkIndex, + accepted: request.accepted, + workDir: request.workDir, + }), + }); + if (!res.ok) throw await parseError(res); + return safeParse(ApplyRunDiffResponseSchema, unwrapEdgeResponse(await res.json()), 'applyRunDiff'); +} + +/** Batch variant of applyRunDiff for accept-all / reject-all decisions. */ +export async function applyAllRunDiffs(runId: string, request: ApplyAllRunDiffsRequest): Promise { + const res = await edgeFetch(`${BASE}/v1/runs/${encodeURIComponent(runId)}/apply-all`, { + method: 'POST', + ...edgeDevRequestInit({}, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ + decisions: request.decisions.map((decision) => ({ + file_path: decision.filePath, + hunk_index: decision.hunkIndex, + accepted: decision.accepted, + })), + workDir: request.workDir, + }), + }); + if (!res.ok) throw await parseError(res); + return safeParse(ApplyAllRunDiffsResponseSchema, unwrapEdgeResponse(await res.json()), 'applyAllRunDiffs'); +} + export async function fetchArtifacts(): Promise> { const res = await edgeFetch(`${BASE}/v1/artifacts`, edgeDevRequestInit()); if (!res.ok) throw await parseError(res); diff --git a/app/desktop/src/api/schemas.ts b/app/desktop/src/api/schemas.ts index c476aefdc..fa479d6eb 100644 --- a/app/desktop/src/api/schemas.ts +++ b/app/desktop/src/api/schemas.ts @@ -173,6 +173,21 @@ export const RunDiffSchema = z.object({ files: z.array(RunDiffFileSchema), }); +// POST /v1/runs/{runId}/apply — single hunk decision write-back. +export const ApplyRunDiffResponseSchema = z.object({ + runId: z.string(), + filePath: z.string(), + hunkIndex: z.number(), + accepted: z.boolean(), + applied: z.boolean(), +}); + +// POST /v1/runs/{runId}/apply-all — batch hunk decision write-back. +export const ApplyAllRunDiffsResponseSchema = z.object({ + runId: z.string(), + applied: z.number(), +}); + export const ArtifactSchema = z.object({ id: z.string(), runId: z.string(), diff --git a/app/desktop/src/platform/desktopPlatform.ts b/app/desktop/src/platform/desktopPlatform.ts index 4919a59f4..5e53cbd9a 100644 --- a/app/desktop/src/platform/desktopPlatform.ts +++ b/app/desktop/src/platform/desktopPlatform.ts @@ -20,19 +20,28 @@ import type { EvidenceRef } from '@shared/transcript'; import type { TranscriptBlock } from '@shared/transcript'; import type { RunInfo, StartRunRequest } from '@shared/types'; import { createHubClient } from '@/api/hubClient'; +import { applyAllRunDiffs, applyRunDiff } from '@/api/edgeClient'; import { edgeAuthHeaders } from '@/api/edgeAuth'; import { getAccessToken } from '@/hooks/useAuth'; import { getEdgeBaseUrl } from '@/config'; -import { fetchRuntimeSessions } from '@shared/workbench'; +import { fetchDesktopRuntimeSessions } from './desktopRuntimeSessions'; import { pickDesktopComposerAttachments } from './desktopAttachments'; -import { canOpenDesktopEvidencePreview, openDesktopEvidencePreview } from './desktopPreview'; +import { + canOpenDesktopEvidencePreview, + openDesktopEvidencePreview, + resolveDesktopEvidenceContentUrl, + resolveDesktopRuntimeEvidenceContent, +} from './desktopPreview'; import { resolveDesktopTargetPreference, type DesktopTargetPreference } from './targetPreference'; import { createDesktopSettingsAdapter } from './desktopSettingsAdapter'; export const DESKTOP_FALLBACK_CONVERSATION_ID = WORKBENCH_DEMO_FALLBACK_CONVERSATION_ID; -export const desktopConversations: WorkbenchConversation[] = workbenchDemoRuntimeStore.getSnapshot().conversations; +export const desktopConversations: WorkbenchConversation[] = + workbenchDemoRuntimeStore.getSnapshot().conversations; export const desktopAgents: WorkbenchAgent[] = demoWorkbenchAgents; -export const desktopTranscript: TranscriptBlock[] = resolveDemoWorkbenchTranscript(DESKTOP_FALLBACK_CONVERSATION_ID); +export const desktopTranscript: TranscriptBlock[] = resolveDemoWorkbenchTranscript( + DESKTOP_FALLBACK_CONVERSATION_ID, +); export function resolveDesktopPreviewTranscript(conversationId: string): TranscriptBlock[] { return workbenchDemoRuntimeStore.resolveTranscript(conversationId); @@ -176,6 +185,28 @@ export function createDesktopPlatform(options: DesktopPlatformOptions = {}): Des preview: { canOpenEvidence: canOpenDesktopEvidencePreview, openEvidence: options.openPreview ?? openDesktopEvidencePreview, + // Interactive diff write-back goes through the Local Edge apply endpoints + // (#1817). Desktop owns the Edge connection, so it owns this port leg. + async applyRunDiff(input) { + await applyRunDiff(input.runId, { + filePath: input.decision.filePath, + hunkIndex: input.decision.hunkIndex, + accepted: input.decision.accepted, + workDir: input.workDir, + }); + }, + async applyAllRunDiffs(input) { + await applyAllRunDiffs(input.runId, { + decisions: input.decisions.map((decision) => ({ + filePath: decision.filePath, + hunkIndex: decision.hunkIndex, + accepted: decision.accepted, + })), + workDir: input.workDir, + }); + }, + resolveContentUrl: resolveDesktopEvidenceContentUrl, + resolveRuntimeEvidenceContent: resolveDesktopRuntimeEvidenceContent, }, settings: createDesktopSettingsAdapter(), // exactOptionalPropertyTypes: omit key when undefined rather than assign undefined. @@ -190,7 +221,11 @@ export function createDesktopPlatform(options: DesktopPlatformOptions = {}): Des const run = await options.submitRun({ projectId: options.activeProjectId, threadId: options.activeThreadId, - prompt: formatComposerPromptWithContext(intent.text, intent.attachments, intent.mentions), + prompt: formatComposerPromptWithContext( + intent.text, + intent.attachments, + intent.mentions, + ), ...edgeSelectedAgent(intent), ...edgePermissionMode(intent), ...edgeWorkDir(intent), @@ -224,7 +259,7 @@ export function readLocalCliDiscovery(): Promise { /** Desktop host: Edge GET /v1/runtime-sessions via typed fetch (no foreign store). */ export async function readRuntimeSessions(limit = 50): Promise { - return fetchRuntimeSessions({ + return fetchDesktopRuntimeSessions({ edgeBaseUrl: getEdgeBaseUrl(), limit, fetchImpl: async (input, init) => { @@ -238,7 +273,8 @@ export async function readRuntimeSessions(limit = 50): Promise { - const mention = intent.mentions.find((item) => item.status !== 'unavailable') ?? intent.mentions[0]; + const mention = + intent.mentions.find((item) => item.status !== 'unavailable') ?? intent.mentions[0]; if (!mention) return {}; return { agentId: mention.runtimeId?.trim() || mention.id, diff --git a/app/desktop/src/platform/desktopPreview.test.ts b/app/desktop/src/platform/desktopPreview.test.ts new file mode 100644 index 000000000..53f24345c --- /dev/null +++ b/app/desktop/src/platform/desktopPreview.test.ts @@ -0,0 +1,58 @@ +// Desktop PreviewPort content-URL resolution (#1817): absolute evidence URLs +// pass through; host-relative API paths resolve against the Local Edge base +// URL because Desktop owns the Edge connection. +import { describe, expect, it } from 'vitest'; +import { + resolveDesktopEvidenceContentUrl, + resolveDesktopRuntimeEvidenceContent, +} from './desktopPreview'; + +describe('resolveDesktopEvidenceContentUrl', () => { + it('returns absolute evidence URLs unchanged', () => { + expect(resolveDesktopEvidenceContentUrl('http://127.0.0.1:4173/preview')).toBe( + 'http://127.0.0.1:4173/preview', + ); + expect(resolveDesktopEvidenceContentUrl('https://preview.example.com/app')).toBe( + 'https://preview.example.com/app', + ); + }); + + it('resolves Edge-relative content paths against the Local Edge base URL', () => { + // Default test-env Edge base URL (no override configured). + expect(resolveDesktopEvidenceContentUrl('/v1/runs/run-1/artifacts/artifact-1/content')).toBe( + 'http://127.0.0.1:3210/v1/runs/run-1/artifacts/artifact-1/content', + ); + expect(resolveDesktopEvidenceContentUrl('/v1/runs/run-1/previews/preview-1/content')).toBe( + 'http://127.0.0.1:3210/v1/runs/run-1/previews/preview-1/content', + ); + }); + + it('yields undefined for empty or non-URL references', () => { + expect(resolveDesktopEvidenceContentUrl('')).toBeUndefined(); + expect(resolveDesktopEvidenceContentUrl(' ')).toBeUndefined(); + expect(resolveDesktopEvidenceContentUrl('# reports/runtime.patch')).toBeUndefined(); + expect(resolveDesktopEvidenceContentUrl('data:text/plain;base64,abc')).toBeUndefined(); + }); +}); + +describe('resolveDesktopRuntimeEvidenceContent', () => { + it('maps artifact refs onto the Edge artifact content endpoint', () => { + expect( + resolveDesktopRuntimeEvidenceContent({ + kind: 'artifact', + runId: 'run-1', + id: 'artifact-1', + }), + ).toBe('http://127.0.0.1:3210/v1/runs/run-1/artifacts/artifact-1/content'); + }); + + it('maps preview refs onto the Edge preview content endpoint', () => { + expect( + resolveDesktopRuntimeEvidenceContent({ + kind: 'preview', + runId: 'run-2', + id: 'preview-2', + }), + ).toBe('http://127.0.0.1:3210/v1/runs/run-2/previews/preview-2/content'); + }); +}); diff --git a/app/desktop/src/platform/desktopPreview.ts b/app/desktop/src/platform/desktopPreview.ts index 3de080fac..e750f2248 100644 --- a/app/desktop/src/platform/desktopPreview.ts +++ b/app/desktop/src/platform/desktopPreview.ts @@ -1,6 +1,8 @@ import { open } from '@tauri-apps/plugin-shell'; import { resolveEvidencePreviewTarget } from '@shared/platform'; +import type { RuntimeEvidenceContentRef } from '@shared/platform'; import type { EvidenceRef } from '@shared/transcript'; +import { getEdgeBaseUrl } from '@/config'; export function canOpenDesktopEvidencePreview(evidence: EvidenceRef): boolean { return Boolean(resolveEvidencePreviewTarget(evidence)); @@ -14,3 +16,37 @@ export async function openDesktopEvidencePreview(evidence: EvidenceRef): Promise await open(target); } + +/** + * PreviewPort.resolveContentUrl for Desktop (#1817). + * Absolute http(s) URLs (e.g. a runtime preview server) are displayed as-is; + * host-relative API paths (`/v1/runs/…/content`) are owned by the Local Edge, + * so they are resolved against the Edge base URL. Anything else has no + * displayable source and yields undefined (UI shows an honest notice). + */ +export function resolveDesktopEvidenceContentUrl(contentRef: string): string | undefined { + const trimmed = contentRef.trim(); + if (!trimmed) return undefined; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith('/')) { + const edgeBase = getEdgeBaseUrl().replace(/\/+$/, ''); + if (!edgeBase) return undefined; + return `${edgeBase}${trimmed}`; + } + return undefined; +} + +/** + * PreviewPort.resolveRuntimeEvidenceContent for Desktop (#1817). + * Desktop owns the Local Edge connection, so it maps the shared structured + * ref onto the Edge run content endpoint. This is the only place where the + * Edge content path shape lives — shared code only carries the neutral ref. + */ +export function resolveDesktopRuntimeEvidenceContent( + ref: RuntimeEvidenceContentRef, +): string | undefined { + const edgeBase = getEdgeBaseUrl().replace(/\/+$/, ''); + if (!edgeBase) return undefined; + const collection = ref.kind === 'artifact' ? 'artifacts' : 'previews'; + return `${edgeBase}/v1/runs/${ref.runId}/${collection}/${ref.id}/content`; +} diff --git a/app/desktop/src/platform/desktopRuntimeSessions.test.ts b/app/desktop/src/platform/desktopRuntimeSessions.test.ts new file mode 100644 index 000000000..fe79a1c39 --- /dev/null +++ b/app/desktop/src/platform/desktopRuntimeSessions.test.ts @@ -0,0 +1,51 @@ +// Desktop host runtime-sessions fetch (#1192): the Edge REST path lives on +// the Desktop platform adapter; shared consumes the data through +// `HostDiagnosticsPort.listRuntimeSessions`. +import { describe, expect, it, vi } from 'vitest'; +import { fetchDesktopRuntimeSessions } from './desktopRuntimeSessions'; + +describe('fetchDesktopRuntimeSessions', () => { + it('unwraps Edge success envelope', async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + json: async () => ({ + code: 'OK', + data: { + items: [ + { + id: 'a', + runtime: 'claude-code', + title: 'a', + sourceMode: 'import', + updatedAt: '2026-07-19T01:00:00Z', + }, + ], + }, + }), + })) as unknown as typeof fetch; + + const items = await fetchDesktopRuntimeSessions({ + edgeBaseUrl: 'http://127.0.0.1:3210', + limit: 5, + fetchImpl, + }); + expect(items).toHaveLength(1); + expect(items[0]?.sourceMode).toBe('import'); + expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3210/v1/runtime-sessions?limit=5'); + }); + + it('rejects when the Edge endpoint fails', async () => { + const fetchImpl = vi.fn(async () => ({ + ok: false, + status: 502, + json: async () => ({}), + })) as unknown as typeof fetch; + + await expect( + fetchDesktopRuntimeSessions({ + edgeBaseUrl: 'http://127.0.0.1:3210', + fetchImpl, + }), + ).rejects.toThrow('Edge GET /v1/runtime-sessions failed: 502'); + }); +}); diff --git a/app/shared/src/workbench/sessionImport/fetchRuntimeSessions.ts b/app/desktop/src/platform/desktopRuntimeSessions.ts similarity index 67% rename from app/shared/src/workbench/sessionImport/fetchRuntimeSessions.ts rename to app/desktop/src/platform/desktopRuntimeSessions.ts index c966db7d7..ea73ebae2 100644 --- a/app/shared/src/workbench/sessionImport/fetchRuntimeSessions.ts +++ b/app/desktop/src/platform/desktopRuntimeSessions.ts @@ -1,6 +1,11 @@ -import type { RuntimeSessionImportItem } from './types'; +import type { RuntimeSessionImportItem } from '@shared/workbench'; -export type FetchRuntimeSessionsOptions = { +/** + * Desktop host: Edge GET /v1/runtime-sessions via typed fetch. + * The Edge REST path lives on the Desktop platform adapter only — shared + * code consumes this data through `HostDiagnosticsPort.listRuntimeSessions`. + */ +export type FetchDesktopRuntimeSessionsOptions = { edgeBaseUrl: string; limit?: number; fetchImpl?: typeof fetch; @@ -11,11 +16,8 @@ type Envelope = { items?: RuntimeSessionImportItem[]; }; -/** - * Fetch local runtime session summaries from Edge GET /v1/runtime-sessions. - */ -export async function fetchRuntimeSessions( - opts: FetchRuntimeSessionsOptions, +export async function fetchDesktopRuntimeSessions( + opts: FetchDesktopRuntimeSessionsOptions, ): Promise { const fetchImpl = opts.fetchImpl ?? fetch; const base = opts.edgeBaseUrl.replace(/\/$/, ''); diff --git a/app/shared/src/platform/index.ts b/app/shared/src/platform/index.ts index 60b151612..ecf2774d9 100644 --- a/app/shared/src/platform/index.ts +++ b/app/shared/src/platform/index.ts @@ -1,10 +1,10 @@ -export { - createMockTerminalPort, -} from './createMockPlatform'; +export { createMockTerminalPort } from './createMockPlatform'; export { resolveEvidencePreviewTarget } from './previewTargets'; export type { AgentHubPlatform, AgentHubSurface, + ApplyAllRunDiffsInput, + ApplyRunDiffInput, AttachmentPort, ConversationKind, ConversationPort, @@ -14,7 +14,9 @@ export type { LocalCliRuntimeId, PreviewPort, RedispatchTaskResult, + RunDiffHunkDecision, RunPort, + RuntimeEvidenceContentRef, RuntimeSessionSummary, SurfaceCapabilities, TerminalPort, diff --git a/app/shared/src/platform/types.ts b/app/shared/src/platform/types.ts index e170d1df5..cd8cb4ade 100644 --- a/app/shared/src/platform/types.ts +++ b/app/shared/src/platform/types.ts @@ -1,4 +1,9 @@ -import type { AttachmentRef, ComposerAttachment, ComposerIntent, ComposerSubmitResult } from '../composer/types'; +import type { + AttachmentRef, + ComposerAttachment, + ComposerIntent, + ComposerSubmitResult, +} from '../composer/types'; import type { EvidenceRef } from '../transcript'; import type { AgentActivitySnapshot } from '../transcript/agentActivity'; @@ -107,9 +112,75 @@ export interface AttachmentPort { uploadAttachment(file: File): Promise; } +/** + * A single hunk accept/reject decision from the interactive diff reviewer. + * Mirrors the Edge apply request body field-by-field (file_path / hunk_index / + * accepted) so host adapters can forward it without re-mapping. + */ +export interface RunDiffHunkDecision { + filePath: string; + hunkIndex: number; + accepted: boolean; +} + +/** Input for `PreviewPort.applyRunDiff` — one hunk decision plus run/workdir context. */ +export interface ApplyRunDiffInput { + runId: string; + /** Run working directory the hunk is written back into (Edge validates it). */ + workDir: string; + decision: RunDiffHunkDecision; +} + +/** Input for `PreviewPort.applyAllRunDiffs` — batch decisions plus run/workdir context. */ +export interface ApplyAllRunDiffsInput { + runId: string; + workDir: string; + decisions: RunDiffHunkDecision[]; +} + +/** + * Neutral reference to one runtime-evidence content item (artifact file or + * preview). Shared code owns this shape only — the host adapter maps it to + * its own content endpoint. No host REST paths live in shared. + */ +export interface RuntimeEvidenceContentRef { + kind: 'artifact' | 'preview'; + runId: string; + /** Artifact id or preview id. */ + id: string; +} + export interface PreviewPort { canOpenEvidence?(evidence: EvidenceRef): boolean; openEvidence(evidence: EvidenceRef): Promise; + /** + * Write one interactive-diff hunk decision back into the run workdir. + * Only surfaces with a Local Edge implement this (Desktop); Web omits it + * and the inspector degrades to an explicit read-only review notice + * instead of silently dropping the click. + */ + applyRunDiff?(input: ApplyRunDiffInput): Promise; + /** + * Batch variant of `applyRunDiff` for accept-all / reject-all. + * Same surface contract. + */ + applyAllRunDiffs?(input: ApplyAllRunDiffsInput): Promise; + /** + * Resolve an evidence content reference into a displayable URL. + * Absolute http(s) URLs are typically returned unchanged; host-relative + * API paths become absolute host URLs on surfaces that own the host + * (Desktop → Local Edge). Return `undefined` when the surface cannot + * serve the content so the UI can render an honest capability notice + * instead of a broken frame. + */ + resolveContentUrl?(contentRef: string): string | undefined; + /** + * Resolve a structured runtime-evidence content ref (artifact/preview + * from a Hub replay) into a displayable URL. Host adapters own the + * endpoint mapping; surfaces without the backing runtime (Web) omit it + * and the inspector renders a capability notice. + */ + resolveRuntimeEvidenceContent?(ref: RuntimeEvidenceContentRef): string | undefined; } export type LocalCliRuntimeId = 'codex' | 'claude-code' | 'opencode'; @@ -144,7 +215,7 @@ export type RuntimeSessionSummary = { export interface HostDiagnosticsPort { localCliDiscovery?(): Promise; /** - * Optional host-owned list of local runtime sessions (Edge GET /v1/runtime-sessions). + * Optional host-owned list of local runtime sessions. * Desktop only; Web must omit. Renderer never opens foreign session stores. */ listRuntimeSessions?(limit?: number): Promise; @@ -168,7 +239,11 @@ export interface MessageActionsPort { unpinMessage(messageId: string, sessionId: string): Promise; forwardMessage(messageId: string, targetSessionIds: string[]): Promise; recallMessage(messageId: string): Promise; - addMessageReaction(messageId: string, sessionId: string, reaction: { emoji: string }): Promise; + addMessageReaction( + messageId: string, + sessionId: string, + reaction: { emoji: string } + ): Promise; } /** Stable id for a host-owned terminal session (not a renderer process handle). */ diff --git a/app/shared/src/workbench/RightInspector.tsx b/app/shared/src/workbench/RightInspector.tsx index 5ae572cad..7bb0eb631 100644 --- a/app/shared/src/workbench/RightInspector.tsx +++ b/app/shared/src/workbench/RightInspector.tsx @@ -45,6 +45,7 @@ export function RightInspector({ maxWidth, minWidth, onOpenPreview, + previewPort, reviewFileRequest, runtimeEvidence, workDir, @@ -196,6 +197,7 @@ export function RightInspector({ overviewFiles={overviewFiles} overviewTasks={overviewTasks} previewFile={previewFile} + previewPort={previewPort} runResult={runResult} runtimeEvidence={runtimeEvidence} visibleTabs={visibleTabs} diff --git a/app/shared/src/workbench/RightInspectorModePanel.tsx b/app/shared/src/workbench/RightInspectorModePanel.tsx index 4c5aab089..500c7edb0 100644 --- a/app/shared/src/workbench/RightInspectorModePanel.tsx +++ b/app/shared/src/workbench/RightInspectorModePanel.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { RuntimeEvidenceSnapshot } from '../inspector'; +import type { PreviewPort } from '../platform'; import type { EvidenceRef, ContextUsageTranscriptBlock } from '../transcript'; import { BrowserModeBody, @@ -39,6 +40,8 @@ export interface RightInspectorModePanelProps { deployStatus: 'pending' | 'building' | 'deploying' | 'deployed' | 'failed' | undefined; files: EvidenceRef[]; previewFile: PreviewFile | null; + /** Platform preview port for the file preview router (#1817). */ + previewPort?: PreviewPort | undefined; onFileClick: (file: FileItem) => void; onClosePreview: () => void; onOpenPreview: ((evidence: EvidenceRef) => Promise) | undefined; @@ -64,6 +67,7 @@ export function RightInspectorModePanel({ deployStatus, files, previewFile, + previewPort, onFileClick, onClosePreview, onOpenPreview, @@ -109,6 +113,7 @@ export function RightInspectorModePanel({ files={files} overviewFiles={overviewFiles} previewFile={previewFile} + previewPort={previewPort} runtimeEvidence={runtimeEvidence} onClose={onClosePreview} onFallbackFileClick={onFileClick} diff --git a/app/shared/src/workbench/__tests__/inspector.test.tsx b/app/shared/src/workbench/__tests__/inspector.test.tsx index 76edc28e1..0c1ad1c29 100644 --- a/app/shared/src/workbench/__tests__/inspector.test.tsx +++ b/app/shared/src/workbench/__tests__/inspector.test.tsx @@ -4,18 +4,9 @@ // Shared vi.mock registration + suite hooks for the #1763 AgentHubWorkbench // test shards. Must stay the first import so mock factories register before // the component tree (and its virtua/@lobehub/icons deps) is evaluated. -import { - installWorkbenchTestHooks, - restoreInspectorTab, -} from './helpers'; +import { installWorkbenchTestHooks, restoreInspectorTab } from './helpers'; -import { - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createMockPlatform } from '../../platform/createMockPlatform'; import type { TranscriptBlock } from '../../transcript/types'; @@ -29,7 +20,6 @@ import { installWorkbenchTestHooks(); describe('AgentHubWorkbench', () => { - it('renders read-only runtime evidence snapshots in the right inspector', () => { const platform = createMockPlatform({ surface: 'desktop', @@ -45,49 +35,59 @@ describe('AgentHubWorkbench', () => { transcript={transcript} runtimeEvidence={{ runId: 'run-edge-1', - diffs: [{ - filePath: 'src/runtime.ts', - status: 'modified', - additions: 1, - deletions: 1, - editId: 'edit-runtime-1', - reviewStatus: 'needs_review', - canApply: false, - canRevert: true, - hunks: [{ - header: '@@ -1 +1 @@', - lines: [ - { type: 'deleted', content: 'old runtime' }, - { type: 'added', content: 'new runtime' }, + diffs: [ + { + filePath: 'src/runtime.ts', + status: 'modified', + additions: 1, + deletions: 1, + editId: 'edit-runtime-1', + reviewStatus: 'needs_review', + canApply: false, + canRevert: true, + hunks: [ + { + header: '@@ -1 +1 @@', + lines: [ + { type: 'deleted', content: 'old runtime' }, + { type: 'added', content: 'new runtime' }, + ], + }, ], - }], - }], - artifacts: [{ - id: 'artifact-1', - runId: 'run-edge-1', - threadId: 'thread-1', - kind: 'patch', - path: 'reports/runtime.patch', - sizeBytes: 2048, - createdAt: '2026-06-08T08:10:00.000Z', - }], - previews: [{ - id: 'preview-1', - runId: 'run-edge-1', - threadId: 'thread-1', - url: 'http://127.0.0.1:4173/preview', - status: 'ready', - createdAt: '2026-06-08T08:12:00.000Z', - }], + }, + ], + artifacts: [ + { + id: 'artifact-1', + runId: 'run-edge-1', + threadId: 'thread-1', + kind: 'patch', + path: 'reports/runtime.patch', + sizeBytes: 2048, + createdAt: '2026-06-08T08:10:00.000Z', + }, + ], + previews: [ + { + id: 'preview-1', + runId: 'run-edge-1', + threadId: 'thread-1', + url: 'http://127.0.0.1:4173/preview', + status: 'ready', + createdAt: '2026-06-08T08:12:00.000Z', + }, + ], sources: { diff: 'edge', artifacts: 'edge', previews: 'edge' }, }} - />, + /> ); expect(screen.getByText('运行证据')).toBeInTheDocument(); expect(screen.getByText('Hub replay artifact index: 1')).toBeInTheDocument(); expect(screen.getByText('Hub replay / run-edge-1')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '打开 reports/runtime.patch 只读预览' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '打开 reports/runtime.patch 只读预览' }) + ).toBeInTheDocument(); expect(screen.queryByText('B0 SQLite 迁移')).not.toBeInTheDocument(); expect(screen.queryByText('sqlite-migration-plan.md')).not.toBeInTheDocument(); @@ -103,15 +103,27 @@ describe('AgentHubWorkbench', () => { expect(screen.getByText('apply unavailable')).toBeInTheDocument(); expect(screen.getByText('revert available')).toBeInTheDocument(); expect(screen.getByLabelText('产物 metadata reports/runtime.patch')).toBeInTheDocument(); - expect(screen.getByRole('group', { name: 'Artifact workspace reports/runtime.patch' })).toBeInTheDocument(); + expect( + screen.getByRole('group', { name: 'Artifact workspace reports/runtime.patch' }) + ).toBeInTheDocument(); expect(screen.getByText('Topic: thread-1')).toBeInTheDocument(); expect(screen.getByText('Version: run-edge-1')).toBeInTheDocument(); expect(screen.getByText('Preview: ready')).toBeInTheDocument(); - expect(screen.getByText('Download: metadata only')).toBeInTheDocument(); - expect(screen.getByText('Export: evidence bundle ready')).toBeInTheDocument(); + expect( + screen.getByText( + 'Download: unavailable — no download action; preview resolves artifact content via host port' + ) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Export: unavailable — this panel has no export action (review-only evidence)' + ) + ).toBeInTheDocument(); expect(screen.getByText('Evidence: Edge')).toBeInTheDocument(); expect(screen.getByText('Diff projection: 1 file')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '查看产物 reports/runtime.patch' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: '查看产物 reports/runtime.patch' }) + ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '打开预览 preview-1' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /discard/i })).not.toBeInTheDocument(); @@ -121,7 +133,9 @@ describe('AgentHubWorkbench', () => { const diffPreview = screen.getByLabelText('src/runtime.ts 只读预览'); expect(diffPreview).toBeInTheDocument(); fireEvent.click(within(diffPreview).getByRole('tab', { name: 'Diff' })); - expect(within(diffPreview).getByText((_, node) => node?.textContent === '+new runtime')).toBeInTheDocument(); + expect( + within(diffPreview).getByText((_, node) => node?.textContent === '+new runtime') + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '返回概览' })); fireEvent.click(screen.getByRole('tab', { name: /文件/ })); @@ -152,7 +166,7 @@ describe('AgentHubWorkbench', () => { errors: { diff: true, artifacts: false, previews: true }, sources: { diff: 'none', artifacts: 'none', previews: 'none' }, }} - />, + /> ); restoreInspectorTab('files'); @@ -177,11 +191,13 @@ describe('AgentHubWorkbench', () => { previews: [], sources: { diff: 'none', artifacts: 'none', previews: 'none' }, }} - />, + /> ); expect(screen.getByText('暂无运行证据')).toBeInTheDocument(); - expect(screen.getByText(/Edge 已返回空 diff、artifact 和 preview snapshot。/)).toBeInTheDocument(); + expect( + screen.getByText(/Edge 已返回空 diff、artifact 和 preview snapshot。/) + ).toBeInTheDocument(); expect(screen.getByText(/Diff snapshot: None/)).toBeInTheDocument(); }); @@ -198,7 +214,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); const shell = screen.getByTestId('agenthub-workbench'); @@ -256,7 +272,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); Object.defineProperty(window, 'innerWidth', { @@ -296,31 +312,47 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); const inspector = within(screen.getByRole('complementary', { name: 'Right inspector' })); /* P76: tasks section open, files collapsed by default → one expanded section head. */ expect(inspector.getAllByRole('button', { expanded: true }).length).toBeGreaterThanOrEqual(1); - expect(inspector.getByRole('button', { name: '折叠 概览' })).toHaveAttribute('aria-expanded', 'true'); - expect(inspector.getByRole('button', { name: '展开 产物' })).toHaveAttribute('aria-expanded', 'false'); + expect(inspector.getByRole('button', { name: '折叠 概览' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(inspector.getByRole('button', { name: '展开 产物' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); fireEvent.click(inspector.getByRole('button', { name: '展开 产物' })); expect(inspector.getByText('Run v4')).toBeInTheDocument(); expect(inspector.getByText('产物索引: 1')).toBeInTheDocument(); expect(inspector.getByText('变更文件: 1')).toBeInTheDocument(); expect(inspector.getByText('工具调用: 1')).toBeInTheDocument(); - expect(inspector.getAllByText('app/shared/src/workbench/RightInspector.tsx').length).toBeGreaterThan(0); + expect( + inspector.getAllByText('app/shared/src/workbench/RightInspector.tsx').length + ).toBeGreaterThan(0); expect(inspector.getByText('产物')).toBeInTheDocument(); - fireEvent.click(inspector.getByRole('button', { name: '打开 app/shared/src/workbench/RightInspector.tsx 只读预览' })); + fireEvent.click( + inspector.getByRole('button', { + name: '打开 app/shared/src/workbench/RightInspector.tsx 只读预览', + }) + ); expect(screen.getByRole('tab', { name: /文件/ })).toHaveAttribute('aria-selected', 'true'); const filePreview = screen.getByRole('region', { name: 'app/shared/src/workbench/RightInspector.tsx 只读预览', }); expect(filePreview).toBeInTheDocument(); - expect(screen.getAllByText('app/shared/src/workbench/RightInspector.tsx').length).toBeGreaterThan(0); - expect(filePreview).toHaveAccessibleName('app/shared/src/workbench/RightInspector.tsx 只读预览'); + expect( + screen.getAllByText('app/shared/src/workbench/RightInspector.tsx').length + ).toBeGreaterThan(0); + expect(filePreview).toHaveAccessibleName( + 'app/shared/src/workbench/RightInspector.tsx 只读预览' + ); fireEvent.click(screen.getByRole('tab', { name: 'Diff' })); fireEvent.click(screen.getByRole('button', { name: /打开方式/ })); expect(screen.getByRole('menu', { name: '打开方式菜单' })).toBeInTheDocument(); @@ -333,11 +365,13 @@ describe('AgentHubWorkbench', () => { fireEvent.click(screen.getByRole('button', { name: '新建右侧窗口' })); fireEvent.click(screen.getByRole('menuitem', { name: /恢复 文件/ })); expect(screen.getByRole('tab', { name: /文件/ })).toHaveAttribute('aria-selected', 'true'); - expect(openEvidence).not.toHaveBeenCalledWith(expect.objectContaining({ - id: 'ev-file', - kind: 'file', - label: 'app/shared/src/workbench/RightInspector.tsx', - })); + expect(openEvidence).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ev-file', + kind: 'file', + label: 'app/shared/src/workbench/RightInspector.tsx', + }) + ); restoreInspectorTab('browser'); fireEvent.click(screen.getByRole('tab', { name: /浏览器/ })); @@ -351,15 +385,20 @@ describe('AgentHubWorkbench', () => { expect(screen.getByRole('button', { name: '刷新' })).toBeInTheDocument(); expect(screen.getByText('about:blank')).toBeInTheDocument(); expect(screen.getByText('只读预览')).toBeInTheDocument(); - expect(openEvidence).not.toHaveBeenCalledWith(expect.objectContaining({ - id: 'ev-artifact', - kind: 'artifact', - label: 'visual-smoke-desktop.png', - })); + expect(openEvidence).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ev-artifact', + kind: 'artifact', + label: 'visual-smoke-desktop.png', + }) + ); expect(platform.openedEvidence).toHaveLength(0); fireEvent.click(screen.getByRole('button', { name: '关闭预览' })); - expect(inspector.getByRole('button', { name: '折叠 概览' })).toHaveAttribute('aria-expanded', 'true'); + expect(inspector.getByRole('button', { name: '折叠 概览' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); fireEvent.click(screen.getByRole('button', { name: '新建右侧窗口' })); const browserMenuItem = screen @@ -401,12 +440,14 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={subtaskTranscript} - />, + /> ); const transcriptRegion = screen.getByRole('region', { name: 'Transcript' }); expect(within(transcriptRegion).queryByText('Card Contract Auditor')).not.toBeInTheDocument(); - expect(within(transcriptRegion).queryByText('Audit chat card contracts')).not.toBeInTheDocument(); + expect( + within(transcriptRegion).queryByText('Audit chat card contracts') + ).not.toBeInTheDocument(); const inspector = within(screen.getByRole('complementary', { name: 'Right inspector' })); expect(inspector.getByText('Agent 调度树')).toBeInTheDocument(); @@ -453,7 +494,10 @@ describe('AgentHubWorkbench', () => { additions: 12, deletions: 0, lines: [ - { type: 'add', content: '+ CREATE TABLE IF NOT EXISTS chat_threads (id TEXT PRIMARY KEY);' }, + { + type: 'add', + content: '+ CREATE TABLE IF NOT EXISTS chat_threads (id TEXT PRIMARY KEY);', + }, ], }, ], @@ -466,20 +510,26 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={reviewTranscript} - />, + /> ); - expect(screen.queryByText('+ CREATE TABLE IF NOT EXISTS chat_threads (id TEXT PRIMARY KEY);')).not.toBeInTheDocument(); + expect( + screen.queryByText('+ CREATE TABLE IF NOT EXISTS chat_threads (id TEXT PRIMARY KEY);') + ).not.toBeInTheDocument(); // run_step_group blocks are sidebar-only — files appear in inspector overview (expand 产物 if collapsed). const inspector = within(screen.getByRole('complementary', { name: 'Right inspector' })); const expandFiles = inspector.queryByRole('button', { name: '展开 产物' }); if (expandFiles) fireEvent.click(expandFiles); - fireEvent.click(screen.getByRole('button', { name: '打开 migrations/0007_chat_threads.sql 只读预览' })); + fireEvent.click( + screen.getByRole('button', { name: '打开 migrations/0007_chat_threads.sql 只读预览' }) + ); expect(screen.getByRole('tab', { name: /文件/ })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('region', { - name: 'migrations/0007_chat_threads.sql 只读预览', - })).toBeInTheDocument(); + expect( + screen.getByRole('region', { + name: 'migrations/0007_chat_threads.sql 只读预览', + }) + ).toBeInTheDocument(); const preview = screen.getByRole('region', { name: 'migrations/0007_chat_threads.sql 只读预览', }); @@ -502,7 +552,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={[]} - />, + /> ); restoreInspectorTab('browser'); @@ -528,7 +578,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); restoreInspectorTab('files'); @@ -576,7 +626,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); restoreInspectorTab('files'); @@ -619,7 +669,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); } @@ -647,33 +697,44 @@ describe('AgentHubWorkbench', () => { window.localStorage.setItem(widthKey, '520'); renderPanelHarness(); expect(screen.getByTestId('agenthub-workbench')).toHaveStyle({ '--inspector-w': '520px' }); - expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })) - .toHaveAttribute('aria-valuenow', '520'); + expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })).toHaveAttribute( + 'aria-valuenow', + '520' + ); }); it('clamps an over-max persisted width to 760', () => { window.localStorage.setItem(widthKey, '9999'); renderPanelHarness(); expect(screen.getByTestId('agenthub-workbench')).toHaveStyle({ '--inspector-w': '760px' }); - expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })) - .toHaveAttribute('aria-valuenow', '760'); + expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })).toHaveAttribute( + 'aria-valuenow', + '760' + ); }); it('clamps an under-min persisted width to 48', () => { window.localStorage.setItem(widthKey, '10'); renderPanelHarness(); expect(screen.getByTestId('agenthub-workbench')).toHaveStyle({ '--inspector-w': '48px' }); - expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })) - .toHaveAttribute('aria-valuenow', '48'); + expect(screen.getByRole('separator', { name: '调整右侧栏宽度' })).toHaveAttribute( + 'aria-valuenow', + '48' + ); }); it('restores the persisted collapsed state', () => { window.localStorage.setItem(collapsedKey, 'true'); renderPanelHarness(); - expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-inspector-collapsed', 'true'); + expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute( + 'data-inspector-collapsed', + 'true' + ); // byRole skips aria-hidden elements (and their names) — query the DOM directly. - expect(document.querySelector('aside[aria-label="Right inspector"]')) - .toHaveAttribute('aria-hidden', 'true'); + expect(document.querySelector('aside[aria-label="Right inspector"]')).toHaveAttribute( + 'aria-hidden', + 'true' + ); expect(screen.getByRole('button', { name: '展开右侧概览' })).toBeInTheDocument(); }); }); @@ -696,7 +757,7 @@ describe('AgentHubWorkbench', () => { platform={platform} conversations={platform.seed.conversations} transcript={transcript} - />, + /> ); } @@ -704,12 +765,18 @@ describe('AgentHubWorkbench', () => { renderWithSettings(async () => ({ inspectorVisible: 'false' })); // Chat page default: inspector open. - expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-inspector-collapsed', 'false'); + expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute( + 'data-inspector-collapsed', + 'false' + ); // Settings load lazily when a non-chat page mounts (WorkbenchRoutes → useWorkbenchSettingsRoute). fireEvent.click(screen.getByRole('button', { name: '设置' })); await waitFor(() => { - expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-inspector-collapsed', 'true'); + expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute( + 'data-inspector-collapsed', + 'true' + ); }); }); @@ -724,7 +791,10 @@ describe('AgentHubWorkbench', () => { expect(within(row).getByRole('switch')).toHaveAttribute('aria-checked', 'true'); fireEvent.click(within(row).getByRole('switch')); - expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-inspector-collapsed', 'true'); + expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute( + 'data-inspector-collapsed', + 'true' + ); }); it('keeps the inspector state untouched when inspectorVisible stays true', async () => { @@ -734,7 +804,10 @@ describe('AgentHubWorkbench', () => { await waitFor(() => { expect(screen.getByText('右侧概览')).toBeInTheDocument(); }); - expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute('data-inspector-collapsed', 'false'); + expect(screen.getByTestId('agenthub-workbench')).toHaveAttribute( + 'data-inspector-collapsed', + 'false' + ); }); }); }); diff --git a/app/shared/src/workbench/inspector/FilePreviewHelpers.test.ts b/app/shared/src/workbench/inspector/FilePreviewHelpers.test.ts index 3da10e62a..c1f4808fd 100644 --- a/app/shared/src/workbench/inspector/FilePreviewHelpers.test.ts +++ b/app/shared/src/workbench/inspector/FilePreviewHelpers.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { PreviewPort } from '../../platform'; import { defaultPreviewMode, diffLineClass, @@ -13,6 +14,7 @@ import { openWithIconClass, openWithItems, resolveNativeMode, + resolvePreviewContentUrl, syntheticDiff, } from './FilePreviewHelpers'; import styles from './FilePreview.module.css'; @@ -80,3 +82,48 @@ describe('FilePreviewHelpers', () => { expect(openWithIconClass('vscode')).toBeTruthy(); }); }); + +describe('resolvePreviewContentUrl (#1817)', () => { + it('returns undefined for empty or prose fallback content', () => { + expect(resolvePreviewContentUrl(undefined, undefined)).toBeUndefined(); + expect(resolvePreviewContentUrl('', undefined)).toBeUndefined(); + expect(resolvePreviewContentUrl('# reports/runtime.patch', undefined)).toBeUndefined(); + expect(resolvePreviewContentUrl('Read-only runtime diff evidence.', undefined)).toBeUndefined(); + }); + + it('passes absolute http(s) URLs through without a port', () => { + expect(resolvePreviewContentUrl('http://127.0.0.1:4173/preview', undefined)).toBe( + 'http://127.0.0.1:4173/preview' + ); + expect(resolvePreviewContentUrl('https://preview.example.com/app', undefined)).toBe( + 'https://preview.example.com/app' + ); + }); + + it('delegates host-relative API paths to the port resolver', () => { + const resolveContentUrl = vi.fn((ref: string) => `http://127.0.0.1:3210${ref}`); + const port: PreviewPort = { + openEvidence: vi.fn(), + resolveContentUrl, + }; + expect(resolvePreviewContentUrl('/host-content/run-1/artifacts/artifact-1/content', port)).toBe( + 'http://127.0.0.1:3210/host-content/run-1/artifacts/artifact-1/content' + ); + expect(resolveContentUrl).toHaveBeenCalledWith( + '/host-content/run-1/artifacts/artifact-1/content' + ); + }); + + it('yields undefined for host-relative paths when the port cannot resolve them (web boundary)', () => { + const port: PreviewPort = { + openEvidence: vi.fn(), + resolveContentUrl: () => undefined, + }; + expect( + resolvePreviewContentUrl('/host-content/run-1/previews/preview-1/content', port) + ).toBeUndefined(); + expect( + resolvePreviewContentUrl('/host-content/run-1/artifacts/artifact-1/content', undefined) + ).toBeUndefined(); + }); +}); diff --git a/app/shared/src/workbench/inspector/FilePreviewHelpers.ts b/app/shared/src/workbench/inspector/FilePreviewHelpers.ts index 03e77eb9d..c1783fd83 100644 --- a/app/shared/src/workbench/inspector/FilePreviewHelpers.ts +++ b/app/shared/src/workbench/inspector/FilePreviewHelpers.ts @@ -1,3 +1,4 @@ +import type { PreviewPort } from '../../platform'; import { highlightLine } from '../../ui/syntaxHighlight'; import type { DesignOpenWithIconName } from '../designIcons'; import styles from './FilePreview.module.css'; @@ -102,7 +103,13 @@ export function diffLineClass(line: string, css: typeof styles): string { } export function highlightDiffLine(line: string, language: string): string { - if (!line || line.startsWith('diff ') || line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')) { + if ( + !line || + line.startsWith('diff ') || + line.startsWith('@@') || + line.startsWith('---') || + line.startsWith('+++') + ) { return highlightLine(line, ''); } const marker = line[0] === '+' || line[0] === '-' || line[0] === ' ' ? line[0] : ''; @@ -122,3 +129,30 @@ export function openWithIconClass(name: DesignOpenWithIconName): string { return styles.brandIconSvg ?? ''; } } + +/** + * Resolve an evidence content reference (from `PreviewFile.content`) into a + * displayable URL for native previews (PDF iframe / image). + * + * - Empty/non-URL references (fallback prose such as `# path` metadata) yield + * `undefined` so the renderer shows an honest capability notice instead of + * an empty frame. + * - Absolute http(s) URLs are used unchanged on every surface. + * - Host-relative API paths require a `PreviewPort` that owns the host: + * Desktop resolves them against the Local Edge base URL, Web returns + * `undefined` (no Local Edge access, Hub-only boundary). + * - Structured runtime-evidence refs (`PreviewFile.contentRef`) are resolved + * separately via `PreviewPort.resolveRuntimeEvidenceContent` (#1817). + */ +export function resolvePreviewContentUrl( + contentRef: string | undefined, + previewPort: PreviewPort | undefined +): string | undefined { + const trimmed = contentRef?.trim() ?? ''; + if (!trimmed) return undefined; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith('/')) { + return previewPort?.resolveContentUrl?.(trimmed); + } + return undefined; +} diff --git a/app/shared/src/workbench/inspector/FilePreviewRouter.apply.test.tsx b/app/shared/src/workbench/inspector/FilePreviewRouter.apply.test.tsx new file mode 100644 index 000000000..badd5e735 --- /dev/null +++ b/app/shared/src/workbench/inspector/FilePreviewRouter.apply.test.tsx @@ -0,0 +1,238 @@ +// Interactive diff apply dispatch through the platform PreviewPort (#1817). +// +// The shared package owns no Local Edge, so hunk write-back must be routed +// through the port: desktop-shaped ports (apply methods present) receive the +// decisions, web-shaped ports (apply methods absent — Hub-only boundary) +// degrade to explicit read-only feedback instead of silent console errors. +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PreviewPort } from '../../platform'; +import { useToastStore } from '../../ui/toast/toastStore'; +import { FilePreviewRouter, type PreviewFile } from './FilePreviewRouter'; + +function interactiveDiffFile(): PreviewFile { + return { + name: 'src/app.ts', + type: 'diff', + interactiveDiff: { + runId: 'run-1', + workDir: '/work/project', + fileDiff: { + filePath: 'src/app.ts', + status: 'modified', + additions: 1, + deletions: 1, + hunks: [{ + header: '@@ -1 +1 @@', + lines: [ + { type: 'deleted', content: 'const legacy = true;' }, + { type: 'added', content: 'const modern = true;' }, + ], + }], + }, + }, + }; +} + +/** Desktop-shaped port: apply methods present (Local Edge write-back). */ +function desktopShapedPort(overrides: { + applyRunDiff?: PreviewPort['applyRunDiff']; + applyAllRunDiffs?: PreviewPort['applyAllRunDiffs']; +} = {}): PreviewPort { + return { + openEvidence: vi.fn().mockResolvedValue(undefined), + applyRunDiff: overrides.applyRunDiff ?? vi.fn().mockResolvedValue(undefined), + applyAllRunDiffs: overrides.applyAllRunDiffs ?? vi.fn().mockResolvedValue(undefined), + }; +} + +/** Web-shaped port: Hub-only surface, no apply methods, content resolution only. */ +function webShapedPort(): PreviewPort { + return { + openEvidence: vi.fn().mockResolvedValue(undefined), + resolveContentUrl: (ref: string) => (/^https?:\/\//i.test(ref) ? ref : undefined), + }; +} + +function currentToasts() { + return useToastStore.getState().toasts; +} + +describe('FilePreviewRouter interactive diff apply dispatch (#1817)', () => { + beforeEach(() => { + useToastStore.setState({ toasts: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + useToastStore.setState({ toasts: [] }); + }); + + it('routes a single hunk accept through PreviewPort.applyRunDiff and toasts success', async () => { + const port = desktopShapedPort(); + render( + , + ); + + // Supported surface: no read-only capability notice. + expect(screen.queryByRole('note')).toBeNull(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Accept line' })[0]!); + + await waitFor(() => { + expect(port.applyRunDiff).toHaveBeenCalledTimes(1); + }); + expect(port.applyRunDiff).toHaveBeenCalledWith({ + runId: 'run-1', + workDir: '/work/project', + decision: { filePath: 'src/app.ts', hunkIndex: 0, accepted: true }, + }); + + await waitFor(() => { + expect(currentToasts().some((toast) => toast.type === 'success')).toBe(true); + }); + }); + + it('routes a single hunk reject through PreviewPort.applyRunDiff with accepted=false', async () => { + const port = desktopShapedPort(); + render( + , + ); + + fireEvent.click(screen.getAllByRole('button', { name: 'Reject line' })[0]!); + + await waitFor(() => { + expect(port.applyRunDiff).toHaveBeenCalledWith({ + runId: 'run-1', + workDir: '/work/project', + decision: { filePath: 'src/app.ts', hunkIndex: 0, accepted: false }, + }); + }); + }); + + it('surfaces port failures as an error toast instead of failing silently', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const port = desktopShapedPort({ + applyRunDiff: vi.fn().mockRejectedValue(new Error('workdir not allowed')), + }); + render( + , + ); + + fireEvent.click(screen.getAllByRole('button', { name: 'Accept line' })[0]!); + + await waitFor(() => { + expect(currentToasts().some((toast) => toast.type === 'error')).toBe(true); + }); + // Silent console fallback is the defect this lane removes. + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('routes accept-all through PreviewPort.applyAllRunDiffs with every hunk decision', async () => { + const port = desktopShapedPort(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Accept All' })); + + await waitFor(() => { + expect(port.applyAllRunDiffs).toHaveBeenCalledTimes(1); + }); + expect(port.applyAllRunDiffs).toHaveBeenCalledWith({ + runId: 'run-1', + workDir: '/work/project', + decisions: [{ filePath: 'src/app.ts', hunkIndex: 0, accepted: true }], + }); + + await waitFor(() => { + expect(currentToasts().some((toast) => toast.type === 'success')).toBe(true); + }); + }); + + it('routes reject-all through PreviewPort.applyAllRunDiffs with accepted=false decisions', async () => { + const port = desktopShapedPort(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Reject All' })); + + await waitFor(() => { + expect(port.applyAllRunDiffs).toHaveBeenCalledWith({ + runId: 'run-1', + workDir: '/work/project', + decisions: [{ filePath: 'src/app.ts', hunkIndex: 0, accepted: false }], + }); + }); + }); + + it('web-shaped port renders the read-only notice and warns on apply attempts', async () => { + const port = webShapedPort(); + render( + , + ); + + const notice = screen.getByRole('note'); + expect(notice).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'Accept line' })[0]!); + + await waitFor(() => { + expect(currentToasts().some((toast) => toast.type === 'warning')).toBe(true); + }); + // Unsupported surface never attempts a write-back. + expect(port.applyRunDiff).toBeUndefined(); + expect(port.applyAllRunDiffs).toBeUndefined(); + }); + + it('accept-all on an unsupported surface warns instead of dispatching', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Accept All' })); + + await waitFor(() => { + expect(currentToasts().some((toast) => toast.type === 'warning')).toBe(true); + }); + }); + + it('missing port entirely still degrades to the explicit read-only notice', () => { + render( + , + ); + expect(screen.getByRole('note')).toBeInTheDocument(); + }); +}); diff --git a/app/shared/src/workbench/inspector/FilePreviewRouter.tsx b/app/shared/src/workbench/inspector/FilePreviewRouter.tsx index 02b5a6823..23f929775 100644 --- a/app/shared/src/workbench/inspector/FilePreviewRouter.tsx +++ b/app/shared/src/workbench/inspector/FilePreviewRouter.tsx @@ -1,14 +1,20 @@ -import React, { useCallback, useMemo } from 'react'; -import { parseError } from '../../errors'; +import React, { useCallback, useMemo, useState } from 'react'; +import type { PreviewPort, RuntimeEvidenceContentRef } from '../../platform'; import type { FileDiff } from '../../types/chat'; -import { DiffReviewPanel, type DiffHunkDecision, type DiffReviewFile } from '../../ui/DiffReviewPanel'; +import { + DiffReviewPanel, + type DiffHunkDecision, + type DiffReviewFile, +} from '../../ui/DiffReviewPanel'; import { DocxPreview } from '../../ui/DocxPreview'; import { SlideshowPreview } from '../../ui/SlideshowPreview'; import { TablePreview } from '../../ui/TablePreview'; import { PREVIEW_SANDBOX_SRCDOC } from '../../ui/previewSandbox'; +import { useToastStore } from '../../ui/toast/toastStore'; import { DesignFileIcon } from '../designIcons'; import styles from '../AgentHubWorkbench.module.css'; import { FilePreview } from './FilePreview'; +import { resolvePreviewContentUrl } from './FilePreviewHelpers'; import type { FileItem } from './OverviewPanel'; /* ═══════════════════════════════════════════════════════════════════════ @@ -16,33 +22,58 @@ import type { FileItem } from './OverviewPanel'; code viewer based on the filename extension. Routing table: - interactiveDiff -> InteractiveDiffPreview (accept/reject write-back) + interactiveDiff -> InteractiveDiffPreview (accept/reject write-back + via PreviewPort; read-only notice on surfaces + without a Local Edge) .pptx -> SlideshowPreview .ppt -> SlideshowPreview (legacy kind) .xlsx / .xls / .csv -> TablePreview .docx -> DocxPreview - .pdf -> browser-native PDF iframe + .pdf -> browser-native PDF iframe (needs a resolvable + content URL; honest notice otherwise) .html / .htm -> sandboxed HTML iframe (srcDoc) - .png/.jpg/... -> image placeholder (URL-loaded later) + .png/.jpg/... -> image via evidence content URL (honest notice + when no resolvable URL exists) .txt / .log -> plain
      everything else       -> FilePreview (code / diff / markdown)
+
+   Interactive diff apply and content-URL resolution go through the platform
+   `PreviewPort` (#1817): the shared package owns no Local Edge, so the
+   renderer never hardcodes an Edge base URL. Desktop implements the port
+   against Edge REST; Web omits apply (Hub-only boundary) and the router
+   degrades to explicit read-only feedback instead of silent console errors.
    ═══════════════════════════════════════════════════════════════════════ */
 
 export type PreviewFile = FileItem & {
   content?: string | undefined;
   diffContent?: string | undefined;
   owner?: string | undefined;
+  /**
+   * Structured ref to host-owned runtime-evidence content (artifact file or
+   * preview). Resolved via `PreviewPort.resolveRuntimeEvidenceContent`;
+   * shared code never constructs host REST paths itself (#1817).
+   */
+  contentRef?: RuntimeEvidenceContentRef | undefined;
   /** When present, this is an interactive diff from a run — enables accept/reject with Edge apply. */
-  interactiveDiff?: {
-    runId: string;
-    fileDiff: FileDiff;
-    workDir: string;
-  } | undefined;
+  interactiveDiff?:
+    | {
+        runId: string;
+        fileDiff: FileDiff;
+        workDir: string;
+      }
+    | undefined;
 };
 
 export interface FilePreviewRouterProps {
   file: PreviewFile;
   onClose: () => void;
+  /**
+   * Platform preview port for capabilities the shared package cannot own:
+   * diff hunk write-back (Edge apply) and evidence content-URL resolution.
+   * Optional so fixture/demo shells keep rendering; absent capabilities
+   * degrade to explicit user-facing notices.
+   */
+  previewPort?: PreviewPort | undefined;
 }
 
 type FilePreviewKind =
@@ -74,8 +105,8 @@ function detectFilePreviewKind(fileName: string): FilePreviewKind {
 }
 
 /** Extract a fetchable URL from a PreviewFile's content field.
- *  runtimeEvidenceOverviewFiles puts real Edge API paths (e.g. /v1/runs/…/content)
- *  or full preview URLs into `content`; fallback text starts with `#` or prose. */
+ *  Overview mappers put full preview URLs into `content`; structured
+ *  runtime-evidence refs are carried by `contentRef` instead (#1817). */
 function extractFileUrl(content: string | undefined): string {
   if (!content) return '';
   // Real URLs start with '/' (relative API path) or 'http'
@@ -85,103 +116,104 @@ function extractFileUrl(content: string | undefined): string {
   return '';
 }
 
-/** Replaces the now-deleted shared Edge REST client apply fns (RFC A-V3 §4.1 —
- *  zero external consumers, stored in shared/src as dead surface).  edgeBaseUrl is unconfigured here
- *  because the shared package has no Local Edge; Desktop drives the Edge
- *  connection through its own wrappers.  InteractiveDiffPreview was already
- *  a known defect per verify-shared-boundary.py (audit-A P → PreviewPort). */
-const edgeBaseUrl = '';
-
-async function postJson(path: string, body: unknown): Promise {
-  if (!edgeBaseUrl) {
-    throw new Error('Edge base URL not configured — route through PreviewPort instead');
-  }
-  const res = await fetch(`${edgeBaseUrl}${path}`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(body),
-  });
-  if (!res.ok) {
-    throw await parseError(res);
-  }
-  return res.json() as Promise;
-}
-
-async function applyRunDiff(
-  runId: string,
-  body: { file_path: string; hunk_index: number; accepted: boolean; workDir: string },
-): Promise<{ code: string; data: unknown }> {
-  return postJson(`/v1/runs/${encodeURIComponent(runId)}/apply`, body);
+function describeError(err: unknown): string {
+  if (err instanceof Error) return err.message;
+  return String(err);
 }
 
-async function applyAllRunDiffs(
-  runId: string,
-  body: { decisions: Array<{ file_path: string; hunk_index: number; accepted: boolean }>; workDir: string },
-): Promise<{ code: string; data: unknown }> {
-  return postJson(`/v1/runs/${encodeURIComponent(runId)}/apply-all`, body);
-}
+const APPLY_UNSUPPORTED_NOTE =
+  '当前端不支持将 diff 写回工作区(仅桌面本地 Edge 支持),当前为只读评审。';
 
-/** Interactive diff preview with hunk accept/reject that writes back to the workdir via Edge API. */
+/** Interactive diff preview with hunk accept/reject that writes back to the workdir via the platform PreviewPort. */
 function InteractiveDiffPreview({
   file,
   onClose,
+  previewPort,
 }: {
   file: PreviewFile;
   onClose: () => void;
+  previewPort?: PreviewPort | undefined;
 }): React.ReactElement {
   const interactiveDiff = file.interactiveDiff;
+  const showToast = useToastStore((state) => state.showToast);
+  const applySupported = Boolean(previewPort?.applyRunDiff && previewPort?.applyAllRunDiffs);
 
   // Hooks run unconditionally so the hook order is stable if a file toggles
   // between interactive and non-interactive diff states across renders.
   const reviewFiles: DiffReviewFile[] = useMemo(() => {
     if (!interactiveDiff) return [];
     const { fileDiff } = interactiveDiff;
-    return [{
-      filePath: fileDiff.filePath,
-      status: fileDiff.status === 'untracked' ? 'added' : fileDiff.status,
-      additions: fileDiff.additions,
-      deletions: fileDiff.deletions,
-      hunks: fileDiff.hunks as unknown as DiffReviewFile['hunks'],
-    }];
+    return [
+      {
+        filePath: fileDiff.filePath,
+        status: fileDiff.status === 'untracked' ? 'added' : fileDiff.status,
+        additions: fileDiff.additions,
+        deletions: fileDiff.deletions,
+        hunks: fileDiff.hunks as unknown as DiffReviewFile['hunks'],
+      },
+    ];
   }, [interactiveDiff]);
 
   const handleApplyHunk = useCallback(
     async (decision: DiffHunkDecision) => {
       if (!interactiveDiff) return;
+      if (!previewPort?.applyRunDiff) {
+        showToast('warning', APPLY_UNSUPPORTED_NOTE);
+        return;
+      }
       try {
-        await applyRunDiff(interactiveDiff.runId, {
-          file_path: decision.filePath,
-          hunk_index: decision.hunkIndex,
-          accepted: decision.accepted,
+        await previewPort.applyRunDiff({
+          runId: interactiveDiff.runId,
           workDir: interactiveDiff.workDir,
+          decision: {
+            filePath: decision.filePath,
+            hunkIndex: decision.hunkIndex,
+            accepted: decision.accepted,
+          },
         });
+        showToast(
+          'success',
+          decision.accepted
+            ? `已应用 hunk:${decision.filePath} #${decision.hunkIndex + 1}`
+            : `已拒绝 hunk:${decision.filePath} #${decision.hunkIndex + 1}`
+        );
       } catch (err) {
-        console.error('RightInspector: applyRunDiff failed for hunk:', decision.filePath, decision.hunkIndex, err);
+        showToast('error', `Diff 应用失败:${describeError(err)}`);
       }
     },
-    [interactiveDiff],
+    [interactiveDiff, previewPort, showToast]
   );
 
   const handleApplyAllHunks = useCallback(
     async (decisions: DiffHunkDecision[]) => {
       if (!interactiveDiff) return;
+      if (!previewPort?.applyAllRunDiffs) {
+        showToast('warning', APPLY_UNSUPPORTED_NOTE);
+        return;
+      }
       try {
-        await applyAllRunDiffs(interactiveDiff.runId, {
-          decisions: decisions.map((d) => ({
-            file_path: d.filePath,
-            hunk_index: d.hunkIndex,
-            accepted: d.accepted,
-          })),
+        await previewPort.applyAllRunDiffs({
+          runId: interactiveDiff.runId,
           workDir: interactiveDiff.workDir,
+          decisions: decisions.map((item) => ({
+            filePath: item.filePath,
+            hunkIndex: item.hunkIndex,
+            accepted: item.accepted,
+          })),
         });
+        const acceptedCount = decisions.filter((item) => item.accepted).length;
+        showToast(
+          'success',
+          `已批量处理 ${decisions.length} 个 hunk(应用 ${acceptedCount},拒绝 ${decisions.length - acceptedCount})`
+        );
       } catch (err) {
-        console.error('RightInspector: applyAllRunDiffs failed:', decisions.length, 'hunks,', err);
+        showToast('error', `Diff 批量应用失败:${describeError(err)}`);
       }
     },
-    [interactiveDiff],
+    [interactiveDiff, previewPort, showToast]
   );
 
-  if (!interactiveDiff) return (<>);
+  if (!interactiveDiff) return <>;
   const { runId, fileDiff } = interactiveDiff;
 
   return (
@@ -192,6 +224,20 @@ function InteractiveDiffPreview({
         
         {fileDiff.filePath}
       
+      {!applySupported && (
+        
+ {APPLY_UNSUPPORTED_NOTE} +
+ )} - ); + return ; } const kind = detectFilePreviewKind(file.name); const content = file.content ?? `${file.name}\n\n暂无文件内容。`; - const fileUrl = extractFileUrl(file.content); + // Structured runtime-evidence refs resolve through the host port; plain + // content refs keep the generic string resolution path (#1817). + const contentUrl = file.contentRef + ? previewPort?.resolveRuntimeEvidenceContent?.(file.contentRef) + : resolvePreviewContentUrl(file.content, previewPort); + // Office viewers accept any direct URL in `content`, and fall back to the + // port-resolved URL (Desktop) instead of a broken host-relative fetch. + const fileUrl = extractFileUrl(file.content) || contentUrl || ''; switch (kind) { case 'pptx': case 'pptx-legacy': - return ( - - ); + return ; case 'xlsx': case 'xls': case 'csv': - return ( - - ); + return ; case 'docx': - return ( - - ); + return ; case 'pdf': - return ; + return ; case 'html': return ; case 'image': - return ; + return ; case 'text': return ; @@ -279,11 +310,67 @@ export function FilePreviewRouter({ /* ═══ Native File Previews (zero extra libraries) ═══ */ -function NativePdfPreview({ filename }: { filename: string }): React.ReactElement { +function NativePreviewFallback({ + detail, + filename, + title, +}: { + detail: string; + filename: string; + title: string; +}): React.ReactElement { + return ( +
+
+ + {title} + {detail} +
+
+ ); +} + +function NativePdfPreview({ + contentUrl, + filename, +}: { + contentUrl?: string | undefined; + filename: string; +}): React.ReactElement { + if (!contentUrl) { + return ( + + ); + } return (