diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx deleted file mode 100644 index cb3fc71855d3..000000000000 --- a/apps/web/src/components/AgentsPanel.tsx +++ /dev/null @@ -1,584 +0,0 @@ -/** - * Agents right-panel surface: the fleet view over the native subagent fold. - * The chat carries one expandable row per spawn batch and links here. - * - * Visualization rules (from live-test feedback): - * - Spawn order is stable. Activity and completion update rows in place. - * - Agent rows reserve three fixed lines for identity, activity, and metrics; - * changing data must never change their height. - * - Workflow expansion is presentation state. A live run stays expanded when - * it settles; older collapsed runs can still be opened at run granularity. - * - Static status dots, DOM-write elapsed timers, plain token counters. - */ -import { useAtomValue } from "@effect/atom-react"; -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; -import type { - AgentPanelModel, - AgentPanelWorkflowGroup, - RuntimeSubagent, -} from "@t3tools/client-runtime/state/subagentRuntime"; -import { - formatSubagentModelLabel, - formatSubagentTokenCount, -} from "@t3tools/client-runtime/state/subagentRuntime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - -import { cn } from "~/lib/utils"; -import { orchestrationEnvironment } from "~/state/orchestration"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { Button } from "~/components/ui/button"; - -/** - * In-flight states all present as Working (one steady state, per the - * monitoring-pill design: detail belongs in the activity sub-line, and a - * stalled/waiting/queued subagent is still the fleet doing its job, not a - * user problem). Only settled states differentiate. - */ -const STATUS_VISUALS: Record = { - pending: { dotClass: "bg-info", label: "Working" }, - running: { dotClass: "bg-info", label: "Working" }, - waiting: { dotClass: "bg-info", label: "Working" }, - // Idle reads as settled (muted, not sky): a resting Codex child looks done - // unless resumed — live-test: sky idle dots read as stuck in-progress. - idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" }, - completed: { dotClass: "bg-success", label: "Completed" }, - failed: { dotClass: "bg-destructive", label: "Failed" }, - cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, - interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" }, -}; - -function StatusDot({ status }: { status: RuntimeSubagent["status"] }) { - return ( - - ); -} - -function formatElapsedSeconds(totalSeconds: number): string { - const seconds = Math.max(0, Math.floor(totalSeconds)); - const minutes = Math.floor(seconds / 60); - if (minutes === 0) { - return `${seconds}s`; - } - const hours = Math.floor(minutes / 60); - if (hours === 0) { - return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; - } - return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; -} - -function elapsedBetween(startedAt: string, endIso: string | null): string { - const start = Date.parse(startedAt); - const end = endIso ? Date.parse(endIso) : Date.now(); - if (Number.isNaN(start) || Number.isNaN(end)) { - return ""; - } - return formatElapsedSeconds((end - start) / 1000); -} - -/** - * Elapsed time for the current activation. Live agents self-tick via DOM - * writes (zero React commits per tick); settled agents freeze at completedAt. - */ -export function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { - const textRef = useRef(null); - const live = agent.status === "running" || agent.status === "waiting"; - const startedAt = agent.startedAt; - - useEffect(() => { - if (!live || !startedAt) { - return; - } - const update = () => { - if (textRef.current) { - textRef.current.textContent = elapsedBetween(startedAt, null); - } - }; - update(); - const id = setInterval(update, 1000); - return () => clearInterval(id); - }, [live, startedAt]); - - if (!startedAt) { - return null; - } - return ( - - {elapsedBetween(startedAt, live ? null : agent.completedAt)} - - ); -} - -/** - * Status-dependent activity line. Live rows lead with what is happening now; - * settled rows lead with the outcome. Errors are the only inline previews on - * failed rows because they explain a red row at a glance. - */ -function agentActivityText(agent: RuntimeSubagent): string | null { - const live = - agent.status === "running" || agent.status === "pending" || agent.status === "waiting"; - if (live) { - return ( - agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ?? - agent.result ?? - agent.error - ); - } - return ( - agent.error ?? - agent.result ?? - agent.progress ?? - (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) - ); -} - -/** Flat, non-interactive agent status line. No unfold. */ -function AgentRow({ agent }: { agent: RuntimeSubagent }) { - const title = formatSubagentDisplayTitle(agent.title); - const visuals = STATUS_VISUALS[agent.status]; - const statusLabel = - agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label; - const activity = agentActivityText(agent); - const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); - const role = - agent.role?.trim().toLocaleLowerCase() === title.trim().toLocaleLowerCase() ? null : agent.role; - const metadata = [ - modelLabel, - agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok", - agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null, - agent.activationCount > 1 ? `run ${agent.activationCount}` : null, - ].filter((value): value is string => value !== null); - - return ( -
- - - - - {title} - {role ? ( - - {role} - - ) : null} - - - - - {agent.status === "completed" ? ( - - ) : null} - - - - {activity ?? statusLabel} - - - {metadata.join(" · ")} - - {statusLabel} -
- ); -} - -function workflowIsLive(group: AgentPanelWorkflowGroup): boolean { - const status = group.workflow.status; - return ( - status !== "completed" && - status !== "failed" && - status !== "cancelled" && - status !== "interrupted" - ); -} - -function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray { - return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; -} - -/** - * Phase rail: the run's shape at a glance. One segment per phase in order, - * separated by chevrons; each segment shows title + one dot per member. - * The whole arc (done → live → pending) is visible without scrolling the - * member list. - */ -function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { - if (group.phases.length === 0) { - return null; - } - return ( -
- {group.phases.map((phase, index) => ( -
- {index > 0 ? ( - - ) : null} -
- - {phase.state === "done" ? "✓ " : ""} - {phase.title} - - - {phase.members.length === 0 ? ( - – - ) : ( - phase.members.map((member) => ) - )} - -
-
- ))} -
- ); -} - -/** - * Read-only workflow script viewer, fetched through the contained - * getWorkflowScript RPC (never a raw filesystem read from the client). - */ -function WorkflowScriptView({ - environmentId, - threadId, - scriptPath, - onClose, -}: { - environmentId: EnvironmentId; - threadId: ThreadId; - scriptPath: string; - onClose: () => void; -}) { - const result = useAtomValue( - orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }), - ); - return ( -
-
- - - {scriptPath.split("/").at(-1)} - - -
-
- {result._tag === "Success" ? ( -
-            {result.value.contents}
-            {result.value.truncated ? "\n… (truncated)" : ""}
-          
- ) : result._tag === "Failure" ? ( -

Could not load the script.

- ) : ( -

Loading…

- )} -
-
- ); -} - -/** - * Collapsible phase section. A phase opens when it becomes active, then keeps - * that shape as it settles so completion never yanks rows out from under the - * user. Manual toggles stick until a later activation begins. - */ -function PhaseSection({ - phase, - defaultOpen = false, -}: { - phase: AgentPanelWorkflowGroup["phases"][number]; - defaultOpen?: boolean; -}) { - const [open, setOpen] = useState(defaultOpen || phase.state === "running"); - const previousState = useRef(phase.state); - - useEffect(() => { - if (previousState.current !== "running" && phase.state === "running") { - setOpen(true); - } - previousState.current = phase.state; - }, [phase.state]); - - return ( -
- - {open ? phase.members.map((member) => ) : null} -
- ); -} - -/** Expanded workflow: phase rail + full phase tree. */ -function ExpandedWorkflowSection({ - group, - environmentId, - threadId, - onCollapse, -}: { - group: AgentPanelWorkflowGroup; - environmentId: EnvironmentId | null; - threadId: ThreadId | null; - onCollapse: () => void; -}) { - const [scriptOpen, setScriptOpen] = useState(false); - const members = workflowMembers(group); - const settled = members.filter( - (member) => - member.status === "completed" || - member.status === "failed" || - member.status === "cancelled" || - member.status === "interrupted", - ).length; - const scriptPath = group.workflow.runHandles?.scriptPath; - const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null; - return ( -
-
- - - {group.workflow.workflowName ?? group.workflow.title} - - {canShowScript ? ( - - ) : null} - - {settled}/{members.length} settled - - -
- - {scriptOpen && canShowScript ? ( - setScriptOpen(false)} - /> - ) : null} - {group.phases.map((phase) => ( - - ))} - {group.unphasedMembers.map((member) => ( - - ))} - {group.phases.length === 0 && group.unphasedMembers.length === 0 ? ( - - ) : null} -
- ); -} - -/** - * Collapsed workflow: one summary line. The parent owns expansion so a live - * workflow keeps its shape when it settles. - */ -function CollapsedWorkflowSection({ - group, - onExpand, -}: { - group: AgentPanelWorkflowGroup; - onExpand: () => void; -}) { - const members = workflowMembers(group); - const failed = members.filter((member) => member.status === "failed").length; - // Coordinator usage may already aggregate members (panel-footer rule): - // count it only when there are no member rows to sum. - const totalTokens = members.reduce( - (sum, member) => sum + (member.usage?.totalTokens ?? 0), - members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0, - ); - const elapsed = - group.workflow.startedAt && group.workflow.completedAt - ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt) - : null; - return ( -
- -
- ); -} - -/** A workflow's open state is presentation state, not a status derivative. */ -function WorkflowSection({ - group, - environmentId, - threadId, -}: { - group: AgentPanelWorkflowGroup; - environmentId: EnvironmentId | null; - threadId: ThreadId | null; -}) { - const [open, setOpen] = useState(() => workflowIsLive(group)); - return open ? ( - setOpen(false)} - /> - ) : ( - setOpen(true)} /> - ); -} - -export function AgentsPanel({ - model, - environmentId = null, - threadId = null, -}: { - model: AgentPanelModel; - environmentId?: EnvironmentId | null; - threadId?: ThreadId | null; -}) { - if (!model.hasAgents) { - return ( -
- -

No agents yet

-

- When this thread spawns subagents or runs a workflow, they show up here with live status, - activity, and token usage. -

-
- ); - } - - return ( -
- -
- {model.workflows.map((group) => ( - - ))} - {model.directAgents.length > 0 ? ( -
-
- Direct spawns -
- {model.directAgents.map((agent) => ( - - ))} -
- ) : null} -
-
-
- - {model.runningCount + model.waitingCount > 0 ? ( - - ● {model.runningCount + model.waitingCount} working - - ) : null} - {model.idleCount > 0 ? {model.idleCount} idle : null} - {model.settledCount > 0 ? {model.settledCount} settled : null} - - Σ {formatSubagentTokenCount(model.totalTokens)} tok -
-
- ); -} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5ef27841405a..0447bd06aef1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -404,11 +404,6 @@ import { import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { AgentsPanel } from "./AgentsPanel"; -import { - deriveAgentPanelModel, - projectedSubagentsToRuntime, -} from "@t3tools/client-runtime/state/subagentRuntime"; import { type EnvironmentOption, resolveEffectiveEnvMode, @@ -1617,17 +1612,6 @@ export default function ChatView(props: ChatViewProps) { } return null; }, [serverProjection?.providerTurns]); - // Agents surface (#5219): on orchestration-v2 the panel model comes from the - // projected subagent entities — the v2 leg of the spec's mapper swap. The - // native-activity fold never runs on this branch. - const agentPanelModel = useMemo( - () => - deriveAgentPanelModel({ - agents: [], - v2Projection: projectedSubagentsToRuntime(serverProjection?.subagents ?? []), - }), - [serverProjection?.subagents], - ); const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = useMemo(() => { @@ -4932,10 +4916,6 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef, openPreview], ); - const addAgentsSurface = useCallback(() => { - if (!activeThreadRef) return; - useRightPanelStore.getState().open(activeThreadRef, "agents"); - }, [activeThreadRef]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useDiffPanelStore.getState().selectGitScope(activeThreadRef, "unstaged"); @@ -10103,12 +10083,6 @@ export default function ChatView(props: ChatViewProps) { /> ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - ) : renderedRightPanelSurface?.kind === "agents" ? ( - ) : renderedRightPanelSurface?.kind === "device" ? ( {rightPanelContent} @@ -11007,7 +10974,6 @@ export default function ChatView(props: ChatViewProps) { onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} - onAddAgents={addAgentsSurface} onAddDevice={addDeviceSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} @@ -11015,9 +10981,7 @@ export default function ChatView(props: ChatViewProps) { filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} - agentsAvailable deviceAvailable={activeThreadRef !== null} - liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index dfaaf171c429..25b0997813e1 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -122,16 +122,13 @@ function renderTabs( onAddPullRequests={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} - onAddAgents={() => undefined} onAddDevice={() => undefined} - liveAgentCount={0} browserAvailable terminalAvailable={false} diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - agentsAvailable={false} deviceAvailable={false} >
content
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 45cd370461d9..b704b696bff5 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -14,7 +14,6 @@ import type { } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { - Bot, Smartphone, ChevronDown, ChevronLeft, @@ -122,7 +121,6 @@ interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; - onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; @@ -130,11 +128,8 @@ interface RightPanelTabsProps { filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; - agentsAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; - /** Running + waiting subagents; badges the Agents card in the empty state. */ - liveAgentCount: number; children: ReactNode; } @@ -161,7 +156,6 @@ const SURFACE_DISABLED_REASONS = { diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", - agents: "Agents are only available from a thread.", device: "Devices are only available from a thread.", } as const; @@ -185,7 +179,6 @@ const SURFACE_UNAVAILABLE_HINTS = { diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", - agents: "Available from a thread.", device: "Available from a thread.", } as const; @@ -325,7 +318,6 @@ function RightPanelEmptyState(props: { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; - onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; @@ -333,9 +325,7 @@ function RightPanelEmptyState(props: { filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; - agentsAvailable: boolean; deviceAvailable: boolean; - liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); @@ -348,7 +338,6 @@ function RightPanelEmptyState(props: { available: props.browserAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, - badgeCount: 0, }, { label: "Terminal", @@ -357,7 +346,6 @@ function RightPanelEmptyState(props: { available: props.terminalAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, - badgeCount: 0, }, { label: "Files", @@ -366,7 +354,6 @@ function RightPanelEmptyState(props: { available: props.filesAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.files, onClick: props.onAddFiles, - badgeCount: 0, }, { label: "Diff", @@ -375,7 +362,6 @@ function RightPanelEmptyState(props: { available: props.diffAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, onClick: props.onAddDiff, - badgeCount: 0, }, { label: "Pull request", @@ -384,7 +370,6 @@ function RightPanelEmptyState(props: { available: props.pullRequestAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, - badgeCount: 0, }, { label: "Linked pull requests", @@ -393,16 +378,6 @@ function RightPanelEmptyState(props: { available: props.pullRequestsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, - badgeCount: 0, - }, - { - label: "Agents", - icon: Bot, - shortcut: "A", - available: props.agentsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, - onClick: props.onAddAgents, - badgeCount: props.liveAgentCount, }, { label: "Device", @@ -412,7 +387,6 @@ function RightPanelEmptyState(props: { available: props.deviceAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, - badgeCount: 0, }, ] as const; @@ -486,14 +460,6 @@ function RightPanelEmptyState(props: { return ( - {action.badgeCount > 0 ? ( - - {action.badgeCount} - - ) : null} ); }; @@ -634,8 +600,6 @@ function surfaceTitle( return `#${surface.number}`; case "pull-requests": return "Pull requests"; - case "agents": - return "Agents"; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "preview": { @@ -719,8 +683,6 @@ function SurfaceIcon({ ); case "pull-requests": return ; - case "agents": - return ; case "device": return surface.target?.platform === "ios" ? ( @@ -923,14 +885,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequests, onClick: props.onAddPullRequests, }, - { - label: "Agents", - icon: Bot, - shortcut: "A", - available: props.agentsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.agents, - onClick: props.onAddAgents, - }, { label: "Device", icon: Smartphone, @@ -1410,7 +1364,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} - onAddAgents={props.onAddAgents} onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} @@ -1418,9 +1371,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} - agentsAvailable={props.agentsAvailable} deviceAvailable={props.deviceAvailable} - liveAgentCount={props.liveAgentCount} /> ) : ( props.children diff --git a/apps/web/src/components/chat/AgentElapsed.tsx b/apps/web/src/components/chat/AgentElapsed.tsx new file mode 100644 index 000000000000..312b0a96fd6d --- /dev/null +++ b/apps/web/src/components/chat/AgentElapsed.tsx @@ -0,0 +1,57 @@ +import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime"; +import { useEffect, useRef } from "react"; + +function formatElapsedSeconds(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const minutes = Math.floor(seconds / 60); + if (minutes === 0) { + return `${seconds}s`; + } + const hours = Math.floor(minutes / 60); + if (hours === 0) { + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`; + } + return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; +} + +function elapsedBetween(startedAt: string, endIso: string | null): string { + const start = Date.parse(startedAt); + const end = endIso ? Date.parse(endIso) : Date.now(); + if (Number.isNaN(start) || Number.isNaN(end)) { + return ""; + } + return formatElapsedSeconds((end - start) / 1000); +} + +/** + * Elapsed time for the current activation. Live agents self-tick via DOM + * writes (zero React commits per tick); settled agents freeze at completedAt. + */ +export function AgentElapsed({ agent }: { agent: RuntimeSubagent }) { + const textRef = useRef(null); + const live = agent.status === "running" || agent.status === "waiting"; + const startedAt = agent.startedAt; + + useEffect(() => { + if (!live || !startedAt) { + return; + } + const update = () => { + if (textRef.current) { + textRef.current.textContent = elapsedBetween(startedAt, null); + } + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [live, startedAt]); + + if (!startedAt) { + return null; + } + return ( + + {elapsedBetween(startedAt, live ? null : agent.completedAt)} + + ); +} diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index e21635c6f131..8dd7fc36d6e7 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -29,8 +29,6 @@ export interface PanelLayoutControlsProps { rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; rightPanelUnavailableLabel?: string; - /** Running + waiting subagents in this thread; badges the right panel toggle. */ - liveAgentCount: number; onToggleTerminal: () => void; onToggleThreadPanel: () => void; onToggleRightPanel: () => void; @@ -53,7 +51,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelOpen, rightPanelShortcutLabel, rightPanelUnavailableLabel = "Right panel is unavailable", - liveAgentCount, onToggleTerminal, onToggleThreadPanel, onToggleRightPanel, @@ -149,33 +146,17 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="shrink-0 [-webkit-app-region:no-drag]" pressed={rightPanelOpen} onPressedChange={onToggleRightPanel} - aria-label={ - liveAgentCount > 0 - ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` - : "Toggle right panel" - } + aria-label="Toggle right panel" variant="ghost" size="sm" disabled={!rightPanelAvailable} > - {liveAgentCount > 0 ? ( - - {liveAgentCount} - - ) : null} {rightPanelAvailable - ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${ - liveAgentCount > 0 - ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working` - : "" - }` + ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}` : rightPanelUnavailableLabel} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index b07013bea588..ad8642143d88 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -43,7 +43,7 @@ import { } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -import { AgentElapsed } from "../AgentsPanel"; +import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 418c1363fcf8..ef27a64130b4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -385,20 +385,24 @@ describe("rightPanelStore", () => { }); }); - it("drops persisted plan surfaces and does not reopen an empty panel", () => { + it.each([ + { kind: "plan", isOpen: true }, + { kind: "agents", isOpen: true }, + { kind: "agents", isOpen: false }, + ])("drops $kind with isOpen=$isOpen and falls back", ({ kind, isOpen }) => { expect( migratePersistedRightPanelState({ byThreadKey: { "env-1:thread-A": { - isOpen: true, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + isOpen, + activeSurfaceId: kind, + surfaces: [{ id: kind, kind }], }, "env-1:thread-B": { - isOpen: true, - activeSurfaceId: "plan", + isOpen, + activeSurfaceId: kind, surfaces: [ - { id: "plan", kind: "plan" }, + { id: kind, kind }, { id: "diff", kind: "diff" }, ], }, @@ -412,7 +416,7 @@ describe("rightPanelStore", () => { surfaces: [], }, "env-1:thread-B": { - isOpen: true, + isOpen, activeSurfaceId: "diff", surfaces: [{ id: "diff", kind: "diff" }], }, @@ -498,7 +502,7 @@ describe("rightPanelStore", () => { }); it("opening a different kind keeps both surfaces and activates the new one", () => { - useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); expect( @@ -508,7 +512,7 @@ describe("rightPanelStore", () => { it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); - useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().open(refA, "diff"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ @@ -516,7 +520,7 @@ describe("rightPanelStore", () => { activeSurfaceId: "diff", surfaces: [ { id: "diff", kind: "diff" }, - { id: "agents", kind: "agents" }, + { id: "device", kind: "device" }, ], }); }); @@ -670,15 +674,15 @@ describe("rightPanelStore", () => { it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); - useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().reconcileFileSurfaces(refA, false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "agents", - surfaces: [{ id: "agents", kind: "agents" }], + activeSurfaceId: "device", + surfaces: [{ id: "device", kind: "device" }], }); useRightPanelStore.getState().openFile(refB, "conductor.json"); @@ -720,16 +724,16 @@ describe("rightPanelStore", () => { }); it("close hides the panel without clearing its selected surface", () => { - useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); expect( selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), - ).toEqual({ id: "agents", kind: "agents" }); + ).toEqual({ id: "device", kind: "device" }); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, - activeSurfaceId: "agents", - surfaces: [{ id: "agents", kind: "agents" }], + activeSurfaceId: "device", + surfaces: [{ id: "device", kind: "device" }], }); }); @@ -759,12 +763,12 @@ describe("rightPanelStore", () => { it("toggle to a different kind switches active", () => { useRightPanelStore.getState().toggle(refA, "preview"); - useRightPanelStore.getState().toggle(refA, "agents"); - expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); + useRightPanelStore.getState().toggle(refA, "device"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("device"); }); it("removeThread clears persisted state", () => { - useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "device"); useRightPanelStore.getState().removeThread(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 49a94befc27e..9d73a952f094 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -29,7 +29,6 @@ const RIGHT_PANEL_KINDS = [ "terminal", "pull-request", "pull-requests", - "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; @@ -85,15 +84,15 @@ export type RightPanelSurface = url?: string; } /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ - | { id: "pull-requests"; kind: "pull-requests" } - | { id: "agents"; kind: "agents" }; + | { id: "pull-requests"; kind: "pull-requests" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device surface. -const RIGHT_PANEL_STORAGE_VERSION = 13; +// v14 removes the agents surface; lineage lives in the thread title bar. +const RIGHT_PANEL_STORAGE_VERSION = 14; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -207,8 +206,6 @@ const singletonSurface = ( return { id: "files", kind }; case "pull-requests": return { id: "pull-requests", kind }; - case "agents": - return { id: "agents", kind }; case "device": return { id: "device", kind }; } @@ -439,9 +436,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { - // Dropped surface kind: plans now render inline in the - // transcript (v9). - if ((surface as { kind?: string }).kind === "plan") return []; + // Removed surfaces: plans render inline, agents in thread lineage. + const kind = (surface as { kind?: string }).kind; + if (kind === "plan" || kind === "agents") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -526,9 +523,11 @@ export function migratePersistedRightPanelState(persistedState: unknown): { : persistedActiveSurfaceId !== null); // An open panel needs an active surface: if migration dropped // the persisted one (e.g. plan was active), fall back to the - // first survivor instead of rendering an open empty panel. + // first survivor instead of rendering an open empty panel. Removed + // agents selections also keep a survivor ready while the panel is closed. const activeSurfaceId = - persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); + persistedActiveSurfaceId ?? + (isOpen || rawActiveSurfaceId === "agents" ? (surfaces[0]?.id ?? null) : null); return [ threadKey, { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index adfc2e4fd2a1..60d78068fb8d 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1628,7 +1628,6 @@ function PullRequestsRouteView() { rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={shortcutLabelForCommand(keybindings, "rightPanel.toggle")} rightPanelUnavailableLabel="Select a pull request first" - liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} /> @@ -2022,7 +2021,6 @@ function PullRequestsRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} - onAddAgents={() => undefined} onAddDevice={() => undefined} browserAvailable={false} terminalAvailable={false} @@ -2030,9 +2028,7 @@ function PullRequestsRouteView() { filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - agentsAvailable={false} deviceAvailable={false} - liveAgentCount={0} pullRequestStatusSeeds={listedPullRequestTabStatuses} >