a spawned session says who started it

The other half of making a spawned agent visible. The commander's pane now lists
its children; opening one gave no way back — no link to the conversation that owns
it, and nothing to tell it apart from a chat somebody had started themselves and
forgotten.

`parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from
the other end. So this needed no table and no write path: a commander's children
were recorded because deleting one has to take them with it, and that record
answers "who is my parent" for free. Nothing had asked.

`GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line
above the transcript linking back to the commander. Null for almost every session,
which is the normal case rather than a miss — a chat somebody opened has no parent
— so the bar renders nothing at all rather than saying so. Never fatal: a session
must open when this lookup will not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 05:09:28 +00:00
co-authored by Claude Opus 5
parent 44f5246e38
commit f6b9ddc877
5 changed files with 85 additions and 0 deletions
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useWorkspace } from '../../components/Workspace';
import { useActiveChatSession, useFilesRefresh, usePreviewRefresh } from '../../channels';
import { useChat } from '../../hooks/useChat';
@@ -21,8 +22,30 @@ type ResumeState = {
offset: number;
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
isCommander: boolean;
/** The commander that started this, when one did — see `SpawnedByBar`. */
spawnedBy?: { sessionId: string; name: string | null } | null;
};
/**
* "Started by <commander>", above a transcript that was spawned rather than opened.
*
* A spawned agent has always rendered here perfectly — it writes an ordinary transcript and this view reads
* it like any other. What it could never say was where it came from, so somebody who followed a link into
* one had no way back to the conversation that owns it, and no way to tell it apart from a chat they had
* started themselves and forgotten.
*
* Renders nothing for the overwhelming majority of sessions, which have no parent. That is the normal case
* and it does not want a bar saying so.
*/
const SpawnedByBar = ({ parent }: { parent: { sessionId: string; name: string | null } }) => (
<div className="shrink-0 border-b border-border px-3 py-1.5 text-xs text-muted-foreground">
Started by{' '}
<Link to={`/command-hq/${parent.sessionId}`} className="text-foreground/80 underline hover:text-foreground">
{parent.name ?? 'a commander'}
</Link>
</div>
);
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
// on scroll. Matches the /chat screen.
const CHAT_TAIL = 20;
@@ -105,6 +128,7 @@ const ChatPanelInner = ({
return (
<div className="flex h-full min-h-0 flex-col">
<AgentIdentityBar panel={agentPanel} />
{resume?.spawnedBy && <SpawnedByBar parent={resume.spawnedBy} />}
<EmbeddableChat
className="min-h-0 flex-1"
chat={chat}
@@ -234,6 +258,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
total: detail.total,
offset: detail.offset,
isCommander: detail.isCommander === true,
spawnedBy: detail.spawnedBy ?? null,
});
} catch {
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
@@ -104,6 +104,14 @@ export type ClaudeSessionDetail = {
* to decide it from the route, so the same commander opened in /chat quietly lost it.
*/
isCommander?: boolean;
/**
* The commander that spawned this session, if one did.
*
* Read from the row a commander already keeps of its children — so this is the parent side of a link that
* existed for a different reason (deleting a commander has to take its children with it). Absent for
* almost every conversation, and absence means "nobody spawned this", never "the lookup failed".
*/
spawnedBy?: { sessionId: string; name: string | null; firstSeenAt: string; finishedAt: string | null } | null;
};
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };