diff --git a/desktop/app.go b/desktop/app.go index 8c593d8d4e..7913977146 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -38,6 +38,7 @@ import ( "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" + "reasonix/internal/eventwire" "reasonix/internal/evidence" "reasonix/internal/extension/providerext" "reasonix/internal/fileref" @@ -5075,22 +5076,25 @@ func (a *App) singleSurfaceLayoutEnabled() bool { // HistoryMessage is one prior turn, for the frontend to repopulate its transcript // after a reload. type HistoryMessage struct { - Role string `json:"role"` - Content string `json:"content"` - Detail string `json:"detail,omitempty"` - Code string `json:"code,omitempty"` - SubmitText string `json:"submitText,omitempty"` - CheckpointTurn *int `json:"checkpointTurn,omitempty"` - CreatedAt int64 `json:"createdAt,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - MemoryCitations []provider.MemoryCitation `json:"memoryCitations,omitempty"` - WorkDurationMs int64 `json:"workDurationMs,omitempty"` - Level string `json:"level,omitempty"` - ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"` - ToolCallID string `json:"toolCallId,omitempty"` - ToolName string `json:"toolName,omitempty"` - ToolResultArchived bool `json:"toolResultArchived,omitempty"` - ToolResultError string `json:"toolResultError,omitempty"` + CompletionReceipt *eventwire.CompletionReceipt `json:"completionReceipt,omitempty"` + CompletionSummary *eventwire.CompletionSummary `json:"completionSummary,omitempty"` + TurnID string `json:"turnId,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + Detail string `json:"detail,omitempty"` + Code string `json:"code,omitempty"` + SubmitText string `json:"submitText,omitempty"` + CheckpointTurn *int `json:"checkpointTurn,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + MemoryCitations []provider.MemoryCitation `json:"memoryCitations,omitempty"` + WorkDurationMs int64 `json:"workDurationMs,omitempty"` + Level string `json:"level,omitempty"` + ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + ToolName string `json:"toolName,omitempty"` + ToolResultArchived bool `json:"toolResultArchived,omitempty"` + ToolResultError string `json:"toolResultError,omitempty"` // Execution is local shell metadata restored onto ToolCards after history // reload. Omitted when absent so older frontends ignore it safely. Execution *provider.ToolExecution `json:"execution,omitempty"` diff --git a/desktop/display_turn_buffer.go b/desktop/display_turn_buffer.go new file mode 100644 index 0000000000..2bc264918f --- /dev/null +++ b/desktop/display_turn_buffer.go @@ -0,0 +1,324 @@ +package main + +import ( + "reasonix/internal/event" + "reasonix/internal/eventwire" + "reasonix/internal/provider" + "reasonix/internal/turnevent" + "strings" +) + +// displayTextAccumulator retains provider chunks without repeatedly copying +// the complete prefix. A turn only materializes the final string when its +// display-only history is persisted; successful executor turns are discarded +// without ever joining their chunks. +type displayTextAccumulator struct { + parts []string + size int +} + +func (a *displayTextAccumulator) append(text string) { + if text == "" { + return + } + a.parts = append(a.parts, text) + a.size += len(text) +} + +func (a *displayTextAccumulator) replace(text string) { + a.parts = nil + a.size = 0 + a.append(text) +} + +func (a *displayTextAccumulator) hasNonWhitespace() bool { + for _, part := range a.parts { + if strings.TrimSpace(part) != "" { + return true + } + } + return false +} + +func (a *displayTextAccumulator) string() string { + switch len(a.parts) { + case 0: + return "" + case 1: + return a.parts[0] + } + var out strings.Builder + out.Grow(a.size) + for _, part := range a.parts { + out.WriteString(part) + } + return out.String() +} + +type bufferedHistoryMessage struct { + message HistoryMessage + content displayTextAccumulator + reasoning displayTextAccumulator +} + +func (m *bufferedHistoryMessage) materialize() HistoryMessage { + out := m.message + if out.Role == "assistant" { + out.Content = m.content.string() + out.Reasoning = m.reasoning.string() + } + if len(out.MemoryCitations) > 0 { + out.MemoryCitations = append([]provider.MemoryCitation(nil), out.MemoryCitations...) + } + if len(out.ToolCalls) > 0 { + out.ToolCalls = append([]HistoryToolCall(nil), out.ToolCalls...) + } + return out +} + +type displayTurnBuffer struct { + messages []*bufferedHistoryMessage + tools map[string]string + completion *eventwire.CompletionSummary +} + +func (b *displayTurnBuffer) reset() { + b.messages = nil + b.tools = nil + b.completion = nil +} + +func (b *displayTurnBuffer) resultMessages() []HistoryMessage { + var out []HistoryMessage + for _, m := range b.messages { + if m.message.Code == "turn_result" { + out = append(out, m.materialize()) + } + } + return out +} + +func (b *displayTurnBuffer) materialize() []HistoryMessage { + if len(b.messages) == 0 { + return nil + } + out := make([]HistoryMessage, 0, len(b.messages)) + for _, message := range b.messages { + out = append(out, message.materialize()) + } + return out +} + +func recordHistoryDisplayEvent(buffer *displayTurnBuffer, e event.Event) { + switch e.Kind { + case event.CompletionSummary: + wire := eventwire.ToWire(e) + buffer.completion = wire.Completion + case event.TurnDone: + wire := eventwire.ToWire(e) + if wire.Receipt != nil || buffer.completion != nil { + buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{ + Role: "notice", Code: "turn_result", Level: "info", TurnID: e.TurnID, + CompletionReceipt: wire.Receipt, CompletionSummary: buffer.completion, CheckpointTurn: e.CheckpointTurn, + }}) + } + case event.Phase: + if strings.TrimSpace(e.Text) != "" { + buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{Role: "phase", Content: e.Text}}) + } + case event.Reasoning: + if e.Text != "" { + hm := ensureDisplayAssistant(buffer) + hm.reasoning.append(e.Text) + } + case event.Text: + if e.Text != "" { + hm := ensureDisplayAssistant(buffer) + hm.content.append(e.Text) + } + case event.Message: + if e.Text != "" || e.Reasoning != "" || len(e.MemoryCitations) > 0 { + hm := ensureDisplayAssistant(buffer) + if e.Text != "" { + hm.content.replace(e.Text) + } + if e.Reasoning != "" { + hm.reasoning.replace(e.Reasoning) + } + if len(e.MemoryCitations) > 0 { + hm.message.MemoryCitations = append([]provider.MemoryCitation(nil), e.MemoryCitations...) + } + } + case event.ToolDispatch: + recordHistoryToolDispatch(buffer, e) + case event.ToolResult: + callID := strings.TrimSpace(e.Tool.ID) + content := firstNonEmpty(e.Tool.Output, e.Tool.Err) + display, errPreview := plannerToolResultDisplay(content, e.Tool.Err != "") + if callID != "" { + updateBufferedHistoryToolCallSummary(buffer.messages, callID, content) + } + toolName := e.Tool.Name + if toolName == "" && buffer.tools != nil { + toolName = buffer.tools[callID] + } + buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{ + Role: "tool", + ToolCallID: callID, + ToolName: toolName, + Content: display, + ToolResultError: errPreview, + }}) + case event.Notice: + if strings.TrimSpace(e.Text) != "" { + level := "info" + if e.Level == event.LevelWarn { + level = "warn" + } + buffer.messages = append(buffer.messages, &bufferedHistoryMessage{message: HistoryMessage{ + Role: "notice", + Level: level, + Content: e.Text, + Detail: e.Detail, + Code: e.Code, + DecisionReceipt: cloneDecisionReceipt(e.DecisionReceipt), + }}) + } + } +} + +func displayEventFromEnvelope(envelope turnevent.Envelope) (event.Event, bool) { + w := envelope.Event + e := event.Event{ + TurnID: envelope.TurnID, Sequence: envelope.Sequence, Status: envelope.Status, + Text: w.Text, Detail: w.Detail, Reasoning: w.Reasoning, ItemID: envelope.ItemID, Source: envelope.Source, + } + switch envelope.Kind { + case "phase": + e.Kind = event.Phase + case "reasoning": + e.Kind = event.Reasoning + case "text": + e.Kind = event.Text + case "message": + e.Kind = event.Message + case "tool_dispatch": + e.Kind = event.ToolDispatch + case "tool_result": + e.Kind = event.ToolResult + case "notice": + e.Kind = event.Notice + case "completion_summary": + e.Kind = event.CompletionSummary + if w.Completion != nil { + c := w.Completion + e.Completion = &event.CompletionSummaryInfo{Preset: c.Preset, Verdict: c.Verdict, Mutations: c.Mutations, ChecksPassed: c.ChecksPassed, ChecksFailed: c.ChecksFailed, ChecksSuppressed: c.ChecksSuppressed, Review: c.Review, GapKinds: c.GapKinds, ConstraintDegraded: c.ConstraintDegraded, Floor: c.Floor, Attention: c.Attention} + } + case "turn_done": + e.Kind = event.TurnDone + e.Receipt = eventwire.CompletionReceiptEvent(w.Receipt) + e.CheckpointTurn = w.CheckpointTurn + default: + return event.Event{}, false + } + if w.Level == "warn" { + e.Level = event.LevelWarn + } + e.Code = w.Code + if w.Tool != nil { + e.Tool = event.Tool{ + ID: w.Tool.ID, Name: w.Tool.Name, Args: w.Tool.Args, ResolvedName: w.Tool.ResolvedName, + CapabilityID: w.Tool.CapabilityID, Output: w.Tool.Output, Err: w.Tool.Err, + ReadOnly: w.Tool.ReadOnly, Truncated: w.Tool.Truncated, DurationMs: w.Tool.DurationMs, + StartedAt: w.Tool.StartedAt, EndedAt: w.Tool.EndedAt, Partial: w.Tool.Partial, + ArgChars: w.Tool.ArgChars, Refreshed: w.Tool.Refreshed, ParentID: w.Tool.ParentID, + AttemptID: w.Tool.AttemptID, FileDiff: event.FileDiff{Diff: w.Tool.Diff, Added: w.Tool.Added, Removed: w.Tool.Removed}, + SubagentRef: w.Tool.SubagentRef, SubagentStatus: w.Tool.SubagentStatus, + SubagentErrorCode: w.Tool.SubagentErrorCode, SubagentRetryable: w.Tool.SubagentRetryable, + } + } + if len(w.MemoryCitations) > 0 { + e.MemoryCitations = make([]provider.MemoryCitation, 0, len(w.MemoryCitations)) + for _, citation := range w.MemoryCitations { + e.MemoryCitations = append(e.MemoryCitations, provider.MemoryCitation{ + ID: citation.ID, Source: citation.Source, LineStart: citation.LineStart, + LineEnd: citation.LineEnd, Note: citation.Note, Kind: citation.Kind, + }) + } + } + if w.DecisionReceipt != nil { + e.DecisionReceipt = &provider.DecisionReceipt{ + ID: w.DecisionReceipt.ID, Kind: w.DecisionReceipt.Kind, Tool: w.DecisionReceipt.Tool, + Subject: w.DecisionReceipt.Subject, Outcome: w.DecisionReceipt.Outcome, + } + } + return e, true +} + +func displayMessagesFromProjection(projection turnevent.PendingProjection) []HistoryMessage { + var planner displayTurnBuffer + var executor displayTurnBuffer + for _, envelope := range projection.Events { + e, ok := displayEventFromEnvelope(envelope) + if !ok { + continue + } + buffer := &executor + if strings.TrimSpace(e.Source) == event.UsageSourcePlanner { + buffer = &planner + } + recordHistoryDisplayEvent(buffer, e) + } + out := planner.materialize() + if projection.Status != event.TurnInterrupted { + out = append(out, executor.resultMessages()...) + } + if projection.Status == event.TurnInterrupted { + out = append(out, executor.materialize()...) + if len(out) > 0 { + out = append(out, HistoryMessage{ + Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn, + Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.", + }) + } + } + return out +} + +func recordHistoryToolDispatch(buffer *displayTurnBuffer, e event.Event) { + if e.Tool.Partial || strings.TrimSpace(e.Tool.Name) == "" { + return + } + hm := ensureDisplayAssistantForTool(buffer) + resolvedReadOnly := e.Tool.ReadOnly + call := HistoryToolCall{ + ID: e.Tool.ID, + Name: e.Tool.Name, + Arguments: e.Tool.Args, + ResolvedName: e.Tool.ResolvedName, + CapabilityID: e.Tool.CapabilityID, + ResolvedReadOnly: &resolvedReadOnly, + Subject: historyToolSubject(e.Tool.Name, e.Tool.Args), + Summary: historyToolSummary(e.Tool.Name, e.Tool.Args, ""), + Diff: e.Tool.Diff, + Added: e.Tool.Added, + Removed: e.Tool.Removed, + } + replaced := false + if call.ID != "" { + for i := range hm.message.ToolCalls { + if hm.message.ToolCalls[i].ID == call.ID { + hm.message.ToolCalls[i] = call + replaced = true + break + } + } + if buffer.tools == nil { + buffer.tools = map[string]string{} + } + buffer.tools[call.ID] = call.Name + } + if !replaced { + hm.message.ToolCalls = append(hm.message.ToolCalls, call) + } +} diff --git a/desktop/frontend/bench/turn-result.html b/desktop/frontend/bench/turn-result.html new file mode 100644 index 0000000000..8c9cfe560f --- /dev/null +++ b/desktop/frontend/bench/turn-result.html @@ -0,0 +1 @@ +Reasonix turn result browser fixture
diff --git a/desktop/frontend/bench/turn-result.tsx b/desktop/frontend/bench/turn-result.tsx new file mode 100644 index 0000000000..adc07a2fed --- /dev/null +++ b/desktop/frontend/bench/turn-result.tsx @@ -0,0 +1,73 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Transcript } from "../src/components/Transcript"; +import { WorkspaceTurnResult } from "../src/components/WorkspaceTurnResult"; +import { initialState, reducer, type Item } from "../src/lib/useController"; +import { historicalResultNotice } from "../src/lib/completionResultState"; +import { app, type AppBindings } from "../src/lib/bridge"; +import { LocaleProvider, useI18n } from "../src/lib/i18n"; +import type { TurnChanges, WireCompletionSummary, WireEvent } from "../src/lib/types"; +import "../src/styles.css"; + +const path = "desktop/frontend/src/components/deeply/nested/very-long-component-name/WorkspaceTurnResult.tsx"; +const diff: TurnChanges = { id: "0:42", turn: 0, coverage: "complete", added: 18, removed: 6, reasons: [], files: [ + { path, kind: "modify", added: 15, removed: 6 }, + { path: "internal/checkpoint/turn_changes.go", kind: "create", added: 3, removed: 0 }, +] }; +let unavailable = false; +const calls: string[] = []; +const fallback = Object.fromEntries(["ReportFrontendDiagnostic", "ToolResultForTab", "ListDirForTab", "WorkspaceChanges"].map(k => [k, app[k as keyof AppBindings]])); +window.go = { main: { App: new Proxy({ + ...fallback, + WorkspaceTurnChanges: async (_tab: string, _session: string, _turn: number, id: string) => { calls.push("summary:" + id); return unavailable ? { ...diff, id: undefined, coverage: "unknown", files: [] } : { ...diff, id }; }, + WorkspaceTurnChangeDetail: async (_tab: string, _session: string, _turn: number, id: string, file: string) => { calls.push("detail:" + id); return unavailable ? null : { ...diff.files.find(f => f.path === file), patch: "@@ -1,2 +1,3 @@\n-old result\n+frozen result for " + id + "\n+confirmed changes\n context" }; }, + TurnCheckLog: async (_tab: string, session: string, id: string) => { calls.push("log:" + session + ":" + id); return unavailable ? null : { output: "go test ./...\n--- FAIL: TestFrozenResult\nexpected 0, got 1\nFAIL\nexit status 1", truncated: false }; }, +}, { get(target, key) { return target[key as keyof typeof target] ?? (async () => undefined); } }) as unknown as AppBindings } }; +(window as unknown as { turnResultCalls: string[] }).turnResultCalls = calls; + +function snapshot(scenario: string) { + const event = (s: typeof initialState, e: WireEvent) => reducer(s, { type: "event", e }); + let state = reducer(initialState, { type: "user", text: "完善本轮结果展示,保留历史差异和检查日志。" }); + state = event(state, { kind: "turn_started", turnId: "fixture-turn", checkpointTurn: 0 }); + state = event(state, { kind: "tool_dispatch", turnId: "fixture-turn", tool: { id: "check-1", name: "exec_command", args: '{"command":"go test ./..."}', readOnly: false } }); + state = event(state, { kind: "tool_progress", turnId: "fixture-turn", tool: { id: "check-1", name: "exec_command", verifying: true, output: "running checkpoint tests..." } }); + if (scenario === "running") return state; + const failed = scenario === "failed"; + const checks = scenario === "none" ? [] : [{ command: "go test ./...", passed: !failed && scenario !== "interrupted", stale: scenario === "stale", interrupted: scenario === "interrupted", exitCode: failed ? 1 : 0, toolCallId: "check-1", toolResultId: "log-entry-1" }]; + const receipt = { verdict: "partial", interrupted: scenario === "interrupted", diff: scenario === "legacy" ? undefined : { ...diff, coverage: scenario === "partial" ? "partial" as const : "complete" as const, reasons: scenario === "partial" ? ["external_change"] : [] }, verifications: checks }; + state = event(state, { kind: "tool_result", turnId: "fixture-turn", tool: { id: "check-1", name: "exec_command", output: "check completed" } }); + state = event(state, { kind: "message", text: "已补齐本轮差异与检查记录。文件统计只计算已确认的净变更。", turnId: "fixture-turn" }); + state = event(state, { kind: "completion_summary", turnId: "fixture-turn", completion: { preset: "balanced", verdict: "partial", mutations: 30, checks_passed: checks.length && !failed ? 1 : 0, checks_failed: failed ? 1 : 0, checks_suppressed: 0, review: "none", attention: failed } }); + state = event(state, { kind: "turn_done", turnId: "fixture-turn", checkpointTurn: 0, receipt: scenario === "legacy" ? undefined : receipt }); + if (scenario === "history") { + const saved = historicalResultNotice({ role: "notice", content: "", completionReceipt: { ...receipt, diff: { ...diff, id: "0:historical" } }, checkpointTurn: 0, turnId: "historical" }, "old-result")!; + state.items = [{ kind: "user", id: "old-user", text: "这是历史轮次" }, saved, { kind: "assistant", id: "old-answer", text: "历史修改已完成。", reasoning: "", streaming: false }, { kind: "user", id: "middle-user", text: "继续说明" }, { kind: "assistant", id: "middle-answer", text: "这一轮没有文件修改。", reasoning: "", streaming: false }, ...state.items] as Item[]; + } + return state; +} + +function Fixture() { + const locale = useI18n(); + const [scenario, setScenario] = useState("failed"); + const [dark, setDark] = useState(true); + const [width, setWidth] = useState(410); + const [selection, setSelection] = useState<{ summary: WireCompletionSummary; view: "changes" | "checks"; key: number }>(); + const [missing, setMissing] = useState(false); + const state = React.useMemo(() => snapshot(scenario), [scenario]); + React.useEffect(() => { document.documentElement.dataset.theme = dark ? "dark" : "light"; document.documentElement.dataset.themeStyle = "graphite"; }, [dark]); + React.useEffect(() => { locale.setPref("zh"); }, [locale.setPref]); + const show = (summary: WireCompletionSummary | undefined, view: "changes" | "checks") => summary && setSelection({ summary, view, key: Date.now() }); + return
+ +
+
{}} onOpenChanges={s => show(s, "changes")} onOpenVerification={s => show(s, "checks")} />
+ {selection && } +
+
; +} +createRoot(document.getElementById("root")!).render(); diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 5f4a0f1268..3b792e5dd2 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -230,12 +230,9 @@ if (initialCSS.length > 0) { // shared title-safe shell, and the shared harness decision surface measure // 116.9 KiB gzip while reusing existing layout primitives. Retain a bounded // 0.1 KiB headroom ratchet. -// Mainline provider/settings and recovery styles measure 119.435 KiB gzip. -// Workbench's column-responsive welcome adds 291 bytes over the 119.479 KiB -// toolbar-refresh base; round the measured 119.763 KiB to the next tenth. -// Shared recovery banner and disabled-send states measure 119.989 KiB; -// +231 gzip bytes over the prior welcome head, retaining the next tenth. -assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 120.0 * 1024); +// Workbench welcome and recovery styles measure 122869 B gzip on main-v2. +// Turn result styles add 388 B after removing obsolete metrics (123257 B). +assertBudget("deferred app-shell CSS gzip", appShellCSSGzip, 120.4 * 1024); if (localeChunks.length !== 2) { throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`); } @@ -300,9 +297,9 @@ for (const path of localeChunks) { // ceiling for cross-platform CI. // Search assignment copy adds 239 / 231 B over main-v2 (63147 / 63920 B). // Measured result: 63386 / 64151 B; retain bounded cross-platform headroom. - // Session recovery guidance adds 173 / 156 B over main-v2, measuring - // 62.173 / 62.887 KiB. Keep only the next one-decimal ceiling. - const budget = name.startsWith("zh-TW-") ? 62.9 * 1024 : 62.2 * 1024; + // Turn result copy adds 554 / 566 B to the latest-base chunks, measuring + // 64219 / 64964 B with recovery guidance included. Round to the next tenth. + const budget = name.startsWith("zh-TW-") ? 63.5 * 1024 : 62.8 * 1024; assertBudget(`${name} gzip`, gzipBytes(path), budget); } @@ -421,6 +418,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // against the 2398.0 KiB base; retain only the next one-decimal ceiling. // Shared availability, visible recovery and retry controls measure 2407.215 KiB // (+6.107 KiB, 0.25% over the prior welcome head). Retain the next tenth. -const rawInitialBudgetKiB = 2_407.3; +// Turn results add 12585 B (0.51%) over main-v2's 2464923 B: bounded receipt +// projection, status presentation and view bindings. Result: 2477508 B. +const rawInitialBudgetKiB = 2_419.6; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/__tests__/completion-summary-presentation.test.ts b/desktop/frontend/src/__tests__/completion-summary-presentation.test.ts index cc4075b4af..8fbc84bb19 100644 --- a/desktop/frontend/src/__tests__/completion-summary-presentation.test.ts +++ b/desktop/frontend/src/__tests__/completion-summary-presentation.test.ts @@ -1,27 +1,48 @@ -import { completionSummaryChangeNotice } from "../lib/completionSummary"; -import type { DictKey, Translator } from "../lib/i18n"; +import assert from "node:assert/strict"; +import { completionSummaryPresentation, normalizeCompletionSummary } from "../lib/completionSummary"; +import { mergeTurnResult, normalizeTurnChanges, turnChangeText, turnCheckState } from "../lib/turnResult"; +import { historicalResultNotice, withTurnResult, withRunningChecks } from "../lib/completionResultState"; +import { partitionTurnItems } from "../lib/transcriptRows"; +import { initialState, type State, type Item } from "../lib/useController"; +import { t } from "../lib/i18n"; +import type { TurnChanges } from "../lib/types"; -const messages: Partial> = { - "notice.completionChangesTitle": "Changes this turn", - "notice.completionChangesBody": "{count} changes", -}; - -const translate: Translator = (key, vars) => { - const value = messages[key] ?? key; - return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? `{${name}}`)); -}; - -const notice = completionSummaryChangeNotice({ - preset: "balanced", - verdict: "complete", - mutations: 3, - checks_passed: 0, - checks_failed: 0, - checks_suppressed: 0, - review: "passed", - constraint_degraded: false, -}, translate); - -if (notice.body !== "3 changes" || notice.body.includes("file")) { - throw new Error(`mutation receipt notice = ${notice.body}, want receipt-neutral changes wording`); -} +const diff: TurnChanges = { id: "0:1", turn: 0, coverage: "complete", files: [{ path: "a.ts", kind: "modify", added: 2, removed: 1 }], added: 2, removed: 1, reasons: [] }; +const legacy = { ...mergeTurnResult(), mutations: 17, changed_files: 9 }; +assert.equal(turnChangeText(legacy, t), "Change statistics unavailable"); +assert.equal(turnCheckState(legacy).status, "unknown"); +const receipt = { verdict: "partial", diff, verifications: [] }; +const result = normalizeCompletionSummary(mergeTurnResult(legacy, receipt, "turn-0", 0)); +assert.equal(result.checkpointTurn, 0); +assert.match(turnChangeText(result, t), /1.*file.*\+2 −1/); +assert.equal(turnCheckState(result).status, "none"); +assert.equal(completionSummaryPresentation(result, "standard", t)?.title, "Turn result"); +assert.match(turnChangeText(mergeTurnResult(undefined, { ...receipt, diff: { ...diff, coverage: "partial" } }), t), /partial/i); +assert.equal(normalizeTurnChanges({ ...diff, added: -1 })?.coverage, "unknown"); +for (const [check, expected] of [ + [{ command: "test", passed: true, exitCode: 1 }, "failed"], + [{ command: "test", passed: true, stale: true }, "stale"], + [{ command: "test", passed: false, interrupted: true }, "interrupted"], + [{ command: "test", passed: true, exitCode: 0 }, "passed"], +] as const) assert.equal(turnCheckState(mergeTurnResult(undefined, { ...receipt, verifications: [check] })).status, expected); +const user: Item = { kind: "user", id: "u0", text: "change" }; +let state = withTurnResult({ ...initialState, items: [user], seq: 10 } as State, result); +const stableId = state.items[1].id; +state = withTurnResult(state, result); +assert.equal(state.items.length, 2); +assert.equal(state.items[1].id, stableId); +state = withTurnResult({ ...state, items: [...state.items, { ...user, id: "u1" }] }, { ...result, turnId: "turn-1" }); +assert.equal(state.items.length, 4); +assert.equal(state.items[1].id, stableId); +const tool = (id: string): Item => ({ kind: "tool", id, name: "exec_command", args: '{"command":"go test ./..."}', readOnly: false, status: "running", verifying: true }); +state = withRunningChecks({ ...state, items: [...state.items, tool("c1"), tool("c2")] }); +assert.equal(state.completionSummary?.liveChecks?.length, 2); +state = withRunningChecks({ ...state, items: state.items.map(i => i.kind === "tool" && i.id === "c1" ? { ...i, status: "done" } : i) }); +assert.equal(state.completionSummary?.checking, true, "one completed parallel check does not stop the other"); +const history = historicalResultNotice({ role: "notice", content: "", completionReceipt: receipt, checkpointTurn: 0, turnId: "turn-0" }, "history"); +assert.equal(history?.completionSummary?.receipt?.diff?.id, diff.id); +assert.equal(history?.completionSummary?.checkpointTurn, 0); +const answer: Item = { kind: "assistant", id: "a0", text: "done", reasoning: "", streaming: false }; +const outside = partitionTurnItems([history!, answer]).flatMap(p => p.outsideItems); +assert.deepEqual(outside.map(i => i.id), ["a0", "history"], "sidecar placement preserves a result footer"); +console.log("turn result truth, stable identity, concurrent checks and history passed"); diff --git a/desktop/frontend/src/__tests__/completion-summary-ui.test.tsx b/desktop/frontend/src/__tests__/completion-summary-ui.test.tsx index 62652ce1f0..78b94c0f75 100644 --- a/desktop/frontend/src/__tests__/completion-summary-ui.test.tsx +++ b/desktop/frontend/src/__tests__/completion-summary-ui.test.tsx @@ -20,7 +20,7 @@ function ok(value: unknown, label: string) { console.log("\ncompletion summary UI"); const harness = await createTranscriptHarness(); -let opens = 0; +const changesOpens: (WireCompletionSummary | undefined)[] = []; const earlierSummary = { preset: "balanced", verdict: "partial", @@ -63,17 +63,18 @@ try { const verificationOpens: WireCompletionSummary[] = []; await harness.render(items, { running: false, - onOpenChanges: () => { opens += 1; }, + onOpenChanges: (summary?: WireCompletionSummary) => { changesOpens.push(summary); }, onOpenVerification: (summary: WireCompletionSummary) => { verificationOpens.push(summary); }, }); - ok(harness.container.textContent?.includes("This turn still needs attention"), "actionable summary stays visible outside the process fold"); + ok(harness.container.textContent?.includes("Turn result"), "result stays visible outside the process fold"); + ok(harness.container.textContent?.includes("Change statistics unavailable"), "legacy mutations do not become file counts"); ok(!harness.container.textContent?.includes("balanced"), "compact notice exposes no internal enum values"); const button = Array.from(harness.container.querySelectorAll("button")).find((node) => node.textContent?.includes("View changes")); ok(button, "completion notice offers a View changes action"); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await harness.flush(); - ok(opens === 1, "View changes delegates to the workspace panel action"); - const verifyButtons = Array.from(harness.container.querySelectorAll("button")).filter((node) => node.textContent?.includes("Turn verification")); + ok(changesOpens[0] === earlierSummary, "View changes delegates the clicked historical summary"); + const verifyButtons = Array.from(harness.container.querySelectorAll("button")).filter((node) => /View check details/.test(node.textContent ?? "")); ok(verifyButtons.length === 2, "each completion notice offers a Turn verification action"); verifyButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); verifyButtons[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); diff --git a/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx b/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx index 74eb1a2c13..58f99edd4c 100644 --- a/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx +++ b/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx @@ -45,7 +45,7 @@ try { await act(async () => { states.openTurnVerification(historical); }); assert.deepEqual(dockCalls, ["changed"], "opening verification reveals the changed-files dock"); assert.deepEqual(states.verificationRevealRequest, { - id: 1, summary: historical, tabId: "A", turnStartAt: 100, currentSummary: summary(1), + id: 1, summary: historical, tabId: "A", turnStartAt: 100, currentSummary: summary(1), sessionPath: undefined, view: "checks", }, "the reveal request binds the clicked summary to the tab and turn that published it"); const second = summary(9); @@ -63,8 +63,12 @@ try { assert.equal(states.verificationRevealRequest, null, "switching tabs clears the historical reveal"); await act(async () => { states.openTurnVerification(summary(4)); }); - await paint({ completionSummary: summary(2) }); - assert.equal(states.verificationRevealRequest, null, "a new completion summary clears the historical reveal"); + await paint({ activeTabId: "B", completionSummary: summary(2) }); + assert.equal(states.verificationRevealRequest?.summary.mutations, 4, "a summary refresh preserves the historical reveal"); + await act(async () => { states.openTurnChanges(historical); }); + assert.equal(states.verificationRevealRequest?.view, "changes"); + await act(async () => { states.closeTurnResult(); }); + assert.equal(states.verificationRevealRequest, null); await paint({ activeTabId: undefined }); await act(async () => { states.openTurnVerification(summary(5)); }); diff --git a/desktop/frontend/src/__tests__/use-controller-stream-progress.test.ts b/desktop/frontend/src/__tests__/use-controller-stream-progress.test.ts index 3f113c68f6..b965647d33 100644 --- a/desktop/frontend/src/__tests__/use-controller-stream-progress.test.ts +++ b/desktop/frontend/src/__tests__/use-controller-stream-progress.test.ts @@ -78,7 +78,7 @@ function ev(s: typeof initialState, e: WireEvent) { attention: true, }, }); - eq(after.items.length, complete.items.length + 1, "actionable completion summary adds one compact transcript notice"); + eq(after.items.length, complete.items.length, "summary refresh replaces the same result notice"); const notice = after.items[after.items.length - 1]; eq(notice?.kind === "notice" ? notice.variant : "", "completion", "quality gap uses the completion notice variant"); eq(notice?.kind === "notice" ? notice.action : "", "open_changes", "quality gap links to the change panel"); @@ -114,7 +114,7 @@ function ev(s: typeof initialState, e: WireEvent) { }, }); const suppressedNotice = suppressed.items[suppressed.items.length - 1]; - eq(suppressedNotice?.kind === "notice" ? suppressedNotice.title : "", "This turn still needs attention", "required suppression uses a generic attention notice"); + eq(suppressedNotice?.kind === "notice" ? suppressedNotice.title : "", "Turn result", "required suppression retains the fixed result title"); const restarted = ev(after, { kind: "turn_started" }); eq(restarted.completionSummary, undefined, "a new turn clears the previous turn's quality details"); diff --git a/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx b/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx index 21c0cd4935..4d7642d185 100644 --- a/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx +++ b/desktop/frontend/src/__tests__/workspace-changes-errors.test.tsx @@ -65,13 +65,11 @@ console.log("\nworkspace changes git errors"); }, }, ); - await waitFor("turn verification summary", () => document.body.textContent?.includes("Turn verification") === true); - const text = document.querySelector(".workspace-completion-summary")?.textContent ?? ""; - ok(text.includes("Partially complete"), "change panel localizes the completion verdict"); - ok(text.includes("1 checks failed") && text.includes("2 checks skipped"), "change panel shows detailed check counts on demand"); - ok(text.includes("stale checks") && text.includes("Other"), "change panel uses safe labels for known and unknown gaps"); - ok(text.includes("Turn verification limited"), "change panel explains constrained verification without exposing an internal flag"); - ok(!text.includes("balanced") && !text.includes("partial") && !text.includes("stale_check") && !text.includes("future_internal_value"), "change panel exposes no raw enum values"); + await waitFor("workspace changes without a turn request", () => document.body.textContent?.includes("No changed files") === true); + ok(document.querySelector(".workspace-completion-summary") === null, "whole workspace does not imply that the latest turn verifies all files"); + ok(document.querySelector(".workspace-turn-result") === null, "turn inspection requires its explicit view request"); + const text = document.body.textContent ?? ""; + ok(!text.includes("balanced") && !text.includes("stale_check") && !text.includes("future_internal_value"), "whole workspace exposes no completion enum values"); await act(async () => { root.unmount(); }); diff --git a/desktop/frontend/src/__tests__/workspace-turn-verification.test.tsx b/desktop/frontend/src/__tests__/workspace-turn-verification.test.tsx index 6b42c4f01c..737ac97cbf 100644 --- a/desktop/frontend/src/__tests__/workspace-turn-verification.test.tsx +++ b/desktop/frontend/src/__tests__/workspace-turn-verification.test.tsx @@ -5,6 +5,8 @@ import { registerHooks } from "node:module"; import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { WORKSPACE_TURN_VERIFICATION_ID, WorkspacePanel } from "../components/WorkspacePanel"; +import { WorkspaceTurnResult } from "../components/WorkspaceTurnResult"; +import { TurnCheckDetails } from "../components/TurnCheckDetails"; import { LocaleProvider } from "../lib/i18n"; import type { AppBindings } from "../lib/bridge"; import type { WireCompletionSummary } from "../lib/types"; @@ -96,6 +98,9 @@ async function createHarness(props: Partial) { WorkspaceGitHistory: async () => [], WorkspaceChanges: async () => ({ files: [], gitAvailable: true }), WorkspaceChangeDetail: async () => ({}), + WorkspaceTurnChanges: async () => ({ turn: 0, coverage: "unknown", files: [], added: 0, removed: 0, reasons: [] }), + WorkspaceTurnChangeDetail: async () => null, + TurnCheckLog: async () => null, ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }), } as Partial as AppBindings, }, @@ -145,72 +150,72 @@ console.log("\nworkspace turn verification"); { const current = summary(3); const { dom, root } = await createHarness({ initialViewMode: "changed", completionSummary: current }); - await waitFor("turn verification summary", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null); - const text = document.querySelector(".workspace-completion-summary")?.textContent ?? ""; - ok(text.includes("Partially complete") && text.includes("3 changes"), "summary renders localized verdict and metrics"); - ok(text.includes("stale checks") && text.includes("Other"), "summary safely labels known and unknown gaps"); - ok(text.includes("Turn verification limited"), "summary explains constrained verification"); - ok(!text.includes("balanced") && !text.includes("partial") && !text.includes("stale_check"), "summary exposes no raw enum values"); - const title = document.getElementById(`${WORKSPACE_TURN_VERIFICATION_ID}-title`); - ok(title?.tagName === "H3", "turn verification title is a heading, not a button"); - ok(document.querySelector(`#${WORKSPACE_TURN_VERIFICATION_ID} button`) === null, "summary does not expose a clickable control"); + ok(document.querySelector(".workspace-turn-result") === null, "workspace overview does not imply the current turn covers all changes"); await closeHarness(dom, root); } { - const legacyDeliverySummary: WireCompletionSummary = { - preset: "balanced", - verdict: "partial", - mutations: 1, - checks_passed: 0, - checks_failed: 0, - checks_suppressed: 0, - review: "passed", - gap_kinds: ["unverified_change"], - constraint_degraded: false, - }; - const { dom, root, rerender } = await createHarness({ - initialViewMode: "changed", - completionSummary: legacyDeliverySummary, - qualityFloor: "delivery", - }); - await waitFor("delivery attention styling", () => document.querySelector(".workspace-completion-summary") !== null); - ok(document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy delivery summary uses delivery-floor attention styling"); - await rerender({ qualityFloor: "standard" }); - ok(!document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy standard summary remains neutral"); + const current = summary(2); + const historical = { ...summary(7), receipt: { verdict: "partial", verifications: [{ command: "go test ./...", passed: false, exitCode: 1, toolCallId: "check-old", stale: true }] } }; + const request = { id: 1, summary: historical, tabId: "tab-a", turnStartAt: 100, currentSummary: current, sessionPath: "/old.json", view: "checks" as const }; + const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current, sessionPath: "/old.json", verificationRevealRequest: request, turnStartAt: 100 }); + await waitFor("historical check", () => document.body.textContent?.includes("go test ./...") === true); + const text = document.body.textContent ?? ""; + ok(text.includes("Exit code: 1"), "actual exit code appears with the historical command"); + ok(!text.includes("7 files") && !text.includes("7 changes"), "mutation receipts are not presented as a diff inventory"); + ok(text.includes("stale") || text.includes("Stale"), "later changes mark checks stale"); + ok(!Array.from(document.querySelectorAll("button")).some(b => /Run|Retry|Continue verification/.test(b.textContent ?? "")), "result panel has only view actions"); + await rerender({ sessionPath: "/new.json" }); + ok(document.querySelector(".workspace-turn-result") === null, "session switch immediately fences a historical request"); await closeHarness(dom, root); } { - const current = summary(2); - const historical = summary(7); - const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current }); - await waitFor("current summary", () => document.body.textContent?.includes("2 changes") === true); - let scrolled = 0; - Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } }); - const request = { id: 1, summary: historical, tabId: "tab-a", turnStartAt: 100, currentSummary: current }; - await rerender({ verificationRevealRequest: request, turnStartAt: 100 } as Partial); - await waitFor("same-view scroll", () => scrolled > 0); - ok(document.body.textContent?.includes("7 changes"), "same-view reveal displays the requested historical summary"); - ok(scrolled === 1, "same-view reveal scrolls exactly once"); - - await rerender({ completionSummary: undefined, turnStartAt: 200 } as Partial); - await waitFor("stale summary cleared", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) === null); - ok(!document.body.textContent?.includes("7 changes"), "a new turn clears the historical reveal"); + const current = summary(0); + const historical = { ...summary(9), turnId: "turn-old", receipt: { verdict: "complete", diff: { id: "0:42", turn: 0, coverage: "complete" as const, files: [{ path: "src/old.ts", kind: "modify", added: 2, removed: 1 }], added: 2, removed: 1, reasons: [] }, verifications: [] } }; + const request = { id: 2, summary: historical, tabId: "tab-a", turnStartAt: 300, currentSummary: current, sessionPath: "/history.json", view: "changes" as const }; + const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current, verificationRevealRequest: request, sessionPath: "/history.json", turnStartAt: 300 }); + await waitFor("frozen result", () => document.body.textContent?.includes("src/old.ts") === true); + ok(document.body.textContent?.includes("+2"), "historical counts come from the frozen receipt"); + await rerender({ completionSummary: summary(42) }); + ok(document.body.textContent?.includes("src/old.ts"), "a current summary refresh preserves the selected historical result"); + await rerender({ turnStartAt: 301 }); + ok(document.querySelector(".workspace-turn-result") === null, "a new turn fences the old reveal"); await closeHarness(dom, root); } { - const current = summary(4); - const historical = summary(9); - const { dom, root, rerender } = await createHarness({ initialViewMode: "files", completionSummary: current }); - let scrolled = 0; - Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } }); - const request = { id: 2, summary: historical, tabId: "tab-a", turnStartAt: 300, currentSummary: current }; - await rerender({ initialViewMode: "changed", verificationRevealRequest: request, turnStartAt: 300 } as Partial); - await waitFor("navigation reveal", () => document.body.textContent?.includes("9 changes") === true && scrolled > 0); - ok(document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null, "reveal navigates from Files to the change overview"); - ok(scrolled === 1, "navigation reveal scrolls after the overview mounts"); + const dom = installDom(); + const root = createRoot(document.getElementById("root")!); + const pending = new Map void>(); + const deferred = (key: string) => new Promise(resolve => pending.set(key, resolve)); + window.go = { main: { App: { + WorkspaceTurnChanges: (_tab, session) => deferred(`files:${session}`), + WorkspaceTurnChangeDetail: (_tab, session) => deferred(`detail:${session}`), + TurnCheckLog: (_tab, session) => deferred(`log:${session}`), + } as Partial as AppBindings } }; + const diff = { id: "frozen", turn: 0, coverage: "complete" as const, files: [{ path: "f.ts", kind: "modify", added: 1, removed: 1 }], added: 1, removed: 1, reasons: [] }; + const result = { ...summary(1), receipt: { verdict: "partial", diff, verifications: [{ command: "test", passed: false, toolCallId: "check", toolResultId: "entry" }] } }; + const paint = (session: string) => act(async () => root.render( {}} />)); + await paint("old"); + await paint("new"); + await act(async () => pending.get("files:old")!(diff)); + ok(document.querySelector(".turn-file-list__entry")?.disabled, "old session response cannot enable the new session file list"); + await act(async () => pending.get("files:new")!(diff)); + await act(async () => document.querySelector(".turn-file-list__entry")!.click()); + await paint("replacement"); + await act(async () => pending.get("detail:new")!({ ...diff.files[0], patch: "stale private patch" })); + ok(!document.body.textContent?.includes("stale private patch"), "late diff response is discarded after session replacement"); + const logs = (session: string) => act(async () => root.render()); + await logs("old"); + await act(async () => { const details = document.querySelector("details")!; details.open = true; details.dispatchEvent(new Event("toggle")); }); + await waitFor("old log request", () => pending.has("log:old")); + await logs("new"); + await waitFor("new log request", () => pending.has("log:new")); + await act(async () => pending.get("log:old")!({ output: "stale private log" })); + ok(!document.body.textContent?.includes("stale private log"), "late log response is discarded after session replacement"); + await act(async () => pending.get("log:new")!(null)); + ok(document.body.textContent?.includes("Logs have not arrived, were cleared, or cannot be linked."), "cleared logs remain explicitly unavailable"); await closeHarness(dom, root); } diff --git a/desktop/frontend/src/app-runtime/useAppSessionComposition.ts b/desktop/frontend/src/app-runtime/useAppSessionComposition.ts index 1a2622cb4f..31d34eee3d 100644 --- a/desktop/frontend/src/app-runtime/useAppSessionComposition.ts +++ b/desktop/frontend/src/app-runtime/useAppSessionComposition.ts @@ -601,6 +601,7 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) { activeTabId, turnStartAt: state.turnStartAt, completionSummary: state.completionSummary, + sessionPath: state.meta?.sessionPath, openChangedDock: () => openRightDockMode("changed"), }); diff --git a/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts b/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts index c4de9e0741..3b313d2589 100644 --- a/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts +++ b/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts @@ -58,9 +58,10 @@ export function useActiveTabUiReset(input: { export function useVerificationRevealReset(input: { activeTabId?: string | null; completionSummary: unknown; + sessionPath?: string; turnStartAt?: number | null; reset: (value: null) => void; }) { - const { activeTabId, completionSummary, turnStartAt, reset } = input; - useEffect(() => { reset(null); }, [activeTabId, completionSummary, reset, turnStartAt]); + const { activeTabId, sessionPath, turnStartAt, reset } = input; + useEffect(() => { reset(null); }, [activeTabId, sessionPath, reset, turnStartAt]); } diff --git a/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts b/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts index cd1f8c4735..9bc151d7a9 100644 --- a/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts +++ b/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts @@ -8,6 +8,7 @@ export type TurnVerificationCommandsInput = { activeTabId: string | undefined; turnStartAt: number; completionSummary: WireCompletionSummary | undefined; + sessionPath?: string; openChangedDock(): void; }; @@ -15,14 +16,14 @@ export type TurnVerificationCommandsInput = { * Owns the turn-verification reveal chain: opening the changed-files dock, * issuing a monotonically sequenced reveal request bound to the tab and turn * that published it, and resetting the request whenever the tab, turn or - * current completion summary changes. WorkspacePanel consumes the request; + * session changes. Summary updates preserve the selected result. WorkspacePanel consumes the request; * only the reveal lifecycle lives here. */ export function useTurnVerificationCommands(input: TurnVerificationCommandsInput) { const revealSequenceRef = useRef(0); const [verificationRevealRequest, setVerificationRevealRequest] = useState(null); - const openTurnVerification = useCommittedCommand((summary: WireCompletionSummary) => { + const openTurnResult = useCommittedCommand((summary: WireCompletionSummary, view: "changes" | "checks") => { input.openChangedDock(); revealSequenceRef.current += 1; setVerificationRevealRequest({ @@ -31,15 +32,25 @@ export function useTurnVerificationCommands(input: TurnVerificationCommandsInput tabId: input.activeTabId ?? "", turnStartAt: input.turnStartAt, currentSummary: input.completionSummary, + sessionPath: input.sessionPath, + view, }); }); + const openTurnVerification = useCommittedCommand((summary: WireCompletionSummary) => openTurnResult(summary, "checks")); + const openTurnChanges = useCommittedCommand((summary?: WireCompletionSummary) => { + if (summary) openTurnResult(summary, "changes"); + else { setVerificationRevealRequest(null); input.openChangedDock(); } + }); + const closeTurnResult = useCommittedCommand(() => setVerificationRevealRequest(null)); + useVerificationRevealReset({ activeTabId: input.activeTabId, completionSummary: input.completionSummary, + sessionPath: input.sessionPath, turnStartAt: input.turnStartAt, reset: setVerificationRevealRequest, }); - return { verificationRevealRequest, openTurnVerification }; + return { verificationRevealRequest, openTurnVerification, openTurnChanges, closeTurnResult }; } diff --git a/desktop/frontend/src/app-shell/AppRuntimeView.tsx b/desktop/frontend/src/app-shell/AppRuntimeView.tsx index 465504499b..b329ed5f68 100644 --- a/desktop/frontend/src/app-shell/AppRuntimeView.tsx +++ b/desktop/frontend/src/app-shell/AppRuntimeView.tsx @@ -347,7 +347,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) { onPrompt: session.transcript.handleTranscriptPrompt, onDeliveryContinue: () => void session.delivery.handleDeliveryContinue(), onAcceptDelivery: session.controlCommands.handleAcceptDelivery, - onOpenChanges: () => session.workspacePanelCommands.openRightDockMode("changed"), + onOpenChanges: session.turnVerificationCommands.openTurnChanges, onOpenVerification: session.turnVerificationCommands.openTurnVerification, onEditPrompt: session.sessionUndo.handleEditPrompt, onRewind: session.sessionUndo.handleMessageAction, diff --git a/desktop/frontend/src/app-shell/dockRegionBuilders.ts b/desktop/frontend/src/app-shell/dockRegionBuilders.ts index 9036b80d2b..a6b963a796 100644 --- a/desktop/frontend/src/app-shell/dockRegionBuilders.ts +++ b/desktop/frontend/src/app-shell/dockRegionBuilders.ts @@ -42,7 +42,7 @@ export function buildWorkspaceDockProps(input: { geometry: ShellGeometry; panels: WorkspacePanelApi; inserts: InsertCommands; - verification: { verificationRevealRequest: WorkspaceVerificationRevealRequest | null }; + verification: { verificationRevealRequest: WorkspaceVerificationRevealRequest | null; closeTurnResult?: () => void }; qualityFloor: ComposerProfile["qualityFloor"]; onFileTreeRefresh: () => void; onSessionRevertCommitted: WorkspaceDockRegionProps["workspace"]["onSessionRevertCommitted"]; @@ -82,6 +82,7 @@ export function buildWorkspaceDockProps(input: { onOpenInTerminal: input.onOpenInTerminal, initialViewMode: input.mode === "changed" ? "changed" : "files", completionSummary: input.completionSummary, turnStartAt: input.turnStartAt, + sessionPath: input.meta?.sessionPath, onDismissTurnResult: input.verification.closeTurnResult, verificationRevealRequest: input.verification.verificationRevealRequest, qualityFloor: input.qualityFloor, showViewTabs: false, creationMode: input.creation, }, diff --git a/desktop/frontend/src/components/Transcript.tsx b/desktop/frontend/src/components/Transcript.tsx index d422c9247e..b629a111c0 100644 --- a/desktop/frontend/src/components/Transcript.tsx +++ b/desktop/frontend/src/components/Transcript.tsx @@ -84,7 +84,7 @@ export type TranscriptProps = { onPrompt: (text: string) => void; onDeliveryContinue?: () => void; onAcceptDelivery?: () => void; - onOpenChanges?: () => void; + onOpenChanges?: (summary?: WireCompletionSummary) => void; onOpenVerification?: (summary: WireCompletionSummary) => void; onEditPrompt?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise; onRewind?: (turn: number, scope: string) => void; diff --git a/desktop/frontend/src/components/TranscriptCards.tsx b/desktop/frontend/src/components/TranscriptCards.tsx index 0c12c128ca..127a266348 100644 --- a/desktop/frontend/src/components/TranscriptCards.tsx +++ b/desktop/frontend/src/components/TranscriptCards.tsx @@ -6,6 +6,7 @@ import { CheckCheck, ChevronRight, CirclePlay, ClipboardCheck, FileSearch, Info, import { useT } from "../lib/i18n"; import type { CompactionItem, NoticeItem } from "../lib/transcriptRows"; import type { WireCompletionSummary } from "../lib/types"; +import { TurnResultSummary } from "./TurnResultSummary"; import { STEER_NOTICE_PREFIX } from "../lib/useController"; import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard"; import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext"; @@ -70,12 +71,13 @@ export function NoticeCard({ item, onAction, onAccept, onOpenVerification, actio const StatusIcon = item.level === "warn" ? TriangleAlert : Info; const ActionIcon = item.action === "open_changes" ? FileSearch : CirclePlay; const showVerification = item.variant === "completion" && Boolean(item.completionSummary && onOpenVerification); + const result = item.variant === "completion" ? item.completionSummary : undefined; const showActions = Boolean((item.action && onAction) || onAccept || showVerification); return (
-