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
32 changes: 32 additions & 0 deletions apps/web/src/components/AgentRosterRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";

/** Shared three-line agent presentation; each transport supplies its own supported actions. */
export function AgentRosterRow({
children,
activity,
metadata,
failed = false,
}: {
children: ReactNode;
activity: ReactNode;
metadata: ReactNode;
failed?: boolean;
}) {
return (
<div className="grid h-[3.875rem] grid-cols-[0.375rem_minmax(0,1fr)_auto_auto_1.75rem_1.75rem] grid-rows-[1.25rem_1.125rem_1rem] items-center gap-x-2 rounded-md px-1.5 py-1">
{children}
<span
className={cn(
"col-start-2 col-end-7 row-start-2 block truncate text-xs",
failed ? "text-destructive-foreground" : "text-muted-foreground",
)}
>
{activity}
</span>
<span className="col-start-2 col-end-7 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">
{metadata}
</span>
</div>
);
}
22 changes: 8 additions & 14 deletions apps/web/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AgentRosterRow } from "./AgentRosterRow";
import { DelegatedThreadList, type DelegatedThreadRows } from "./DelegatedThreadList";
/**
* Agents right-panel surface: the fleet view over the native subagent fold,
Expand Down Expand Up @@ -221,7 +222,11 @@ function AgentRow({
const liveActivityAvailable = liveActivityEligible && active;

return (
<div className="grid h-[3.875rem] grid-cols-[0.375rem_minmax(0,1fr)_auto_auto_1.75rem_1.75rem] grid-rows-[1.25rem_1.125rem_1rem] items-center gap-x-2 rounded-md px-1.5 py-1">
<AgentRosterRow
activity={activity ? `${statusLabel} · ${activity}` : statusLabel}
metadata={metadata.join(" · ")}
failed={agent.status === "failed"}
>
<span className="col-start-1 row-start-1 flex items-center">
<StatusDot status={agent.status} />
</span>
Expand Down Expand Up @@ -287,18 +292,7 @@ function AgentRow({
</button>
) : null}
</span>
<span
className={cn(
"col-start-2 col-end-7 row-start-2 block truncate text-xs",
agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
)}
>
{activity ? `${statusLabel} · ${activity}` : statusLabel}
</span>
<span className="col-start-2 col-end-7 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">
{metadata.join(" · ")}
</span>
</div>
</AgentRosterRow>
);
}

Expand Down Expand Up @@ -869,7 +863,7 @@ export function AgentsPanel({
<div className="flex flex-col gap-2 p-2">
{delegatedThreads.length > 0 ? (
<section>
<div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">
<div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
Pylon threads
</div>
<DelegatedThreadList rows={delegatedThreads} />
Expand Down
20 changes: 4 additions & 16 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { delegatedThreadRows } from "@t3tools/client-runtime/state/delegated-threads";
import { DelegationSummary } from "./DelegatedThreadList";
import { delegationActivity } from "../delegationActivity";
import { prepareRevertedMessageContext } from "../lib/composerRewindContext";
import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
import { resolveLoadBalancingStatus } from "@t3tools/client-runtime/load-balancing";
Expand Down Expand Up @@ -3005,15 +3003,11 @@ export default function ChatView(props: ChatViewProps) {
const delegatedLiveCount = delegatedThreads.filter((row) =>
["starting", "running", "needs-approval", "needs-input"].includes(row.status),
).length;
const delegatedAttentionCount = delegatedThreads.filter(
(row) => row.status === "needs-approval" || row.status === "needs-input",
).length;
const delegationWaitingTurnId =
activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null;
const waitingForDelegation =
delegationWaitingTurnId !== null &&
workLogEntries.some(
(entry) =>
entry.turnId === delegationWaitingTurnId && delegationActivity(entry)?.waiting === true,
);

const agentPanelModel = useMemo(
() => deriveAgentPanelModel({ agents: runtimeSubagents }),
[runtimeSubagents],
Expand Down Expand Up @@ -9837,6 +9831,7 @@ export default function ChatView(props: ChatViewProps) {
? 0
: agentPanelModel.liveCount + delegatedLiveCount
}
attentionAgentCount={delegatedAttentionCount}
onToggleTerminal={toggleTerminalVisibility}
onToggleRightPanel={toggleRightPanel}
/>
Expand Down Expand Up @@ -10186,13 +10181,6 @@ export default function ChatView(props: ChatViewProps) {
</div>
{/* Messages Wrapper */}
<div className="relative flex min-h-0 flex-1 flex-col bg-background">
{!paintOnlyDisplayedTimeline ? (
<DelegationSummary
rows={delegatedThreads}
waiting={waitingForDelegation}
onOpenAgents={addAgentsSurface}
/>
) : null}
{/* Messages — LegendList handles virtualization and scrolling internally */}
<MessagesTimeline
citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest}
Expand Down
24 changes: 1 addition & 23 deletions apps/web/src/components/DelegatedThreadList.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";
import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import {
DelegationSummary,
DelegatedThreadList,
type DelegatedThreadRows,
} from "./DelegatedThreadList";
import { DelegatedThreadList } from "./DelegatedThreadList";
vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
Expand All @@ -24,25 +20,7 @@ const child = {
status: "running" as const,
activity: "Checking session expiry",
};
const render = (rows: DelegatedThreadRows, waiting = false) =>
renderToStaticMarkup(<DelegationSummary rows={rows} waiting={waiting} onOpenAgents={() => {}} />);
describe("Pylon child visibility", () => {
it("keeps independent child work visible without claiming the parent waits", () => {
const html = render([child]);
expect(html).toContain("1 delegated agent active");
expect(html).not.toContain("Waiting for");
expect(html).toContain("Review authentication");
expect(html).toContain("Checking session expiry");
expect(html).toContain("/env/delegated:parent:0123456789abcdef");
expect(html).not.toContain("Cancel");
});
it("prioritizes blockers over waiting and clears waiting for terminal children", () => {
expect(render([child], true)).toContain("Waiting for delegated agent");
expect(render([{ ...child, status: "needs-input" }], true)).toContain("needs attention");
expect(render([{ ...child, status: "completed" }], true)).not.toContain("Waiting for");
expect(render([{ ...child, status: "error" }])).not.toContain("results available");
expect(render([])).toBe("");
});
it("labels completion as a review action rather than verified success", () => {
const html = renderToStaticMarkup(
<DelegatedThreadList rows={[{ ...child, status: "completed" }]} />,
Expand Down
106 changes: 38 additions & 68 deletions apps/web/src/components/DelegatedThreadList.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { delegatedThreadRows } from "@t3tools/client-runtime/state/delegated-threads";
import { Link } from "@tanstack/react-router";
import { AgentRosterRow } from "./AgentRosterRow";
import { cn } from "~/lib/utils";

export type DelegatedThreadRows = ReturnType<typeof delegatedThreadRows>;

const labels = {
starting: "Starting",
running: "Running",
running: "Working",
"needs-approval": "Needs approval",
"needs-input": "Needs input",
completed: "Completed · review result",
Expand All @@ -14,72 +16,40 @@ const labels = {
archived: "Archived",
};

/** Shared Pylon roster; controls always address the child thread, never native agent IDs. */
/** Same roster presentation as native agents, with actions addressed to the child thread. */
export function DelegatedThreadList({ rows }: { rows: DelegatedThreadRows }) {
return (
<div className="grid gap-1">
{rows.map((row) => (
<div key={row.threadId} className="rounded-md border border-border/60 px-2.5 py-2 text-xs">
<div className="flex items-center gap-2">
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${row.status === "error" ? "bg-destructive" : row.status === "completed" ? "bg-success" : "bg-info"}`}
/>
<span className="min-w-0 flex-1 truncate font-medium">{row.title}</span>
<Link
to="/$environmentId/$threadId"
params={{ environmentId: row.environmentId, threadId: row.threadId }}
className="shrink-0 underline underline-offset-2"
>
Open thread
</Link>
</div>
<div className="mt-1 truncate text-muted-foreground">
{row.providerName ?? row.modelSelection.instanceId} · {row.modelSelection.model}
</div>
<div className="mt-1">{labels[row.status]}</div>
{row.activity ? (
<div className="mt-1 truncate text-muted-foreground">{row.activity}</div>
) : null}
</div>
))}
</div>
);
}

export function DelegationSummary({
rows,
waiting,
onOpenAgents,
}: {
rows: DelegatedThreadRows;
waiting: boolean;
onOpenAgents: () => void;
}) {
if (rows.length === 0) return null;
const active = rows.filter((row) =>
["starting", "running", "needs-approval", "needs-input"].includes(row.status),
);
const blocked = active.filter(
(row) => row.status === "needs-approval" || row.status === "needs-input",
).length;
return (
<details className="mx-3 my-1 rounded-md border border-border/60 bg-card/50 px-2.5 py-2 text-xs sm:mx-5">
<summary className="cursor-pointer font-medium">
{blocked > 0
? `${blocked} delegated ${blocked === 1 ? "agent needs" : "agents need"} attention`
: waiting && active.length > 0
? "Waiting for delegated agent"
: active.length > 0
? `${active.length} delegated ${active.length === 1 ? "agent active" : "agents active"}`
: `Pylon delegation · ${rows.length} child ${rows.length === 1 ? "thread" : "threads"}`}
</summary>
<div className="mt-2 max-h-64 overflow-y-auto">
<DelegatedThreadList rows={rows} />
</div>
<button type="button" className="mt-2 underline underline-offset-2" onClick={onOpenAgents}>
Open Agents
</button>
</details>
);
return rows.map((row) => (
<AgentRosterRow
key={row.threadId}
activity={row.activity ? `${labels[row.status]} · ${row.activity}` : labels[row.status]}
metadata={`${row.providerName ?? row.modelSelection.instanceId} · ${row.modelSelection.model}`}
failed={row.status === "error"}
>
<span className="col-start-1 row-start-1 flex items-center">
<span
aria-hidden
className={cn(
"size-1.5 shrink-0 rounded-full",
row.status === "error"
? "bg-destructive"
: row.status === "completed"
? "bg-success"
: row.status === "archived" || row.status === "interrupted"
? "bg-muted-foreground/60"
: "bg-info",
)}
/>
</span>
<span className="col-start-2 row-start-1 min-w-0 truncate text-sm font-medium">
{row.title}
</span>
<Link
to="/$environmentId/$threadId"
params={{ environmentId: row.environmentId, threadId: row.threadId }}
className="col-start-3 col-end-7 row-start-1 justify-self-end rounded-sm px-1.5 py-1 text-[.65rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
Open thread
</Link>
</AgentRosterRow>
));
}
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { delegationActivity } from "../../delegationActivity";
import * as Equal from "effect/Equal";
import { shallow } from "zustand/vanilla/shallow";
import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
Expand Down Expand Up @@ -680,7 +681,9 @@ function deriveTurnFolds(input: {
// when the turn settles makes a still-running fleet invisible.
if (
entry.kind === "work" &&
(entry.entry.agentSpawn !== undefined || workLogEntryIsMissingResponse(entry.entry))
(entry.entry.agentSpawn !== undefined ||
delegationActivity(entry.entry)?.name === "delegate_thread" ||
workLogEntryIsMissingResponse(entry.entry))
) {
continue;
}
Expand Down Expand Up @@ -910,6 +913,7 @@ export function deriveMessagesTimelineRows(input: {
!entryBelongsToActiveTurn(entry, index) ||
entry.kind !== "work" ||
entry.entry.agentSpawn !== undefined ||
delegationActivity(entry.entry)?.name === "delegate_thread" ||
entry.entry.sourceActivityKind === "context-compaction" ||
entry.entry.tone === "error"
) {
Expand Down Expand Up @@ -1034,7 +1038,11 @@ export function deriveMessagesTimelineRows(input: {
}

if (timelineEntry.kind === "work") {
if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
if (
timelineEntry.entry.agentSpawn !== undefined ||
delegationActivity(timelineEntry.entry)?.name === "delegate_thread" ||
timelineEntry.entry.tone === "error"
) {
nextRows.push({
kind: "work",
id: timelineEntry.id,
Expand All @@ -1054,6 +1062,7 @@ export function deriveMessagesTimelineRows(input: {
nextEntry.kind !== "work" ||
workLogEntryIsMissingResponse(nextEntry.entry) ||
nextEntry.entry.agentSpawn !== undefined ||
delegationActivity(nextEntry.entry)?.name === "delegate_thread" ||
nextEntry.entry.sourceActivityKind === "context-compaction" ||
nextEntry.entry.tone === "error" ||
activeWorkEntryIds.has(nextEntry.id) ||
Expand Down
Loading
Loading