9a3bee0288
Stacked on #4418. Merge that first. The UI slice of the dashboard agent: the side panel, the chat transport wiring, message and card rendering, suggested prompts, and chat history. #4418 works without this — the system is simply invisible. The diff is mostly components, so the notes below cover only the three decisions you can't read off the markup. Behavior and a hands-on walkthrough live in GUIDEBOOK.md, which lands with #4525. ## Decisions worth knowing - **Action rows always render at the end of a turn.** The model's emission order isn't trusted for layout, so action blocks are split out of the stream and appended last. Display only — `answered` stays keyed on the emission index. - **The last-chat memory is org-true.** It's keyed by the chat's own organization, and a foreign or deleted chat comes back as a 404 the client treats as gone, rather than an empty chat it keeps around. - **A dead stream self-heals from the settled transcript.** Terminal records are written to the chat row after the client's stream closes, so the panel re-reads it. The poll gate is any unfinished turn — a dangling tool part, not just an open investigation. ## Notes - Gated by `canAccessDashboardAgent`; no behavior change with the flag off. - Page marks: `handle.agentPageContext` on 47 routes, ~20 lines each. - Entry points: Ask Trigger button, ⌘J, Help & Feedback. The old ⌘I and `?aiHelp=` links keep working. ## Screenshots <img width="1440" height="788" alt="Screenshot 2026-08-07 at 15 14 29" src="https://github.com/user-attachments/assets/f4e89e8d-13ed-4be3-a88d-d5cca3ece0fa" />
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./progress-line";
|
|
|
|
/**
|
|
* Re-reading the stored transcript once a turn settles.
|
|
*
|
|
* The turn's terminal records — a force-settled investigation card, a failure
|
|
* record — are written to the chat row, not pushed as a stream chunk. An open panel
|
|
* has already closed its stream by then, so without this it keeps rendering the last
|
|
* `in_progress` revision and spins until the user reloads.
|
|
*/
|
|
|
|
type Identified = { id: string };
|
|
|
|
/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */
|
|
function stillRunning(message: unknown): boolean {
|
|
const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts;
|
|
if (!Array.isArray(parts)) return false;
|
|
return parts.some(
|
|
(part) =>
|
|
typeof part?.type === "string" &&
|
|
part.type.startsWith("tool-") &&
|
|
IN_FLIGHT_TOOL_STATES.has(part.state ?? "")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Merge the authoritative re-read into what the panel holds, keyed on the message id.
|
|
*
|
|
* Genuinely-new messages (a settlement card is `investigation-settlement:{id}:{revision}`)
|
|
* are appended, so re-reading the same transcript any number of times never produces a
|
|
* second copy. A message whose in-memory copy died mid-tool — a stream that EOF'd before
|
|
* the part settled — is replaced by its finished version from the re-read; otherwise it
|
|
* would show that step running forever. We only replace a still-running copy with a copy
|
|
* that has itself settled, so a live turn streaming under the same id is left alone and
|
|
* ordering is preserved.
|
|
*/
|
|
export function mergeSettledMessages<T extends Identified>(current: T[], fetched: T[]): T[] {
|
|
const byId = new Map(fetched.map((message) => [message.id, message]));
|
|
|
|
let replaced = false;
|
|
const next = current.map((existing) => {
|
|
const settled = byId.get(existing.id);
|
|
if (settled && settled !== existing && stillRunning(existing) && !stillRunning(settled)) {
|
|
replaced = true;
|
|
return settled;
|
|
}
|
|
return existing;
|
|
});
|
|
|
|
const missing = fetched.filter(
|
|
(message) => !current.some((existing) => existing.id === message.id)
|
|
);
|
|
if (missing.length === 0) return replaced ? next : current;
|
|
return [...next, ...missing];
|
|
}
|
|
|
|
/** Whether the transcript still resolves to a card mid-investigation. */
|
|
export function hasOpenInvestigation(messages: ReadonlyArray<unknown>): boolean {
|
|
return liveInvestigation(messages as never) !== null;
|
|
}
|
|
|
|
/**
|
|
* Whether the transcript still reads as mid-turn. A stream that dies without
|
|
* `turn-complete` leaves the tool part it was on dangling forever, so an open card is
|
|
* not the only shape a re-read has to recover from.
|
|
*/
|
|
export function transcriptLooksUnfinished(messages: ReadonlyArray<unknown>): boolean {
|
|
return hasOpenInvestigation(messages) || inFlightToolName(messages as never) !== null;
|
|
}
|
|
|
|
/**
|
|
* The settlement is written in `onTurnComplete`, which runs AFTER the client's stream
|
|
* closes, so the first re-read can legitimately land before it. Retry a few times,
|
|
* then leave it: a reload and the between-turns sweep are both still backstops.
|
|
*/
|
|
export const SETTLE_REFETCH_DELAYS_MS = [200, 800, 2_500];
|
|
|
|
export async function pollSettledTranscript<T extends Identified>(deps: {
|
|
fetchTranscript: () => Promise<T[] | null>;
|
|
apply: (merge: (current: T[]) => T[]) => void;
|
|
wait: (ms: number) => Promise<void>;
|
|
delays?: ReadonlyArray<number>;
|
|
}): Promise<void> {
|
|
for (const delay of deps.delays ?? SETTLE_REFETCH_DELAYS_MS) {
|
|
await deps.wait(delay);
|
|
const fetched = await deps.fetchTranscript();
|
|
if (!fetched) return;
|
|
deps.apply((current) => mergeSettledMessages(current, fetched));
|
|
// The stored transcript is the authority on whether anything is still open. Same test that
|
|
// starts the poll, so a stream that died mid-tool is followed until it settles too.
|
|
if (!transcriptLooksUnfinished(fetched)) return;
|
|
}
|
|
}
|
|
|
|
export async function fetchChatTranscript<T extends Identified>(
|
|
actionPath: string,
|
|
chatId: string
|
|
): Promise<T[] | null> {
|
|
try {
|
|
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(chatId)}`);
|
|
if (!res.ok) return null;
|
|
const data = (await res.json()) as { messages?: unknown };
|
|
if (!Array.isArray(data.messages)) return null;
|
|
// Anything else under `messages` is not a transcript; keep only what merging can key on.
|
|
return data.messages.filter(
|
|
(message): message is T =>
|
|
typeof message === "object" &&
|
|
message !== null &&
|
|
typeof (message as Identified).id === "string"
|
|
);
|
|
} catch (error) {
|
|
console.error("Dashboard agent: failed to re-read the settled transcript", error);
|
|
return null;
|
|
}
|
|
}
|