Skip to content
Open
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
22 changes: 20 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tauri-apps/cli": "^2.5.0",
"@types/diff": "^7.0.2",
"@types/node": "22.17.0",
"@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.2",
Expand All @@ -70,7 +71,6 @@
"vitest": "^4.0.18"
},
"dependencies": {
"@recallai/desktop-sdk": "2.0.26",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
Expand All @@ -87,6 +87,7 @@
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@recallai/desktop-sdk": "2.0.26",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.3.3",
Expand Down
32 changes: 32 additions & 0 deletions src/lib/scratchpad-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseScratchpadPayload,
repairScratchpadPayload,
stripScratchpadBlocks,
stripStreamingBlocks,
} from "./scratchpad-block";

const block = (payload: string) => "```brains-scratchpad\n" + payload + "\n```";
Expand Down Expand Up @@ -107,3 +108,34 @@ describe("stripScratchpadBlocks", () => {
expect(stripScratchpadBlocks(text)).toBe("Before\n\nAfter");
});
});

describe("stripStreamingBlocks", () => {
it("hides a block that is still being written", () => {
const partial = 'Here you go.\n```brains-scratchpad\n{"title":"T","html":"<div id="log">rea';
expect(stripStreamingBlocks(partial)).toBe("Here you go.");
});

it("hides raw markup when the turn was rejoined mid-block", () => {
// Switching tabs mid-turn used to resume the stream with no opening fence,
// so document markup rendered as the agent's own message:
// ched tabs and came back<button onclick="probe()">Probe environment…
// With the stream buffered per run the fence is present again, and
// everything from it onwards is cut.
const buffered =
"I switched tabs and came back\n```brains-scratchpad\n" +
'ched tabs and came back<button onclick="probe()">Probe environment<div id="log">ready.';
expect(stripStreamingBlocks(buffered)).toBe("I switched tabs and came back");
});

it("keeps prose that follows a COMPLETE block", () => {
// The open-block cut runs to end-of-string, so it must never see a block
// that already closed — otherwise the agent's closing question disappears.
const text = "Drafted.\n" + block('{"title":"T","html":"<p>x</p>"}') + "\nWant me to send it?";
expect(stripStreamingBlocks(text)).toBe("Drafted.\n\nWant me to send it?");
});

it("leaves ordinary prose and complete code blocks alone", () => {
const text = "Here is a snippet:\n```ts\nconst a = 1;\n```\nDone.";
expect(stripStreamingBlocks(text)).toBe(text);
});
});
17 changes: 17 additions & 0 deletions src/lib/scratchpad-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,20 @@ export function stripScratchpadBlocks(text: string): string {
SCRATCHPAD_BLOCK.lastIndex = 0;
return text.replace(SCRATCHPAD_BLOCK, "").trim();
}

/** A block that has been opened but not yet closed — i.e. one still streaming. */
const OPEN_BLOCK = /```(?:brains-)?scratchpad[\s\S]*$/i;

/**
* What a PARTIAL turn should show. A block's payload is a document, not prose:
* while it streams, the raw JSON and HTML would otherwise render as the agent's
* message — and worse, rejoining a turn mid-block means the opening fence
* arrived while the user was elsewhere, so the strip above cannot match it and
* naked markup ("…<div id="log">ready.") renders as an answer.
*
* Complete blocks are removed first, so this only ever cuts one that is
* genuinely unterminated — never prose that follows a finished block.
*/
export function stripStreamingBlocks(text: string): string {
return stripScratchpadBlocks(text).replace(OPEN_BLOCK, "").trim();
}
129 changes: 129 additions & 0 deletions src/lib/turn-restore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
isTerminalRunState,
mergeWorkingRuns,
restoredTurnActivity,
runStateIsSettled,
runStateIsWorking,
tabIsWorkingOnOpen,
TERMINAL_RUN_STATES,
} from "./turn-restore";

describe("restoredTurnActivity", () => {
it("shows the working indicator for a turn still in flight", () => {
// The regression this exists for: switching away from a working chat and
// back rendered the user's message with nothing under it and no spinner.
expect(restoredTurnActivity({ hasPendingAsk: false, runState: "running" })).toBe("working");
expect(restoredTurnActivity({ hasPendingAsk: false, runState: "pending" })).toBe("working");
expect(restoredTurnActivity({ hasPendingAsk: false, runState: "RUNNING" })).toBe("working");
});

it("counts a SPAWNING turn as working — the cold-start window", () => {
// The nastiest version of this bug: the live event handler counts anything
// non-terminal as working, so if the restore recognised only running/pending
// it would classify the entire spawn (10-20s on a cold CLI) as idle, tear
// down a live turn's spinner and disarm its watchdog. Any state that is not
// terminal must mean working, in BOTH places.
expect(restoredTurnActivity({ hasPendingAsk: false, runState: "spawning" })).toBe("working");
expect(restoredTurnActivity({ hasPendingAsk: false, runState: "some-future-state" })).toBe(
"working",
);
});

it("never spins when the agent is waiting on the user", () => {
expect(restoredTurnActivity({ hasPendingAsk: true, runState: "running" })).toBe(
"waiting-for-user",
);
});

it("is idle for finished states, and for no state at all", () => {
for (const runState of [...TERMINAL_RUN_STATES, "", " "]) {
expect(restoredTurnActivity({ hasPendingAsk: false, runState })).toBe("idle");
}
});
});

describe("isTerminalRunState", () => {
it("is the exact complement the live event handler uses to clear workingRuns", () => {
for (const state of TERMINAL_RUN_STATES) expect(isTerminalRunState(state)).toBe(true);
for (const state of ["running", "pending", "spawning", "anything-else"]) {
expect(isTerminalRunState(state)).toBe(false);
}
});

it("tolerates casing, padding and non-strings", () => {
expect(isTerminalRunState(" IDLE ")).toBe(true);
expect(isTerminalRunState(null)).toBe(false);
expect(isTerminalRunState(undefined)).toBe(false);
});
});

describe("runStateIsWorking", () => {
it("is the one predicate every consumer of a run state should share", () => {
expect(runStateIsWorking("running")).toBe(true);
expect(runStateIsWorking("spawning")).toBe(true);
expect(runStateIsWorking("pending")).toBe(true);
for (const state of TERMINAL_RUN_STATES) expect(runStateIsWorking(state)).toBe(false);
// No state makes no claim that work is happening.
expect(runStateIsWorking("")).toBe(false);
expect(runStateIsWorking(null)).toBe(false);
});
});

describe("runStateIsSettled", () => {
it("excludes idle, because a live process sits idle between turns", () => {
// This is the distinction that makes mergeWorkingRuns correct: `idle` is
// terminal for "is a turn running?" but NOT proof the run is over, because a
// warm agent process reports idle while waiting for its first message.
expect(runStateIsSettled("idle")).toBe(false);
expect(runStateIsSettled("completed")).toBe(true);
expect(runStateIsSettled("failed")).toBe(true);
expect(runStateIsSettled("stopped")).toBe(true);
expect(runStateIsSettled("running")).toBe(false);
});
});

describe("mergeWorkingRuns", () => {
it("keeps a just-marked warm run that the backend still reports as idle", () => {
// The regression this exists for: dispatch marks the run in-flight, then calls
// loadSessions() unawaited. On the warm path the row is still `idle` (the
// process is alive, waiting), so rebuilding the set from persisted status
// erased the mark — and a tab switch in that window read the turn as dead.
const merged = mergeWorkingRuns(new Set(["warm-1"]), [{ id: "warm-1", status: "idle" }]);
expect(merged.has("warm-1")).toBe(true);
});

it("still adopts runs the backend reports as running", () => {
const merged = mergeWorkingRuns(new Set(), [
{ id: "a", status: "running" },
{ id: "b", status: "idle" },
]);
expect([...merged]).toEqual(["a"]);
});

it("reaps a mark once the run has genuinely settled", () => {
for (const status of ["completed", "failed", "stopped"]) {
const merged = mergeWorkingRuns(new Set(["gone"]), [{ id: "gone", status }]);
expect(merged.has("gone")).toBe(false);
}
});

it("keeps a mark for a run missing from the list rather than dropping a live turn", () => {
const merged = mergeWorkingRuns(new Set(["unlisted"]), [{ id: "other", status: "idle" }]);
expect(merged.has("unlisted")).toBe(true);
});
});

describe("tabIsWorkingOnOpen", () => {
it("seeds the spinner from the globally tracked working set", () => {
const working = new Set(["run-a"]);
expect(tabIsWorkingOnOpen("run-a", working)).toBe(true);
expect(tabIsWorkingOnOpen("run-b", working)).toBe(false);
});

it("is false for a tab with no session yet", () => {
expect(tabIsWorkingOnOpen(null, new Set(["run-a"]))).toBe(false);
expect(tabIsWorkingOnOpen("", new Set(["run-a"]))).toBe(false);
expect(tabIsWorkingOnOpen(undefined, new Set())).toBe(false);
});
});
127 changes: 127 additions & 0 deletions src/lib/turn-restore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* What should the chat show for a session the user just switched (back) into?
*
* A turn outlives the tab it was started from: the agent keeps working while the
* user reads another conversation. Coming back must therefore restore one of
* three states, and getting it wrong is what made a live turn look dead — the
* user's message on screen, nothing under it, no spinner.
*/

/**
* The run states that mean a turn is OVER. Everything else — `running`,
* `pending`, `spawning`, and any state a future CLI adds — means it is still
* going.
*
* This list is the single source of truth for that question, and it is shared
* deliberately: the live event handler decides "is this run working?" by the
* complement of this set, and the restore path below must agree with it exactly.
* When the two drift the disagreement is invisible and brutal — a turn spends
* its whole `spawning` phase (10-20s on a cold CLI) counted as working by one
* and idle by the other, so returning to the tab tears down a live turn's
* spinner and disarms its watchdog.
*/
export const TERMINAL_RUN_STATES = ["idle", "completed", "failed", "stopped"] as const;

export function isTerminalRunState(state: unknown): boolean {
return (TERMINAL_RUN_STATES as readonly string[]).includes(
String(state ?? "")
.trim()
.toLowerCase(),
);
}

/**
* Is a turn still in flight, judged from a run state alone?
*
* Every consumer of a run state should ask through this, so they cannot drift
* apart the way the live handler and the restore once did. An empty state makes
* no claim that work is happening and counts as not-working; anything else that
* is not terminal — including `spawning` — counts as working.
*/
export function runStateIsWorking(state: unknown): boolean {
const value = String(state ?? "")
.trim()
.toLowerCase();
return !!value && !isTerminalRunState(value);
}

/**
* Terminal states a LIVE process can never sit in.
*
* `idle` is deliberately NOT one of them. A warm agent process is idle between
* turns, so its run row reads `idle` while it is very much alive — and a poll
* that took `idle` for "finished" is precisely what erased a turn the moment
* after it was marked in-flight. These three, by contrast, mean the run is over
* for good, so they are safe to reap a stale mark with.
*/
const SETTLED_RUN_STATES = ["completed", "failed", "stopped"] as const;

export function runStateIsSettled(state: unknown): boolean {
return (SETTLED_RUN_STATES as readonly string[]).includes(
String(state ?? "")
.trim()
.toLowerCase(),
);
}

/**
* Fold a freshly-polled run list into the set of runs known to be working.
*
* The poll is a SEED, not the authority: it may add runs this app never marked
* (recovered from another source), but it must not remove a mark just because a
* row has not caught up yet. Live `run_state` events, an explicit stop, a failed
* dispatch and the watchdog are what clear marks — all of them definite. A mark
* is only reaped here when the backend says the run has genuinely settled.
*/
export function mergeWorkingRuns(
marked: ReadonlySet<string>,
persisted: ReadonlyArray<{ id: string; status?: unknown }>,
): Set<string> {
const next = new Set<string>();
for (const run of persisted) {
if (
String(run.status ?? "")
.trim()
.toLowerCase() === "running"
)
next.add(run.id);
}
const statusById = new Map(persisted.map((run) => [run.id, run.status]));
for (const id of marked) {
// Absent from the list is kept too: it can be filtered or lag, and dropping a
// live turn is worse than holding a mark for one more poll.
if (!statusById.has(id) || !runStateIsSettled(statusById.get(id))) next.add(id);
}
return next;
}

export type RestoredTurnActivity =
/** The agent asked something and is blocked on the user — never a spinner. */
| "waiting-for-user"
/** The turn is still in flight: show the working indicator, arm the watchdog. */
| "working"
/** Nothing in flight. */
| "idle";

export function restoredTurnActivity(input: {
hasPendingAsk: boolean;
/** Latest known run state ("running" | "spawning" | "idle" | "completed" | …). */
runState: string;
}): RestoredTurnActivity {
if (input.hasPendingAsk) return "waiting-for-user";
return runStateIsWorking(input.runState) ? "working" : "idle";
}

/**
* Optimistic seed used the instant a tab is shown, before any IPC: the workspace
* already tracks which runs are working (live `run_state` events maintain the set
* for EVERY run, not just the open one), so the spinner can be correct on the
* first frame instead of after a getRun + transcript round-trip. Authoritative
* state from `restoredTurnActivity` corrects it moments later.
*/
export function tabIsWorkingOnOpen(
sessionId: string | null | undefined,
workingRuns: ReadonlySet<string>,
): boolean {
return !!sessionId && workingRuns.has(sessionId);
}
Loading
Loading