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 @@ -310,6 +310,11 @@ it("shows a remote subagent's full title in its tooltip even when its environmen
{ router: "/environment/fork" },
);

// A finished remote task sits with the other finished agents, not in its own list.
await expect
.element(screen.getByRole("button", { name: new RegExp(longTitle) }))
.not.toBeInTheDocument();
await screen.getByRole("button", { name: /Previous agents/ }).click();
const row = screen.getByRole("button", { name: new RegExp(longTitle) });
await userEvent.hover(row);
await vi.waitFor(() =>
Expand Down
98 changes: 60 additions & 38 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,32 @@ function ThreadLineageRowList(props: {
);
}

function ThreadLineageGroup(props: {
type RemoteSubagent = OrchestrationV2Subagent & {
readonly remoteChild: OrchestrationV2RemoteTaskChild;
};

/** A task delegated to a linked environment has no thread here, so it gets its own row kind. */
type LineageRow = ThreadRelationshipWalkRow | { readonly remote: RemoteSubagent };

const lineageRowStatus = (row: LineageRow) =>
"remote" in row ? row.remote.status : row.edge.status;

const isFinishedAgentStatus = (status: string | null | undefined) =>
["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(status ?? "");

function ThreadLineageGroup<Row extends LineageRow>(props: {
readonly label: string | null;
readonly rows: ReadonlyArray<ThreadRelationshipWalkRow>;
readonly rows: ReadonlyArray<Row>;
readonly expanded: boolean;
readonly children: (rows: ReadonlyArray<ThreadRelationshipWalkRow>) => ReactNode;
readonly children: (rows: ReadonlyArray<Row>) => ReactNode;
}) {
const [expanded, setExpanded] = useState(props.expanded);
const [visibleCount, setVisibleCount] = useState(THREAD_LINEAGE_INITIAL_COUNT);
const { visibleRows, hiddenCount } = resolveThreadLineageWindow(props.rows, visibleCount);
const failedCount = props.rows.filter(
({ edge }) => edge.status === "failed" || edge.status === "error",
).length;
const failedCount = props.rows.filter((row) => {
const status = lineageRowStatus(row);
return status === "failed" || status === "error";
}).length;
if (props.rows.length === 0) return null;
return (
<div>
Expand Down Expand Up @@ -247,9 +261,7 @@ function currentSubagent<Agent extends RuntimeSubagent>(
* the thread there, when this client is connected to that environment.
*/
function RemoteSubagentRow(props: {
readonly agent: OrchestrationV2Subagent & {
readonly remoteChild: OrchestrationV2RemoteTaskChild;
};
readonly agent: RemoteSubagent;
readonly provider: ServerProvider | undefined;
}) {
const navigate = useNavigate();
Expand Down Expand Up @@ -537,22 +549,29 @@ export function ThreadRelationshipsPanel(props: {
if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true)
return "sideChats";
if (edge.kind !== "subagent" || isParent) return "related";
return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes(
edge.status ?? "",
)
? "previous"
: "active";
return isFinishedAgentStatus(edge.status) ? "previous" : "active";
});
const groups = [
const { remoteActive = [], remotePrevious = [] } = groupBy(
(projection?.subagents ?? [])
.filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined)
.map((remote) => ({ remote })),
({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"),
);
const groups: ReadonlyArray<{
id: string;
label: string | null;
rows: ReadonlyArray<LineageRow>;
expanded: boolean;
}> = [
{ id: "related", label: null, rows: related, expanded: true },
{ id: "active", label: null, rows: active, expanded: true },
{ id: "previous", label: "Previous agents", rows: previous, expanded: false },
{ id: "active", label: null, rows: [...active, ...remoteActive], expanded: true },
{
id: "previous",
label: "Previous agents",
rows: [...previous, ...remotePrevious],
expanded: false,
},
];
// Tasks running in a linked environment have no thread here; they get their own rows.
const remoteSubagents = (projection?.subagents ?? []).filter(
(agent): agent is OrchestrationV2Subagent & { remoteChild: OrchestrationV2RemoteTaskChild } =>
agent.remoteChild !== undefined,
);
const runningCount =
(projection?.subagents.filter(
(agent) => agent.childThreadId === null && agent.status === "running",
Expand All @@ -567,7 +586,8 @@ export function ThreadRelationshipsPanel(props: {
active.length > 0 ||
previous.length > 0 ||
runningCount > 0 ||
remoteSubagents.length > 0 ||
remoteActive.length > 0 ||
remotePrevious.length > 0 ||
delegatedFrom !== undefined ||
// Disconnect lives in this heading, so a thread with only side chats still shows it.
(canDetach && sideChats.length > 0));
Expand Down Expand Up @@ -929,6 +949,7 @@ export function ThreadRelationshipsPanel(props: {
canDetach ||
related.length > 0 ||
active.length > 0 ||
remoteActive.length > 0 ||
delegatedFrom !== undefined
}
data-thread-relationships-panel
Expand Down Expand Up @@ -968,23 +989,24 @@ export function ThreadRelationshipsPanel(props: {
)}
{groups.map((group) => (
<ThreadLineageGroup key={`${scopedThreadKey(ref)}:${group.id}`} {...group}>
{(visibleRows) => visibleRows.map(renderRelationshipRow)}
{(visibleRows) =>
visibleRows.map((row) =>
"remote" in row ? (
<RemoteSubagentRow
key={row.remote.id}
agent={row.remote}
// Its provider instance belongs to the environment it runs in.
provider={providersByEnvironment
.get(row.remote.remoteChild.environmentId)
?.find((entry) => entry.instanceId === row.remote.providerInstanceId)}
/>
) : (
renderRelationshipRow(row)
),
)
}
</ThreadLineageGroup>
))}
{remoteSubagents.length > 0 ? (
<ThreadLineageRowList hiddenCount={0} onShowMore={() => {}}>
{remoteSubagents.map((agent) => (
<RemoteSubagentRow
key={agent.id}
agent={agent}
// Its provider instance belongs to the environment it runs in.
provider={providersByEnvironment
.get(agent.remoteChild.environmentId)
?.find((entry) => entry.instanceId === agent.providerInstanceId)}
/>
))}
</ThreadLineageRowList>
) : null}
</ThreadDetailsSection>
) : null}
{sideChatsVisible ? (
Expand Down
Loading