diff --git a/src/databases/officer_db/src/command-hq/index.ts b/src/databases/officer_db/src/command-hq/index.ts index 5c70d26f..d7cff72b 100644 --- a/src/databases/officer_db/src/command-hq/index.ts +++ b/src/databases/officer_db/src/command-hq/index.ts @@ -15,6 +15,7 @@ export { disarmCommandHqWatcher, recordCommandHqTurn, ingestSpawnedChildren, + parentCommanderOf, recordSpawnedOutcome, getCommandHqCostSummary, listCommandHqTurns, diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts index 1bfec196..0b503030 100644 --- a/src/databases/officer_db/src/command-hq/queries.ts +++ b/src/databases/officer_db/src/command-hq/queries.ts @@ -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/` 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 { + 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, + }; +} diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 3b78ea61..6d4f3401 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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), }); }); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index adc809d2..90951c2d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -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 ", 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 } }) => ( +
+ Started by{' '} + + {parent.name ?? 'a commander'} + +
+); + // 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 (
+ {resume?.spawnedBy && }