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
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ describe("chatPaneSelectors", () => {
state: "started",
payload: { name: "Workflow", status: "running" },
streams: {},
observedLive: true,
},
"tool-2": {
id: "tool-2",
Expand All @@ -601,6 +602,28 @@ describe("chatPaneSelectors", () => {
]);
});

it("drops workflows hydrated from history (not observed live this session)", () => {
const state = {
runtimeItemIdsByThread: { t1: ["workflow-replayed"] },
runtimeItemsByIdByThread: {
t1: {
"workflow-replayed": {
id: "workflow-replayed",
type: "tool_call",
state: "completed",
payload: { name: "Workflow", status: "success" },
streams: {},
// No observedLive — seeded from the DB on thread reopen. The
// launching process is gone; the composer dock must stay empty.
},
},
},
runtimeStructuralVersionByThread: { t1: 1 },
} as unknown as AppStoreState;

expect(selectActiveSubAgentParentItemIds(state, "t1")).toEqual([]);
});

it("tracks running native Agent calls without retaining completed ones", () => {
const itemIds = ["agent-running", "agent-completed", "workflow-completed"];
const items = {
Expand Down Expand Up @@ -632,6 +655,7 @@ describe("chatPaneSelectors", () => {
state: "completed",
payload: { name: "Workflow", status: "success" },
streams: {},
observedLive: true,
},
};
const activeState = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState, type ReactNode } from "react";
import type { ProjectLocation } from "@/shared/contracts";
import { readBridge } from "@/renderer/bridge";
import { useAppStore } from "@/renderer/state/appStore";
import { selectVisibleThreadTimelineEntries } from "../../chatPaneSelectors";
import { ChatItemRow } from "./ChatItemRow";

/**
* Render a workflow agent's transcript with the real chat timeline. The
* supervisor converts the agent's on-disk jsonl into canonical runtime events;
* we key them under a synthetic per-agent thread id in the regular runtime
* store so `ChatItemRow` (which reads items from the store by thread id) works
* unchanged. Each poll clears and re-applies the synthetic thread — the events
* are deterministic snapshots, so replacing is cheaper than merging streamed
* deltas correctly.
*/
interface WorkflowAgentChatProps {
transcriptDir: string;
agentId: string;
/** Terminal agents are fetched once; running agents poll. */
agentFinished: boolean;
location: ProjectLocation;
/** Rendered while no transcript entries are available (yet). */
fallback: ReactNode;
}

const POLL_INTERVAL_MS = 1500;

export function WorkflowAgentChat({
transcriptDir,
agentId,
agentFinished,
location,
fallback,
}: WorkflowAgentChatProps) {
const syntheticThreadId = `wf-agent-chat:${agentId}`;
const entries = useAppStore((s) => selectVisibleThreadTimelineEntries(s, syntheticThreadId));
// Deferred fallback: render nothing until the first read settles, so the
// old boxed sections don't flash before the transcript-backed chat swaps in.
const [settledAgentId, setSettledAgentId] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
let timer: number | undefined;

const fetchOnce = async (): Promise<void> => {
try {
const { events } = await readBridge().workflowAgentChat({
threadId: syntheticThreadId,
transcriptDir,
agentId,
agentFinished,
location,
});
if (cancelled) return;
const store = useAppStore.getState();
store.clearThreadRuntimeEvents(syntheticThreadId);
if (events.length > 0) store.applyRuntimeEvents(syntheticThreadId, events);
} catch (err) {
console.warn("[workflow] agent chat read failed", { agentId, err });
}
if (cancelled) return;
setSettledAgentId(agentId);
if (!agentFinished) {
timer = window.setTimeout(() => void fetchOnce(), POLL_INTERVAL_MS);
}
};
void fetchOnce();

return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
useAppStore.getState().clearThreadRuntimeEvents(syntheticThreadId);
};
}, [syntheticThreadId, transcriptDir, agentId, agentFinished, location]);

if (entries.length === 0) {
return settledAgentId === agentId ? fallback : null;
}
return (
<div className="flex flex-col gap-1.5">
{entries.map((entry, index) => (
<ChatItemRow
key={entry.id}
threadId={syntheticThreadId}
entry={entry}
isLastEntry={!agentFinished && index === entries.length - 1}
checkpointRevert={null}
/>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PixelLoader } from "@/renderer/components/common/PixelLoader";
import { ThreadDockRow } from "@/renderer/components/thread/ThreadDockUI";
import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount";
import { useWorkflowRun } from "@/renderer/state/useWorkflowRun";
import { WorkflowAgentChat } from "./WorkflowAgentChat";
import type { WorkflowInfo } from "./workflowDisplay";

/**
Expand Down Expand Up @@ -94,7 +95,12 @@ export function WorkflowOverlayBody({
: t`No agents in this phase.`
}
/>
<AgentDetail agent={selectedAgent} phaseTitle={selectedAgent?.phaseTitle ?? phase.title} />
<AgentDetail
agent={selectedAgent}
phaseTitle={selectedAgent?.phaseTitle ?? phase.title}
transcriptDir={workflow.transcriptDir}
location={projectLocation}
/>
</div>
{unphased.length > 0 && phases.length > 0 ? (
<UnphasedAgents agents={unphased} onSelect={setActiveAgentId} />
Expand Down Expand Up @@ -266,7 +272,17 @@ function AgentRow({
);
}

function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phaseTitle: string }) {
function AgentDetail({
agent,
phaseTitle,
transcriptDir,
location,
}: {
agent: WorkflowAgent | null;
phaseTitle: string;
transcriptDir: string | undefined;
location: ProjectLocation | undefined;
}) {
if (!agent) {
return (
<div className="hidden min-h-0 overflow-y-auto px-3 py-3 sm:block">
Expand Down Expand Up @@ -303,6 +319,31 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase
</Trans>
</p>
) : null}
{transcriptDir && location ? (
<div className="pt-3">
<WorkflowAgentChat
transcriptDir={transcriptDir}
agentId={agent.agentId}
agentFinished={isAgentDone(agent)}
location={location}
fallback={<AgentDetailFallback agent={agent} />}
/>
</div>
) : (
<AgentDetailFallback agent={agent} />
)}
</div>
);
}

/**
* Pre-transcript rendering of an agent's prompt/outcome/chat from the manifest
* previews. Shown until the transcript-backed chat timeline has entries (or
* when the transcript location isn't available at all).
*/
function AgentDetailFallback({ agent }: { agent: WorkflowAgent }) {
return (
<>
{agent.promptPreview ? (
<section className="pt-3">
<h3 className="pb-1 text-[length:var(--lc-chat-font-size-meta)] font-medium text-foreground">
Expand Down Expand Up @@ -355,7 +396,7 @@ function AgentDetail({ agent, phaseTitle }: { agent: WorkflowAgent | null; phase
</ol>
</section>
) : null}
</div>
</>
);
}

Expand Down Expand Up @@ -432,32 +473,39 @@ function phasesFromInfo(info: WorkflowInfo): WorkflowPhase[] {
}

function applyWorkflowPlan(run: WorkflowRun, workflow: WorkflowInfo): WorkflowRun {
if (run.phases.length > 0 || workflow.plannedAgents.length === 0) return run;
// Statically planned agents (parsed from the script) win; otherwise fall
// back to live observations from the run's progress descriptions. Both are
// in agent-start order, matching the journal order of `unphasedAgents`.
const plan = workflow.plannedAgents.length > 0 ? workflow.plannedAgents : workflow.liveAgents;
if (run.phases.length > 0 || plan.length === 0) return run;

const phases = phasesFromInfo(workflow);
const phaseByTitle = new Map(phases.map((phase) => [phase.title, phase]));
const unphasedAgents: WorkflowAgent[] = [];
const agents = run.unphasedAgents.map((agent, index) => {
const planned = workflow.plannedAgents[index];
if (!planned) return agent;
for (const [index, agent] of run.unphasedAgents.entries()) {
const planned = plan[index];
if (!planned) {
unphasedAgents.push(agent);
continue;
}
const merged: WorkflowAgent = {
...agent,
label: planned.label,
...(planned.phaseTitle ? { phaseTitle: planned.phaseTitle } : {}),
// A synthesized in-flight agent is labeled with its raw id; only then is
// the positional pairing an improvement. A real label (from the manifest
// or transcript inference) is more trustworthy than order-based pairing.
label: agent.label === agent.agentId ? planned.label : agent.label,
...(planned.phaseTitle && !agent.phaseTitle ? { phaseTitle: planned.phaseTitle } : {}),
...(planned.model && !agent.model ? { model: planned.model } : {}),
};
if (planned.phaseTitle) {
const target = phaseByTitle.get(planned.phaseTitle);
const targetTitle = merged.phaseTitle;
if (targetTitle) {
const target = phaseByTitle.get(targetTitle);
if (target) {
target.agents.push(merged);
return null;
continue;
}
}
return merged;
});

for (const agent of agents) {
if (agent) unphasedAgents.push(agent);
unphasedAgents.push(merged);
}

return { ...run, phases, unphasedAgents };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,28 @@ Run ID: wf_5478fde3-ae0
Use /workflows to watch live progress.`;

describe("parseWorkflowInfo", () => {
it("prefers progress.description for the description", () => {
it("prefers the structured workflow payload over text parsing", () => {
const info = parseWorkflowInfo(
makePayload({
progress: { description: "verify:runtimeItems.ts:altitude" },
workflow: {
name: "simplify-review",
runId: "wf_struct",
summary: "Four-angle simplify review",
transcriptDir: "/home/x/.claude/projects/p/sess/subagents/workflows/wf_struct",
},
}),
);
// The stable workflow summary wins over the live agent label.
expect(info.description).toBe("Four-angle simplify review");
expect(info.runId).toBe("wf_struct");
expect(info.transcriptDir).toBe(
"/home/x/.claude/projects/p/sess/subagents/workflows/wf_struct",
);
expect(info.manifestPath).toBe("/home/x/.claude/projects/p/sess/workflows/wf_struct.json");
});

it("uses progress.description when no structured summary exists", () => {
const info = parseWorkflowInfo(
makePayload({
args: { script: SCRIPT },
Expand Down
Loading