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:
@@ -15,6 +15,7 @@ export {
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
parentCommanderOf,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
|
||||
@@ -475,3 +475,44 @@ export async function recordSpawnedOutcome(
|
||||
and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export type SpawnParent = {
|
||||
/** The commander's transcript id — what `/command-hq/<id>` opens. */
|
||||
sessionId: string;
|
||||
/** What the owner called it, or null for a commander filed before the column existed. */
|
||||
name: string | null;
|
||||
firstSeenAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which commander spawned this session, if any.
|
||||
*
|
||||
* The same row `ingestSpawnedChildren` writes, read from the other end — so this needed no new table and no
|
||||
* new write path. A commander's children were already recorded because deleting one has to take them with
|
||||
* it; that record answers "who is my parent" for free, and nothing was asking.
|
||||
*
|
||||
* Null for almost every session, and that is the normal case rather than a miss: a chat somebody opened
|
||||
* themselves has no parent, and a session spawned by anything other than a commander has no row here. The
|
||||
* caller must treat absence as "not spawned", never as "lookup failed".
|
||||
*/
|
||||
export async function parentCommanderOf(spawnedSessionId: string): Promise<SpawnParent | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
name: commandHqSessions.name,
|
||||
firstSeenAt: commandHqSpawned.firstSeenAt,
|
||||
finishedAt: commandHqSpawned.finishedAt,
|
||||
})
|
||||
.from(commandHqSpawned)
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
firstSeenAt: row.firstSeenAt.toISOString(),
|
||||
finishedAt: row.finishedAt ? row.finishedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getChatNotifyPreferences,
|
||||
setChatNotifyPreference,
|
||||
forgetChatSessions,
|
||||
parentCommanderOf,
|
||||
} from 'officerdb';
|
||||
import type { NotifyPreference } from 'officerdb/types';
|
||||
import { setNudge, getNudge, cancelNudge } from 'officerdb';
|
||||
@@ -273,6 +274,15 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: (await listCommandHqSessionIds(user.id)).has(id),
|
||||
/*
|
||||
Which commander spawned this, if any — the row `ingestSpawnedChildren` writes, read from the other
|
||||
end. A spawned agent has always been fully readable here; what it could never say was who started
|
||||
it, so opening one gave no way back to the conversation that owns it.
|
||||
|
||||
Null for almost every session and that is the normal case, not a miss: a chat somebody opened
|
||||
themselves has no parent. Never fatal — a session must render when this lookup will not.
|
||||
*/
|
||||
spawnedBy: await parentCommanderOf(id).catch(() => null),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user