Skip to content

Commit 5355b72

Browse files
committed
Show the reported plan and a Save to Notes action under each finished turn
TaskPlanCard lists each step as Not started, In progress, Reported done or Status unknown, with where the plan came from and its revision. Under each finished turn, Save to Notes (then Update note) saves the turn's record and offers Open. e2e/task-to-notes.spec.ts failed on both tests before this and passes now. Refs #280 Coven-Harness: Claude Code / claude-opus-5-5
1 parent d49927a commit 5355b72

5 files changed

Lines changed: 147 additions & 1 deletion

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { currentRow, items, launch, seedHome, tid } from "./support";
5+
6+
/**
7+
* A task's plan and record (docs/task-context.md): the plan the CLI reported, in its statuses, and an
8+
* explicit Save to Notes that writes the turn's record into the project's notes and updates it when
9+
* saved again. Plan items are sent to the window as main would; the saved turn ran on the mock engine.
10+
*/
11+
let app: ElectronApplication;
12+
let page: Page;
13+
let home: string;
14+
let repo: string;
15+
let threadId: string;
16+
17+
const at = () => new Date().toISOString();
18+
19+
test.beforeAll(async () => {
20+
({ home, repo } = seedHome());
21+
({ app, page } = await launch(home));
22+
const script = path.join(home, "task-script.json");
23+
fs.writeFileSync(script, JSON.stringify([{ content: "Fixed the import." }]));
24+
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
25+
await page.keyboard.press("Meta+n");
26+
await expect(tid(page, "composer-input")).toBeFocused();
27+
await tid(page, "composer-input").fill("Fix the failing import");
28+
await page.keyboard.press("Meta+Enter");
29+
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
30+
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
31+
threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
32+
});
33+
34+
test.afterAll(async () => {
35+
await app?.close();
36+
fs.rmSync(home, { recursive: true, force: true });
37+
fs.rmSync(repo, { recursive: true, force: true });
38+
});
39+
40+
test("a plan shows each step as the CLI reported it, never as verified", async () => {
41+
const origin = { thread: threadId, turn: "u-plan", attempt: "P1", backend: "codex", native: { runtime: "R", item: "plan" } };
42+
await app.evaluate(({ BrowserWindow }, events) => {
43+
for (const event of events) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
44+
}, [
45+
{ type: "item", threadId, item: { id: "u-plan", kind: "user", text: "plan turn", at: at() } },
46+
{ type: "item", threadId, item: { id: "p", kind: "plan", from: "codex-plan", revision: 2, explanation: "Small fix", outcome: "succeeded", origin, at: at(),
47+
steps: [{ text: "Read the config", status: "completed" }, { text: "Fix the import", status: "in-progress" }, { text: "Run the tests", status: "pending" }, { text: "Ship", status: "unknown" }] } },
48+
]);
49+
const plan = items(page, "plan");
50+
await expect(tid(plan, "plan-meta")).toHaveText("From Codex's plan · revision 2");
51+
await expect(tid(plan, "plan-step")).toHaveText(["Reported done Read the config", "In progress Fix the import", "Not started Run the tests", "Status unknown Ship"]);
52+
await expect(plan).toContainText("Small fix");
53+
await expect(plan).not.toContainText(/verified/i);
54+
});
55+
56+
test("Save to Notes writes the turn's record into the project's notes, and saving again updates the same note", async () => {
57+
const save = tid(page, "save-task-note").first();
58+
await save.click();
59+
await expect(tid(page, "task-note-saved").first()).toContainText("Saved to Notes · Task:");
60+
const pages = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { id: string; title: string; markdown: string }[];
61+
const saved = (await pages()).filter((p) => p.title.startsWith("Task:"));
62+
expect(saved).toHaveLength(1);
63+
expect(saved[0]!.markdown).toContain("> Fix the failing import");
64+
expect(saved[0]!.markdown).toContain("Fixed the import.");
65+
expect(saved[0]!.markdown).toContain(`From modex thread ${threadId}.`);
66+
expect(saved[0]!.markdown).not.toContain(repo);
67+
await expect(save).toHaveText("Update note");
68+
await save.click();
69+
await expect(tid(page, "task-note-saved").first()).toContainText("Updated in Notes · Task:");
70+
expect((await pages()).filter((p) => p.title.startsWith("Task:"))).toHaveLength(1);
71+
});

‎apps/desktop/src/renderer/components/ThreadView.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ErrorNotice } from "./ui/ErrorNotice";
1616
import { TurnContent, groupTurns } from "./transcript/TurnContent";
1717
import { ReasoningItem } from "./transcript/ReasoningItem";
1818
import { EvidenceCard } from "./transcript/EvidenceCard";
19+
import { SaveTaskNote, TaskPlanCard } from "./transcript/TaskPlanCard";
1920
import { OUTPUT_PREVIEW_BYTES } from "../../shared/evidence";
2021

2122
export { formatElapsed } from "./transcript/TurnContent";
@@ -170,6 +171,7 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
170171
)}
171172
{turns.map((turn, i) => (
172173
<TurnContent key={turn.user?.id ?? `pre-${i}`} turn={turn} status={i === lastUser && busy ? thread.status : undefined}
174+
footer={(t) => <SaveTaskNote threadId={thread.id} turnId={t.user!.id} saved={Boolean(thread.taskNotes?.[t.user!.id])} />}
173175
render={(item, live) => <Item key={item.id} item={item} live={live} onAnswer={onAnswer} onPolicy={(approvals) => onUpdate({ approvals })} onFix={onFix} onRetry={!thread.retired && !busy && item.id === retryId ? onRetry : undefined} />} />
174176
))}
175177
</div>
@@ -247,6 +249,8 @@ function Item({ item, live, onAnswer, onPolicy, onFix, onRetry }: { item: Thread
247249
return <ReasoningItem item={item} />;
248250
case "route":
249251
return <RouteItem item={item} />;
252+
case "plan":
253+
return <TaskPlanCard item={item} />;
250254
}
251255
}
252256

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState } from "react";
2+
import type { ThreadItem } from "../../../shared/types";
3+
import type { PlanStep } from "../../../shared/conversation-events";
4+
import { bridge } from "../../bridge";
5+
import { ErrorNotice } from "../ui/ErrorNotice";
6+
7+
const STATUS: Record<PlanStep["status"], string> = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" };
8+
const FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list" } as const;
9+
10+
/**
11+
* The plan a CLI reported for a turn (docs/task-context.md), in its own statuses. A step the CLI
12+
* marked completed reads "Reported done": what it said, not a check.
13+
*/
14+
export function TaskPlanCard({ item }: { item: Extract<ThreadItem, { kind: "plan" }> }) {
15+
return (
16+
<div className="plan-card" data-testid="item" data-item-kind="plan">
17+
<div className="plan-head">
18+
<span className="plan-title">Plan</span>
19+
<span className="plan-meta" data-testid="plan-meta">From {FROM[item.from]} · revision {item.revision}</span>
20+
</div>
21+
{item.explanation && <p className="plan-explanation">{item.explanation}</p>}
22+
<ol className="plan-steps">
23+
{item.steps.map((step, i) => (
24+
<li key={i} className={`plan-step ${step.status}`} data-testid="plan-step" data-status={step.status}>
25+
<span className="plan-status">{STATUS[step.status]}</span> {step.text}
26+
</li>
27+
))}
28+
</ol>
29+
</div>
30+
);
31+
}
32+
33+
/**
34+
* Saves one turn's task record to its project's notes, only when asked. Saving again updates the
35+
* same note, unless it was edited since; then the edit stays and this version becomes a new note.
36+
*/
37+
export function SaveTaskNote({ threadId, turnId, saved }: { threadId: string; turnId: string; saved: boolean }) {
38+
const [state, setState] = useState<{ busy: boolean; result?: { pageId: string; title: string; created: boolean; preserved: boolean }; error?: string }>({ busy: false });
39+
const save = async () => {
40+
setState((s) => ({ ...s, busy: true, error: undefined }));
41+
try { setState({ busy: false, result: await bridge.invoke("task:save-note", { threadId, turnId }) }); }
42+
catch (err) { setState({ busy: false, error: (err as Error).message }); }
43+
};
44+
const result = state.result;
45+
return (
46+
<div className="task-note">
47+
<button type="button" className="task-note-save" data-testid="save-task-note" disabled={state.busy} onClick={() => void save()}>{saved || result ? "Update note" : "Save to Notes"}</button>
48+
{result && <span className="task-note-saved" data-testid="task-note-saved">
49+
{result.created ? (result.preserved ? "The note was edited since; saved as a new note · " : "Saved to Notes · ") : "Updated in Notes · "}{result.title}
50+
<button type="button" className="btn small" onClick={() => window.dispatchEvent(new CustomEvent("modex:open-page", { detail: result.pageId }))}>Open</button>
51+
</span>}
52+
{state.error && <ErrorNotice testId="task-note-error" where="Notes · save task" message={state.error} onRetry={state.busy ? undefined : () => void save()} context={{ threadId, turnId }} />}
53+
</div>
54+
);
55+
}

‎apps/desktop/src/renderer/components/transcript/TurnContent.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function groupTurns(items: ThreadItem[]): Turn[] {
2323
* Keep the final answer and actionable items visible; completed work stays inspectable. Which item
2424
* answers, which are live and which may fold comes from `layoutTurn` (shared/turn-layout.ts).
2525
*/
26-
export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode }) {
26+
export function TurnContent({ turn, status, render, footer }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode; footer?: (turn: Turn) => ReactNode }) {
2727
const [expanded, setExpanded] = useState(false);
2828
const historyId = useId();
2929
const live = status !== undefined;
@@ -41,6 +41,7 @@ export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thr
4141
<div key={item.id} hidden={layout.work[index] && !showWork} data-final={index === layout.final ? "true" : undefined} data-live={layout.live[index] ? "true" : undefined}>{render(item, layout.live[index]!)}</div>,
4242
])}
4343
</div>
44+
{!live && turn.user && turn.rest.length > 0 && footer?.(turn)}
4445
</>;
4546
}
4647

‎apps/desktop/src/renderer/styles.css‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,21 @@ a.thread-pr:hover { text-decoration: underline; }
679679
.evidence-where { overflow-wrap: anywhere; color: var(--text-2); font: 12px var(--font-mono); }
680680
.evidence-note { margin-left: 8px; color: var(--text-3); }
681681

682+
/* ── Task plans and records (docs/task-context.md). ── */
683+
.plan-card { margin: 4px 0 14px; padding: 10px 14px; border: 1px solid var(--border-pane); border-radius: var(--radius-md); font-size: var(--text-sm); }
684+
.plan-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
685+
.plan-title { color: var(--text-1); font-weight: var(--weight-semibold); }
686+
.plan-meta, .plan-explanation { color: var(--text-3); font-size: var(--text-xs); }
687+
.plan-explanation { margin: 0 0 6px; }
688+
.plan-steps { margin: 0; padding-left: 18px; display: grid; gap: 3px; color: var(--text-2); }
689+
.plan-status { display: inline-block; min-width: 92px; color: var(--text-3); font-size: var(--text-xs); }
690+
.plan-step.completed .plan-status { color: var(--text-2); }
691+
.task-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: -6px 0 18px; font-size: var(--text-xs); color: var(--text-3); }
692+
.task-note-save { border: 0; background: transparent; padding: 2px 0; color: var(--text-3); font: inherit; cursor: pointer; }
693+
.task-note-save:hover { color: var(--text-1); }
694+
.task-note-save:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
695+
.task-note-saved { display: inline-flex; align-items: center; gap: 6px; }
696+
682697
/* Small controls keep their look but get a 28 px hit area. */
683698
.icon-btn.sm, .composer-chip, .icon { position: relative; }
684699
.icon-btn.sm::before, .icon::before { content: ""; position: absolute; inset: -3px; }

0 commit comments

Comments
 (0)