Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions apps/desktop/e2e/task-to-notes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { currentRow, items, launch, seedHome, tid } from "./support";

/**
* A task's plan and record (docs/task-context.md): the plan the CLI reported, in its statuses, and an
* explicit Save to Notes that writes the turn's record into the project's notes and updates it when
* saved again. Plan items are sent to the window as main would; the saved turn ran on the mock engine.
*/
let app: ElectronApplication;
let page: Page;
let home: string;
let repo: string;
let threadId: string;

const at = () => new Date().toISOString();

test.beforeAll(async () => {
({ home, repo } = seedHome());
({ app, page } = await launch(home));
const script = path.join(home, "task-script.json");
fs.writeFileSync(script, JSON.stringify([{ content: "Fixed the import." }]));
await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script);
await page.keyboard.press("Meta+n");
await expect(tid(page, "composer-input")).toBeFocused();
await tid(page, "composer-input").fill("Fix the failing import");
await page.keyboard.press("Meta+Enter");
await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 });
await expect(currentRow(page)).toHaveAttribute("data-status", "idle");
threadId = (await currentRow(page).getAttribute("data-thread-id"))!;
});

test.afterAll(async () => {
await app?.close();
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});

test("a plan shows each step as the CLI reported it, never as verified", async () => {
const origin = { thread: threadId, turn: "u-plan", attempt: "P1", backend: "codex", native: { runtime: "R", item: "plan" } };
await app.evaluate(({ BrowserWindow }, events) => {
for (const event of events) BrowserWindow.getAllWindows()[0]!.webContents.send("thread:event", event);
}, [
{ type: "item", threadId, item: { id: "u-plan", kind: "user", text: "plan turn", at: at() } },
{ type: "item", threadId, item: { id: "p", kind: "plan", from: "codex-plan", revision: 2, explanation: "Small fix", outcome: "succeeded", origin, at: at(),
steps: [{ text: "Read the config", status: "completed" }, { text: "Fix the import", status: "in-progress" }, { text: "Run the tests", status: "pending" }, { text: "Ship", status: "unknown" }] } },
]);
const plan = items(page, "plan");
await expect(tid(plan, "plan-meta")).toHaveText("From Codex's plan · revision 2");
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"]);
await expect(plan).toContainText("Small fix");
await expect(plan).not.toContainText(/verified/i);
});

test("Save to Notes writes the turn's record into the project's notes, and saving again updates the same note", async () => {
const save = tid(page, "save-task-note").first();
await save.click();
await expect(tid(page, "task-note-saved").first()).toContainText("Saved to Notes · Task:");
const pages = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { id: string; title: string; markdown: string }[];
const saved = (await pages()).filter((p) => p.title.startsWith("Task:"));
expect(saved).toHaveLength(1);
expect(saved[0]!.markdown).toContain("> Fix the failing import");
expect(saved[0]!.markdown).toContain("Fixed the import.");
expect(saved[0]!.markdown).toContain(`From modex thread ${threadId}.`);
expect(saved[0]!.markdown).not.toContain(repo);
await expect(save).toHaveText("Update note");
await save.click();
await expect(tid(page, "task-note-saved").first()).toContainText("Updated in Notes · Task:");
expect((await pages()).filter((p) => p.title.startsWith("Task:"))).toHaveLength(1);
});
1 change: 1 addition & 0 deletions apps/desktop/src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function registerCommands(handle: RegisterCommand, {
});
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
handle("thread:items", ({ threadId }) => runner.items(threadId));
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments)));
registerAttachmentCommands(handle, runner.attachments, { bytesOnly: true, reveal: revealAttachment ?? (() => { throw new Error("Attachment reveal is unavailable."); }) });
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/main/engine/backends/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LineBuffer, shortJson, stderrTail } from "./types.js";
import { claudeCall, UsageMeter, withUsage } from "./usage.js";
import { cliEnvironment, cliSpawn, health, installation, probe } from "./health.js";
import type { BackendHealth } from "../../../shared/types.js";
import type { NativeRef, Observation } from "../../../shared/conversation-events.js";
import type { NativeRef, Observation, PlanStepStatus } from "../../../shared/conversation-events.js";
import { isModelOutOfCredits } from "../../../shared/failures.js";

/**
Expand Down Expand Up @@ -407,6 +407,11 @@ export class ClaudeBackend implements Backend {
continue;
}
ctx.started.set(block.id, Date.now());
// TodoWrite carries the whole todo list: Claude's plan, revised by each call.
if (block.name === "TodoWrite" && Array.isArray(block.input?.todos)) {
const steps = (block.input.todos as { content?: unknown; status?: unknown }[]).map((t) => ({ text: String(t.content ?? ""), status: claudeStepStatus(t.status) }));
ctx.observe({ type: "plan.update", native: ctx.native("todos"), from: "claude-todo", steps });
}
// Bash's command is what ran; its working directory is the CLI's own and persists between calls, so none is claimed.
const command = block.name === "Bash" && typeof block.input?.command === "string" ? block.input.command : undefined;
ctx.observe({ type: "tool.start", native: ctx.native(block.id), tool: { name: block.name ?? "tool", title: toolTitle(block.name ?? "tool", block.input ?? {}), args: block.input ?? {}, ...(command ? { exec: { command } } : {}) } });
Expand Down Expand Up @@ -582,6 +587,10 @@ export function thinkingRepresentation(display: string | undefined): "summary" |
return display === "summarized" ? "summary" : "provider-text";
}

function claudeStepStatus(status: unknown): PlanStepStatus {
return status === "pending" ? "pending" : status === "in_progress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
}

/** A `result` message as a turn outcome. */
function resultOf(msg: ClaudeMessage): TurnResult {
if (msg.is_error) return { status: "failed", error: typeof msg.result === "string" ? msg.result : msg.subtype ?? "error", detail: { resultSubtype: msg.subtype, errors: msg.errors, sessionId: msg.session_id, numTurns: msg.num_turns, durationMs: msg.duration_ms } };
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/engine/backends/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LineBuffer, stderrTail } from "./types.js";
import { codexCall, UsageMeter, withUsage } from "./usage.js";
import { cliEnvironment, cliSpawn, health, installation } from "./health.js";
import type { AgentActivity, BackendHealth } from "../../../shared/types.js";
import type { MessagePhase, NativeRef, SettledOutcome } from "../../../shared/conversation-events.js";
import type { MessagePhase, NativeRef, PlanStepStatus, SettledOutcome } from "../../../shared/conversation-events.js";
import { boundOutput, byteLength, memoryEvidence } from "../../../shared/evidence.js";

/**
Expand Down Expand Up @@ -425,6 +425,12 @@ export class CodexBackend implements Backend {
case "item/reasoning/textDelta":
sink.observe({ type: "reasoning.delta", native: native(p.itemId), representation: "provider-text", index: partIndex(p.contentIndex), text: String(p.delta ?? "") });
break;
// The plan Codex reports for the turn (codex-cli 0.163.0: pending, inProgress, completed), whole each time.
case "turn/plan/updated": {
const steps = Array.isArray(p.plan) ? (p.plan as { step?: unknown; status?: unknown }[]).map((s) => ({ text: String(s.step ?? ""), status: codexStepStatus(s.status) })) : [];
sink.observe({ type: "plan.update", native: native("plan"), from: "codex-plan", steps, ...(typeof p.explanation === "string" && p.explanation ? { explanation: p.explanation } : {}) });
break;
}
case "item/agentMessage/delta":
sink.observe({ type: "message.delta", native: native(p.itemId), text: String(p.delta ?? "") });
break;
Expand Down Expand Up @@ -700,6 +706,10 @@ export function codexOutcome(item: { type: string; status?: string; exitCode?: n
return item.error ? "failed" : "unknown";
}

function codexStepStatus(status: unknown): PlanStepStatus {
return status === "pending" ? "pending" : status === "inProgress" ? "in-progress" : status === "completed" ? "completed" : "unknown";
}

function partIndex(value: unknown): number {
const n = Number(value);
return Number.isSafeInteger(n) && n >= 0 ? n : 0;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/engine/companion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ export function mobileItem(item: ThreadItem): Record<string, unknown> {
case "tool": return { id: item.id, kind: item.kind, title: item.status === "done" && (item.outcome ? item.outcome !== "succeeded" : item.ok === false) ? `${item.title} · ${toolStatusLabel(item)}` : item.title,
status: item.status, ok: item.ok, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
case "route": return { id: item.id, kind: item.kind, text: `${item.backend} · ${item.model || "default"}`, at: item.at };
// A phone that predates plans shows a kind it does not know as its text.
case "plan": return { id: item.id, kind: item.kind, text: `Plan · ${item.steps.filter((s) => s.status === "completed").length} of ${item.steps.length} reported done`, steps: item.steps, revision: item.revision, ...(item.outcome ? { outcome: item.outcome } : {}), at: item.at };
}
}

Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { copyComposerRecovery } from "./update-recovery.js";
import { parseRecoveryJSON, recoveryJSON } from "./attachment-recovery-file.js";
import type { DraftRemovalScope } from "../../shared/native-drafts.js";
import { boundOutput } from "../../shared/evidence.js";
import { taskMarkdown, taskRecord } from "../../shared/task-context.js";
import { CONVERSATION_SCHEMA, applyConversationEvent, initialConversation, okFor, type AttemptMark, type ConversationEvent, type ConversationState, type Observation, type SettledOutcome, type TurnRef } from "../../shared/conversation-events.js";

/** Refused approvals in a routine run: the routine is read-only, and nobody is there to answer. */
Expand Down Expand Up @@ -301,6 +302,38 @@ export class ThreadRunner {
} finally { this.unattended.delete(threadId); }
}

/**
* Saves a turn's task record (shared/task-context.ts) to the thread's project notes, at a person's
* request. Saving again updates the same note, unless it was edited since: then the edit stays and
* this version becomes a note of its own. Writes only through the notes store; reads only the
* transcript.
*/
saveTaskNote(threadId: string, turnId: string): { pageId: string; title: string; revision: number; created: boolean; preserved: boolean } {
this.assertNotRestarting();
const thread = this.o.store.thread(threadId);
if (!thread) throw new Error(`unknown thread ${threadId}`);
const items = this.items(threadId);
const start = items.findIndex((item) => item.kind === "user" && item.id === turnId);
if (start < 0) throw new Error("That message is no longer in this thread.");
const next = items.findIndex((item, i) => i > start && item.kind === "user");
const turn = items.slice(start, next < 0 ? undefined : next);
const record = taskRecord(turn, { threadId, title: `Task: ${thread.title}`, cwd: thread.cwd });
const markdown = taskMarkdown(record);
const notes = this.o.store.space;
const project = notes.tree().nodes.find((node) => node.kind === "project" && node.modexProjectId === thread.projectId);
if (!project) throw new Error("This project has no notes yet. Open Notes for it, then save again.");
const saved = thread.taskNotes?.[turnId];
const existing = saved ? notes.list().find((page) => page.id === saved.pageId && !page.trashedAt) : undefined;
const change = { actor: "human", summary: "Saved a task record" };
const untouched = existing && existing.revision === saved!.revision;
const page = untouched
? notes.save({ ...existing, title: record.title, markdown }, change)
: notes.create({ title: existing ? `${record.title} (saved again)` : record.title, markdown, containerId: project.id }, change);
const updated = this.o.store.updateThread(threadId, { taskNotes: { ...thread.taskNotes, [turnId]: { pageId: page.id, revision: page.revision } } });
this.o.emit({ threadId, type: "thread", thread: updated });
return { pageId: page.id, title: page.title, revision: page.revision, created: !untouched, preserved: Boolean(existing && !untouched) };
}

/** A receipt in the transcript for something Modex did on the thread's behalf. */
addNotice(threadId: string, level: "info" | "warn" | "error", text: string): void {
this.addItem(threadId, { id: newId(), kind: "notice", level, text, at: new Date().toISOString() });
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ handle("project:remove", async ({ projectId }) => {
});
handle("thread:create", ({ projectId, worktree, mode, model, backend, auto }) => runner.createThread(projectId, { worktree, mode, model, backend, auto }));
handle("thread:items", ({ threadId }) => runner.items(threadId));
handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId));
handle("thread:followup", ({ threadId }) => runner.followUp(threadId));
/**
* Starts a turn without waiting for it. A rejection the runner can detect up front (busy thread,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ErrorNotice } from "./ui/ErrorNotice";
import { TurnContent, groupTurns } from "./transcript/TurnContent";
import { ReasoningItem } from "./transcript/ReasoningItem";
import { EvidenceCard } from "./transcript/EvidenceCard";
import { SaveTaskNote, TaskPlanCard } from "./transcript/TaskPlanCard";
import { OUTPUT_PREVIEW_BYTES } from "../../shared/evidence";

export { formatElapsed } from "./transcript/TurnContent";
Expand Down Expand Up @@ -170,6 +171,7 @@ export function ThreadView({ thread, project, items, text, onText, attachments,
)}
{turns.map((turn, i) => (
<TurnContent key={turn.user?.id ?? `pre-${i}`} turn={turn} status={i === lastUser && busy ? thread.status : undefined}
footer={(t) => <SaveTaskNote threadId={thread.id} turnId={t.user!.id} saved={Boolean(thread.taskNotes?.[t.user!.id])} />}
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} />} />
))}
</div>
Expand Down Expand Up @@ -247,6 +249,8 @@ function Item({ item, live, onAnswer, onPolicy, onFix, onRetry }: { item: Thread
return <ReasoningItem item={item} />;
case "route":
return <RouteItem item={item} />;
case "plan":
return <TaskPlanCard item={item} />;
}
}

Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src/renderer/components/transcript/TaskPlanCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { ThreadItem } from "../../../shared/types";
import type { PlanStep } from "../../../shared/conversation-events";
import { bridge } from "../../bridge";
import { ErrorNotice } from "../ui/ErrorNotice";

const STATUS: Record<PlanStep["status"], string> = { pending: "Not started", "in-progress": "In progress", completed: "Reported done", unknown: "Status unknown" };
const FROM = { "codex-plan": "Codex's plan", "claude-todo": "Claude's todo list" } as const;

/**
* The plan a CLI reported for a turn (docs/task-context.md), in its own statuses. A step the CLI
* marked completed reads "Reported done": what it said, not a check.
*/
export function TaskPlanCard({ item }: { item: Extract<ThreadItem, { kind: "plan" }> }) {
return (
<div className="plan-card" data-testid="item" data-item-kind="plan">
<div className="plan-head">
<span className="plan-title">Plan</span>
<span className="plan-meta" data-testid="plan-meta">From {FROM[item.from]} · revision {item.revision}</span>
</div>
{item.explanation && <p className="plan-explanation">{item.explanation}</p>}
<ol className="plan-steps">
{item.steps.map((step, i) => (
<li key={i} className={`plan-step ${step.status}`} data-testid="plan-step" data-status={step.status}>
<span className="plan-status">{STATUS[step.status]}</span> {step.text}
</li>
))}
</ol>
</div>
);
}

/**
* Saves one turn's task record to its project's notes, only when asked. Saving again updates the
* same note, unless it was edited since; then the edit stays and this version becomes a new note.
*/
export function SaveTaskNote({ threadId, turnId, saved }: { threadId: string; turnId: string; saved: boolean }) {
const [state, setState] = useState<{ busy: boolean; result?: { pageId: string; title: string; created: boolean; preserved: boolean }; error?: string }>({ busy: false });
const save = async () => {
setState((s) => ({ ...s, busy: true, error: undefined }));
try { setState({ busy: false, result: await bridge.invoke("task:save-note", { threadId, turnId }) }); }
catch (err) { setState({ busy: false, error: (err as Error).message }); }
};
const result = state.result;
return (
<div className="task-note">
<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>
{result && <span className="task-note-saved" data-testid="task-note-saved">
{result.created ? (result.preserved ? "The note was edited since; saved as a new note · " : "Saved to Notes · ") : "Updated in Notes · "}{result.title}
<button type="button" className="btn small" onClick={() => window.dispatchEvent(new CustomEvent("modex:open-page", { detail: result.pageId }))}>Open</button>
</span>}
{state.error && <ErrorNotice testId="task-note-error" where="Notes · save task" message={state.error} onRetry={state.busy ? undefined : () => void save()} context={{ threadId, turnId }} />}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function groupTurns(items: ThreadItem[]): Turn[] {
* Keep the final answer and actionable items visible; completed work stays inspectable. Which item
* answers, which are live and which may fold comes from `layoutTurn` (shared/turn-layout.ts).
*/
export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode }) {
export function TurnContent({ turn, status, render, footer }: { turn: Turn; status?: Thread["status"]; render: (item: ThreadItem, live: boolean) => ReactNode; footer?: (turn: Turn) => ReactNode }) {
const [expanded, setExpanded] = useState(false);
const historyId = useId();
const live = status !== undefined;
Expand All @@ -41,6 +41,7 @@ export function TurnContent({ turn, status, render }: { turn: Turn; status?: Thr
<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>,
])}
</div>
{!live && turn.user && turn.rest.length > 0 && footer?.(turn)}
</>;
}

Expand Down
Loading
Loading