From bc8208622cf4246d465668f5b9d7bb590508537d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 08:29:20 +0000 Subject: [PATCH] a chat panel can be a named agent with one session forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel remembers its agent's NAME in its own layout config, not the server row's panel id: movePanel mints a fresh id on every drag, so an id-based lookup forgets the agent the first time the dashboard is rearranged. The name travels with the panel contents; the row is found by name and re-anchored to wherever the panel now is. A named panel passes the row's sessionKey to useChat instead of letting the server mint a throwaway uuid per connection. That is the whole of continuity: the same key comes back on every load, resume-cursor replays the durable events under it, and the claude sidecar resumes the same transcript from its write-through map even after the session was reaped. Its cwd comes from the row too, because deliverToAgentPanel already runs an incoming handoff there — otherwise the same agent would work in two directories depending on whether the human or a peer spoke to it. Only on real dashboards. The fixed screens keep anonymous chat panels exactly as before. --- .../src/apps/Chat/AgentIdentityBar.tsx | 114 +++++++++++++++++ .../src/apps/Chat/ChatPanelWrapper.tsx | 53 ++++++-- .../officerdev/src/apps/Chat/useAgentPanel.ts | 120 ++++++++++++++++++ 3 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/AgentIdentityBar.tsx create mode 100644 src/workspaces/officerdev/src/apps/Chat/useAgentPanel.ts diff --git a/src/workspaces/officerdev/src/apps/Chat/AgentIdentityBar.tsx b/src/workspaces/officerdev/src/apps/Chat/AgentIdentityBar.tsx new file mode 100644 index 00000000..3eab7bd4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/AgentIdentityBar.tsx @@ -0,0 +1,114 @@ +import { useState, type FormEvent } from 'react'; +import { Users, X } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import type { UseAgentPanelType } from './useAgentPanel'; + +type AgentIdentityBarProps = { panel: UseAgentPanelType }; + +/** + * One line above the transcript saying which agent this panel is, or offering to make it one. + * + * It renders only on a real dashboard — see `useAgentPanel`. A named panel is a *different thing* from + * an anonymous chat: it keeps one session forever, it is addressable by its peers, and work can arrive + * in it while nobody is looking. That is worth a line of chrome, because otherwise the only way to tell + * the two apart is to notice that the conversation did not reset. + */ +export const AgentIdentityBar = ({ panel }: AgentIdentityBarProps) => { + const [naming, setNaming] = useState(false); + const [name, setName] = useState(''); + const [error, setError] = useState(null); + + if (!panel.addressable || panel.isLoading) return null; + + const submit = async (ev: FormEvent) => { + ev.preventDefault(); + setError(null); + try { + await panel.claim.mutateAsync({ name: name.trim().toLowerCase() }); + setNaming(false); + setName(''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + if (naming) { + return ( +
+ + setName(ev.target.value)} + placeholder="frontend" + className="h-6 flex-1 text-xs" + onKeyDown={(ev) => { + if (ev.key === 'Escape') setNaming(false); + }} + /> + + + {error ? {error} : null} + + ); + } + + if (panel.orphaned) { + return ( +
+ + This panel's agent no longer exists. + +
+ ); + } + + if (!panel.agent) { + return ( +
+ +
+ ); + } + + const peers = panel.agents.filter((a) => a.id !== panel.agent!.id); + + return ( +
+ + {panel.agent.name} + p.name).join(', ')}` : undefined} + > + {peers.length === 0 + ? 'no peers yet' + : peers.length === 1 + ? `1 peer: ${peers[0]!.name}` + : `${peers.length} peers`} + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index d78cb6e4..236b428a 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -3,6 +3,8 @@ import { useWorkspace } from '../../components/Workspace'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChat } from '../../hooks/useChat'; import { EmbeddableChat } from './EmbeddableChat'; +import { AgentIdentityBar } from './AgentIdentityBar'; +import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel'; type ChatPanelInnerProps = { scoped: boolean; @@ -11,6 +13,8 @@ type ChatPanelInnerProps = { chatContext: Record; setActiveSession: (id: string | null) => void; onTurnComplete?: (hadToolCalls: boolean) => void; + sessionKey?: string; + agentPanel: UseAgentPanelType; }; const ChatPanelInner = ({ @@ -20,8 +24,10 @@ const ChatPanelInner = ({ chatContext, setActiveSession, onTurnComplete, + sessionKey, + agentPanel, }: ChatPanelInnerProps) => { - const chat = useChat(undefined, undefined, { + const chat = useChat(sessionKey, undefined, { replaceUrl: false, projectScoped: scoped, onTurnComplete, @@ -33,18 +39,23 @@ const ChatPanelInner = ({ }, [chat.sessionId]); return ( - +
+ + +
); }; -export const ChatPanelWrapper = () => { +type ChatPanelWrapperProps = { panelId: string }; + +export const ChatPanelWrapper = ({ panelId }: ChatPanelWrapperProps) => { const { dashboardId, cwd, root, promptPrefix } = useWorkspace(); const scoped = cwd !== '~'; @@ -55,6 +66,8 @@ export const ChatPanelWrapper = () => { ? { context: 'dashboard' as const, contextId: dashboardId } : {}; + const agentPanel = useAgentPanel(panelId); + const [, setActiveSession] = usePanelChannel('chat:active-session', null); const [, setPreviewRefresh] = usePanelChannel('preview:refresh', 0); const [, setFilesRefresh] = usePanelChannel('files:refresh-signal', 0); @@ -69,16 +82,34 @@ export const ChatPanelWrapper = () => { [setPreviewRefresh, setFilesRefresh], ); - const cwdParam = scoped ? { root, path: cwd } : undefined; + // A named agent's own directory wins over the dashboard's. It has to: `deliverToAgentPanel` runs an + // incoming handoff in `resolveBaseCwd(email, row.cwd)`, so if a turn the human types ran anywhere else + // the same agent would be working in two directories depending on who spoke to it. Both paths are the + // same home-relative string resolved by the same function. + const cwdParam = agentPanel.agent?.cwd ? { path: agentPanel.agent.cwd } : scoped ? { root, path: cwd } : undefined; + + // A named panel is one session forever, so its key has to be decided before `useChat` mounts — the + // hook seeds its state from the first `initialSessionId` it sees and the socket's resume handshake + // sends whatever is in that state on connect. Remounting on the key is the honest way to say "this is + // now a different conversation": it happens exactly twice in a panel's life, when it is named and if + // it is released, and both are the user asking for precisely that. + const key = agentPanel.agent?.sessionKey ?? 'anonymous'; + + // Until the address book has answered, a named panel must not mount an anonymous chat — that would + // start a throwaway session and stream the answer into a transcript nobody can find again. + if (agentPanel.isLoading) return null; return ( ); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/useAgentPanel.ts b/src/workspaces/officerdev/src/apps/Chat/useAgentPanel.ts new file mode 100644 index 00000000..d806743b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useAgentPanel.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import { useWorkspace, usePanelConfig } from '../../components/Workspace'; + +/** What `/chat/agent-panels` returns — the row minus the bearer token and the user id. */ +export type AgentPanelView = { + id: number; + dashboardId: string; + panelId: string; + name: string; + sessionKey: string; + cwd: string | null; + rolePrompt: string | null; + introducedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +type AgentPanelConfig = { agentName?: string }; + +/** + * Resolve which named agent, if any, this panel is. + * + * **The panel's memory is its name, not its id.** `movePanel` treats panel ids as positions in the + * layout tree and moves *contents* between them, so dragging a panel across the dashboard gives it a + * fresh id — an id-based lookup forgets the agent the first time the owner rearranges anything. So the + * name lives in the panel's own `config` (which travels with the contents through swap and drag) and + * the server row is found by that name. The row's `panelId` is bookkeeping; when it disagrees with + * where the panel actually is, we re-anchor it rather than trust it. + * + * Only meaningful on a real dashboard. The fixed screens (`screens/chat`, `screens/email`, …) are not + * places you assemble a team, so the query never runs there and every panel stays an anonymous chat — + * exactly as before this existed. + */ +export function useAgentPanel(panelId: string) { + const { dashboardId, cwd } = useWorkspace(); + const [config, setConfig] = usePanelConfig(panelId); + const agentName = config?.agentName; + + const addressable = !!dashboardId && !dashboardId.startsWith('screens/'); + + // `useClient()` rebuilds its verbs on every render, so a verb in a dependency list changes identity + // every render and silently re-runs whatever depends on it. Hold it in a ref instead. + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const queryClient = useQueryClient(); + const queryKey = ['agent-panels', dashboardId]; + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: () => + clientRef.current.get<{ agents: AgentPanelView[] }>( + `/chat/agent-panels?dashboardId=${encodeURIComponent(dashboardId ?? '')}`, + ), + enabled: addressable, + staleTime: 30_000, + }); + + const agents = data?.agents ?? []; + const agent = agentName ? agents.find((a) => a.name === agentName) : undefined; + + // Named, but no such row: the agent was deleted from another panel or another tab. Say so rather than + // silently opening an anonymous chat — the panel would look identical while pointing at nothing. + const orphaned = !!agentName && !isLoading && !!data && !agent; + + // The panel moved. Tell the server where it lives now — the row's `panelId` is how a future GET, and + // anything else keyed on position, finds it. + const reanchoredRef = useRef(null); + useEffect(() => { + if (!agent || agent.panelId === panelId) return; + if (reanchoredRef.current === `${agent.id}:${panelId}`) return; + reanchoredRef.current = `${agent.id}:${panelId}`; + void clientRef.current + .patch<{ agent: AgentPanelView }>(`/chat/agent-panels/${agent.id}`, { panelId }) + .then(() => queryClient.invalidateQueries({ queryKey: ['agent-panels', dashboardId] })) + .catch(() => { + // Non-fatal: the panel already knows its own name, which is the address that matters. Allow a + // later render to try again rather than pinning the failed attempt. + reanchoredRef.current = null; + }); + }, [agent?.id, agent?.panelId, panelId, dashboardId, queryClient]); + + const claim = useMutation({ + mutationFn: (input: { name: string; cwd?: string; rolePrompt?: string }) => + clientRef.current.post<{ agent: AgentPanelView; created: boolean }>('/chat/agent-panels', { + dashboardId, + panelId, + // Record the directory the panel is already scoped to. A row with no cwd runs incoming handoffs + // in the owner's home while the human's own turns run in the dashboard's directory — the same + // agent working in two places depending on who spoke to it. + ...(cwd && cwd !== '~' ? { cwd } : {}), + ...input, + }), + onSuccess: ({ agent: created }) => { + // Write the name into the panel before invalidating: the config is what survives a drag, and a + // refetch that landed first would show an agent this panel does not yet claim. + setConfig({ agentName: created.name }); + void queryClient.invalidateQueries({ queryKey: ['agent-panels', dashboardId] }); + }, + }); + + // Forget the name locally without deleting the agent. The session and its transcript are untouched — + // another panel can claim the same name back and pick the conversation up where it stopped. + const release = useCallback(() => setConfig(undefined), [setConfig]); + + return { + addressable, + agent, + agents, + orphaned, + isLoading: addressable && isLoading, + claim, + release, + }; +} + +export type UseAgentPanelType = ReturnType;