Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4f4b93d
Add the agents panel: the turn's subagents and runs as a thread rail
badcuban Aug 11, 2026
87e3851
Fix agents panel lockouts: source control can reclaim the slot, close…
badcuban Aug 11, 2026
4cf2a83
Make the right panel one tabbed rail with a single header entry point
badcuban Aug 11, 2026
01809e6
Replace inline subagent cards with compact one-line receipts
badcuban Aug 11, 2026
621866f
Record both rail tabs closed when the rail is dismissed
badcuban Aug 11, 2026
c84f40b
Rebuild the right sidebar as a tabbed panel with a launcher
badcuban Aug 11, 2026
bca1288
Fix the sidebar tab store's URL-lag race and update the browser tests
badcuban Aug 11, 2026
ede2d14
Hand a draft's Diff tab to the thread route and drop a dead import
badcuban Aug 11, 2026
6e90e93
Give the Agents tab the thread's whole agent history
badcuban Aug 11, 2026
86f30a5
Cover the agent history, de-noising, live status line and tool-run fo…
badcuban Aug 11, 2026
0e32192
Fix the agent panel's Codex meta, chat de-noising, toast overlap and …
badcuban Aug 11, 2026
b740e02
Keep the turn's agent tracker on a turn that only delegated
badcuban Aug 11, 2026
d534b2d
Read a turn's dispatched model off the unscoped preparing activity
badcuban Aug 12, 2026
e78655c
Draw a settled turn's agent tracker from the durable agent history
badcuban Aug 12, 2026
899de94
Show a turn's agent tracker on its first activity group only
badcuban Aug 12, 2026
eadb76c
Size the panel tabs like the browser's and split the Windows titlebar…
badcuban Aug 12, 2026
ebe3633
Keep the panel tabs in the titlebar row and cut the agent rows to thr…
badcuban Aug 12, 2026
b2a6457
Give the thread panel one gutter, flat records and a scrolling tab strip
badcuban Aug 12, 2026
50454e3
Finish mirroring the browser strip in the thread panel's tabs
badcuban Aug 12, 2026
59fd5e6
Tighten the panel strip's window-controls clearance to the cluster it…
badcuban Aug 12, 2026
5a0867a
Fade only the panel tab strip's right edge
badcuban Aug 12, 2026
f9c0b23
Give the panel tab's close control the glyph's slot, and name the mod…
badcuban Aug 12, 2026
ac71c85
Time a forked Codex child's replayed first turn from the thread's start
badcuban Aug 12, 2026
e9a9fc2
Align the transcript's dots with its text, and make every tool run a …
badcuban Aug 12, 2026
fb5d5d9
Make the tab strip's scrollbar opaque
badcuban Aug 12, 2026
aa55a56
Start a forked Codex child's transcript at its own first turn
badcuban Aug 12, 2026
ba1304e
Give the transcript the chat's own clickable file, and its real instr…
badcuban Aug 12, 2026
a96f0ef
Render every backticked file reference as the chat's file chip
badcuban Aug 12, 2026
a70e1af
Drop every tab label rather than scroll when the strip runs out of room
badcuban Aug 12, 2026
ec3da3d
Give the tab's close control room against the tab's edge
badcuban Aug 12, 2026
6abecc1
Say what each launcher surface actually holds, and dim the empty ones
badcuban Aug 12, 2026
7c9b71f
Keep Source lit, and let the Diff button say why it is unavailable
badcuban Aug 12, 2026
fb33a63
Say what the agents panel is showing in its own header row
badcuban Aug 12, 2026
499513d
Cut a forked Codex child's transcript at the clock, not at the timest…
badcuban Aug 12, 2026
a57b6d7
Merge remote-tracking branch 'origin/main' into feat/agents-panel
badcuban Aug 12, 2026
55568d8
Let an open menu take the click a toast was covering
badcuban Aug 12, 2026
48f5988
Dim empty panel surfaces without disabling their launcher entries
badcuban Aug 12, 2026
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
164 changes: 164 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,170 @@ describe("CodexAdapter item mapping", () => {
});
});

it("times a forked child's replayed first turn from the thread's own start", () => {
// A forked child's history arrives as a synthetic turn -- Codex ids it
// "rollout-N" -- with no startedAt, completedAt or durationMs, while every
// real turn after it is stamped. Codex times a turn rather than an item, so
// without a fallback the agent's opening words are the only untimed entries
// in the transcript, which reads as the timestamps sitting a row too low.
const thread = {
id: "forked-child",
createdAt: 1_786_487_612,
turns: [
{
id: "rollout-2",
status: "completed",
startedAt: null,
completedAt: null,
items: [{ id: "assistant-1", type: "agentMessage", text: "I'll delegate both counts." }],
},
{
id: "019ff2f5-a952-7c10-9fda-66f5498a6d49",
status: "completed",
startedAt: 1_786_487_613,
items: [{ id: "assistant-2", type: "agentMessage", text: "50 .tsx files." }],
},
],
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];

assert.deepStrictEqual(
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.at),
["2026-08-11T22:33:32.000Z", "2026-08-11T22:33:33.000Z"],
);
});

it("drops the parent history a forked child replays before its own first turn", () => {
// Codex spawns a subagent by forking, so the child's stored history opens
// with the parent's conversation replayed as an untimed turn. Rendering it
// labelled the operator's own message to the main thread as the instruction
// this agent was given, and attributed the main thread's reply to the child.
const thread = {
id: "forked-child",
forkedFromId: "parent-thread",
parentThreadId: "parent-thread",
createdAt: 1_786_487_612,
turns: [
{
id: "rollout-2",
status: "completed",
startedAt: null,
completedAt: null,
items: [
{ id: "user-1", type: "userMessage", text: "can you start up subagents again" },
{ id: "assistant-1", type: "agentMessage", text: "Sure, starting two agents." },
],
},
{
id: "019ff2f5-a952-7c10-9fda-66f5498a6d49",
status: "completed",
startedAt: 1_786_487_613,
items: [{ id: "assistant-2", type: "agentMessage", text: "50 .tsx files." }],
},
],
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];

assert.deepStrictEqual(
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
["50 .tsx files."],
);
});

it("drops the parent turn that was live at the fork, which carries a real time", () => {
// The second shape of the same inheritance, and the one a "no timestamps"
// rule slid straight past: the turn the parent was in the middle of when it
// spawned this child comes across with an ordinary id and a startedAt from
// before the child existed. Measured from a real fork: the parent's live
// turn started 11s before creation, the child's own work 1s after it.
const createdAt = 1_786_558_783;
const thread = {
id: "forked-child-live-parent-turn",
forkedFromId: "parent-thread",
parentThreadId: "parent-thread",
createdAt,
turns: [
{
id: "rollout-2",
status: "completed",
startedAt: null,
items: [{ id: "assistant-1", type: "agentMessage", text: "Older replayed history." }],
},
{
id: "019ff733-7569-7ad2-9171-d6f19574734f",
status: "interrupted",
startedAt: createdAt - 11,
items: [
{ id: "user-1", type: "userMessage", text: "can you run just 1 subagent this time" },
{
id: "assistant-2",
type: "agentMessage",
text: "I can run exactly one subagent now.",
},
],
},
{
id: "019ff733-a3ec-7060-912e-f858d78b9ef9",
status: "completed",
startedAt: createdAt + 1,
items: [{ id: "assistant-3", type: "agentMessage", text: "Read-only scan complete." }],
},
],
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];

assert.deepStrictEqual(
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
["Read-only scan complete."],
);
});

it("keeps a child's own first turn that starts in the second it was created", () => {
const createdAt = 1_786_558_783;
const thread = {
id: "forked-child-instant-start",
forkedFromId: "parent-thread",
createdAt,
turns: [
{
id: "019ff733-7569-7ad2-9171-d6f19574734f",
status: "completed",
startedAt: createdAt - 4,
items: [{ id: "assistant-1", type: "agentMessage", text: "Parent's own words." }],
},
{
id: "019ff733-a3ec-7060-912e-f858d78b9ef9",
status: "completed",
startedAt: createdAt,
items: [{ id: "assistant-2", type: "agentMessage", text: "Off to work." }],
},
],
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];

assert.deepStrictEqual(
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
["Off to work."],
);
});

it("keeps every turn when a forked child has no timed turn to start from", () => {
const thread = {
id: "forked-child-untimed",
forkedFromId: "parent-thread",
createdAt: 1_786_487_612,
turns: [
{
id: "rollout-2",
status: "completed",
startedAt: null,
items: [{ id: "assistant-1", type: "agentMessage", text: "Only content there is." }],
},
],
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];

assert.deepStrictEqual(
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
["Only content there is."],
);
});

it("uses source ancestry metadata and honors transcript limits", () => {
const thread = {
id: "grandchild-thread",
Expand Down
58 changes: 47 additions & 11 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,19 +406,55 @@ export function mapCodexSubagentTranscript(
readonly fromEnd?: boolean;
},
): ProviderSubagentTranscriptResult {
const entries = thread.turns.flatMap((turn) =>
// Codex stamps a time per turn rather than per item, and a forked child's
// first turn is a synthetic replay of the history it inherited (`turn.id` is
// literally "rollout-2"), which carries no times at all. Its entries are the
// agent's opening words, so leaving them blank reads as the transcript's
// timestamps being shifted a row. The thread's own creation time is the
// honest stand-in: it is a required field, and it lands a second or two
// before the replayed turn's real records.
const threadStartedAt = Number.isFinite(thread.createdAt)
? isoFromEpochSeconds(thread.createdAt)
: undefined;
// Codex spawns a subagent by forking its parent, so the child inherits the
// parent's conversation and replays it at the head of its own history. None
// of it is the child's work: the replayed user message is what the operator
// typed to the main thread, which the panel then labelled as the instruction
// this agent was given, and the replayed assistant message is the main
// thread's own reply attributed to the child.
//
// The inheritance arrives in two shapes, and only one of them is timeless: an
// older block Codex ids `rollout-N` with no times at all, and the parent turn
// that was live at the moment of the fork, which carries an ordinary id and a
// real `startedAt` -- from *before* this thread existed. What separates them
// from the child's own work is therefore not the ids or the missing times but
// the clock: a turn that started before the child was created cannot be the
// child's. Measured against two real forks, own work begins one and two
// seconds after `createdAt`, so the comparison is strict rather than fuzzy.
const forkedThreadCreatedAt = Number.isFinite(thread.createdAt) ? thread.createdAt : null;
const firstOwnTurnIndex =
thread.forkedFromId && forkedThreadCreatedAt !== null
? thread.turns.findIndex(
(turn) =>
turn.startedAt !== undefined &&
turn.startedAt !== null &&
turn.startedAt >= forkedThreadCreatedAt,
)
: 0;
// No turn of the child's own leaves nothing to tell the inheritance from, so
// the thread keeps every turn rather than losing its only content.
const ownTurns = firstOwnTurnIndex > 0 ? thread.turns.slice(firstOwnTurnIndex) : thread.turns;
const entries = ownTurns.flatMap((turn) =>
turn.items.flatMap((item) => {
const entry = mapCodexStoredItem(item);
return entry === undefined
? []
: [
{
...entry,
...(turn.startedAt !== undefined && turn.startedAt !== null
? { at: isoFromEpochSeconds(turn.startedAt) }
: {}),
},
];
if (entry === undefined) {
return [];
}
const at =
turn.startedAt !== undefined && turn.startedAt !== null
? isoFromEpochSeconds(turn.startedAt)
: threadStartedAt;
return [{ ...entry, ...(at === undefined ? {} : { at }) }];
}),
);
const limit =
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/agentsPanelStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Bridge between the chat view, which knows what the current turn is doing,
* and the route, which owns the right-panel slot the agents panel renders in.
*
* The panel needs live subagent progress, background runs and the terminal
* toggle — all of which are chat-view state — but it mounts as a sibling of
* the chat column, next to source control. ChatView publishes here; the route
* reads. Same shape as the file viewer's store, for the same reason.
*/
import { create } from "zustand";

import type { EnvironmentId, ThreadId } from "@threadlines/contracts";

import type { SubagentProgressItem, ThreadSubagentHistoryEntry } from "./session-logic";
import type { ThreadBackgroundRunItem } from "./components/chat/threadActivity";

export interface AgentsPanelSource {
environmentId: EnvironmentId;
threadId: ThreadId;
subagents: ReadonlyArray<SubagentProgressItem>;
backgroundRuns: ReadonlyArray<ThreadBackgroundRunItem>;
/** Every agent the thread has run, live or long finished. Published alongside
* the live items so the panel and the conversation's receipts resolve the
* same set of agents. */
history: ReadonlyArray<ThreadSubagentHistoryEntry>;
/** Provider driver label, e.g. `codex`; drives the trunk hue and run chips. */
providerLabel: string | null;
/** True from the moment a turn is dispatched until it settles. Lets the panel
* say it is waiting rather than claim the thread has never run an agent
* while the provider handoff is still in flight. */
turnInFlight: boolean;
threadCwd: string | null;
onToggleBackgroundRunTerminal: (terminalId: string) => void;
onStopBackgroundRun: (run: ThreadBackgroundRunItem) => void;
}

interface AgentsPanelStoreState {
source: AgentsPanelSource | null;
/**
* The agent whose transcript the panel is drilled into. It lives here rather
* than inside the panel because the drill-in is reachable from outside it:
* a finished agent's receipt in the conversation opens the rail already
* pointed at that agent.
*/
selectedAgentId: string | null;
publishSource: (source: AgentsPanelSource | null) => void;
selectAgent: (agentId: string | null) => void;
}

export const useAgentsPanelStore = create<AgentsPanelStoreState>((set) => ({
source: null,
selectedAgentId: null,
publishSource: (source) => {
set((state) => ({
source,
// A drill-in belongs to the thread it was opened from; leaving the
// thread drops it rather than pointing the panel at a stale agent.
selectedAgentId: state.source?.threadId === source?.threadId ? state.selectedAgentId : null,
}));
},
selectAgent: (agentId) => {
set({ selectedAgentId: agentId });
},
}));

export function useAgentsPanelSource(): AgentsPanelSource | null {
return useAgentsPanelStore((state) => state.source);
}

export function useSelectedAgentId(): string | null {
return useAgentsPanelStore((state) => state.selectedAgentId);
}

export function publishAgentsPanelSource(source: AgentsPanelSource | null): void {
useAgentsPanelStore.getState().publishSource(source);
}

export function selectAgentsPanelAgent(agentId: string | null): void {
useAgentsPanelStore.getState().selectAgent(agentId);
}

export function resetAgentsPanelSourceForTests(): void {
useAgentsPanelStore.setState({ source: null, selectedAgentId: null });
}
Loading
Loading