a spawned agent is a link, not a rumour
The endpoint, the table and the ingest all existed and nothing had ever called them — so `command_hq_spawned` sat empty and a spawned agent stayed invisible even though its transcript was readable the whole time. It writes an ordinary transcript under the project directory for its cwd, so `/chat/<id>` has always rendered it live. What was missing was anybody being told the id existed: the job panel showed its output as a text stream in a small box with no link, so finding the session meant knowing to look. Now the commander's own pane lists them, each a link to its transcript, with a dot on the ones still running. Ingest happens on read, so the list is right without a timer that can stop working quietly. Cost is stated as a floor when any child did not report one — `$1.20+` and a count of unknowns, never a confident total. A commander's line reading $0.28 for a night that spent several dollars is the specific understatement this exists to stop. Renders nothing when there are none. A commander that has not fired yet is the normal case and does not need a box explaining that. Closes todo/background-jobs/a-spawned-agent-is-invisible-in-the-interface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Check, FolderOpen, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
@@ -12,7 +12,12 @@ import { useChat, EmbeddableChat } from '../Chat';
|
||||
import { DirPickerModal } from '../ChatHistory/DirPickerModal';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
import {
|
||||
useCommandHqCategories,
|
||||
useCommandHqChildren,
|
||||
useCommandHqCommander,
|
||||
useCommandHqCommanderActions,
|
||||
} from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
|
||||
@@ -390,6 +395,62 @@ function NewCommander({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents this commander spawned, each a link to its own transcript.
|
||||
*
|
||||
* A spawned agent has always been fully readable — it writes an ordinary transcript under the project
|
||||
* directory for its cwd, and `/chat/<id>` renders it live like any other session. What was missing was
|
||||
* anybody being told the id existed. The job panel showed its output as a text stream in a small box with
|
||||
* no link, so the only way to find the session was to know to go looking.
|
||||
*
|
||||
* Renders nothing when there are none, rather than an empty state. A commander that has not fired yet is
|
||||
* the normal case and does not need a box explaining that.
|
||||
*/
|
||||
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
|
||||
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
|
||||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<div className="flex items-baseline gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Spawned</span>
|
||||
<span>{children.length}</span>
|
||||
{/*
|
||||
Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's
|
||||
line reading $0.28 for a night that spent several dollars is the specific understatement this
|
||||
exists to stop.
|
||||
*/}
|
||||
{totalUsd > 0 && (
|
||||
<span>
|
||||
· ${totalUsd.toFixed(2)}
|
||||
{unknownCost ? '+' : ''}
|
||||
</span>
|
||||
)}
|
||||
{unknownCost > 0 && (
|
||||
<span title={`${unknownCost} child${unknownCost === 1 ? '' : 'ren'} did not report a cost`}>
|
||||
· {unknownCost} unknown
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{children.map((c) => (
|
||||
<Link
|
||||
key={c.spawnedSessionId}
|
||||
to={`/chat/${c.spawnedSessionId}`}
|
||||
title={`spawned ${new Date(c.firstSeenAt).toLocaleString()}${c.finishedAt ? `, finished ${new Date(c.finishedAt).toLocaleString()}` : ' — still running'}`}
|
||||
className="rounded border border-border px-1.5 py-0.5 font-mono text-[11px] text-foreground/80 hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
{c.spawnedSessionId.slice(0, 8)}
|
||||
{!c.finishedAt && (
|
||||
<span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-duck-orange align-middle" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MetaBarProps = {
|
||||
sessionId: string;
|
||||
title: string | undefined;
|
||||
@@ -576,6 +637,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
|
||||
isGenerating={chat.isGenerating}
|
||||
onDisconnect={chat.disconnectSession}
|
||||
/>
|
||||
<SpawnedChildren sessionId={sessionId} />
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
|
||||
@@ -102,3 +102,39 @@ export function useCommandHqCommanderActions(sessionId: string) {
|
||||
|
||||
return { setCategory, rename, disarmWatcher, deleteCommander };
|
||||
}
|
||||
|
||||
export type SpawnedChild = {
|
||||
spawnedSessionId: string;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
totalUsd: number | null;
|
||||
outcome: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The agents this commander spawned.
|
||||
*
|
||||
* The backend ingests the loop's record file on every read, so calling this often is free and there is no
|
||||
* timer to stop working quietly. Which is the reason this hook exists at all: the endpoint and the table
|
||||
* were built, and nothing ever called them — so the table sat empty and a spawned agent stayed invisible
|
||||
* even though its transcript was sitting there, readable, the whole time.
|
||||
*/
|
||||
export function useCommandHqChildren(sessionId: string | undefined) {
|
||||
const client = useClient();
|
||||
const { data, isLoading } = useQuery<{ children: SpawnedChild[]; totalUsd: number; unknownCost: number }>({
|
||||
queryKey: ['command-hq', 'children', sessionId ?? ''],
|
||||
queryFn: () => client.get(`/command-hq/commanders/${sessionId}/children`),
|
||||
enabled: !!sessionId,
|
||||
// A spawn appears while the panel is open, so a stale window here means watching a stale list during
|
||||
// exactly the minutes somebody is watching.
|
||||
staleTime: 0,
|
||||
refetchInterval: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
return {
|
||||
children: data?.children ?? [],
|
||||
totalUsd: data?.totalUsd ?? 0,
|
||||
unknownCost: data?.unknownCost ?? 0,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user