a chat panel can be a named agent with one session forever

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.
This commit is contained in:
2026-08-07 08:29:20 +00:00
parent e58852412a
commit bc8208622c
3 changed files with 276 additions and 11 deletions
@@ -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<string | null>(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 (
<form onSubmit={submit} className="flex items-center gap-1.5 border-b px-2 py-1.5">
<Users className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<Input
autoFocus
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder="frontend"
className="h-6 flex-1 text-xs"
onKeyDown={(ev) => {
if (ev.key === 'Escape') setNaming(false);
}}
/>
<Button type="submit" size="sm" className="h-6 px-2 text-xs" disabled={!name.trim() || panel.claim.isPending}>
{panel.claim.isPending ? 'Naming…' : 'Name'}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => setNaming(false)}
aria-label="Cancel"
>
<X className="h-3 w-3" />
</Button>
{error ? <span className="text-destructive max-w-[50%] truncate text-xs">{error}</span> : null}
</form>
);
}
if (panel.orphaned) {
return (
<div className="text-muted-foreground flex items-center gap-1.5 border-b px-2 py-1.5 text-xs">
<Users className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">This panel's agent no longer exists.</span>
<Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={panel.release}>
Forget it
</Button>
</div>
);
}
if (!panel.agent) {
return (
<div className="flex items-center gap-1.5 border-b px-2 py-1.5">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground h-6 px-1.5 text-xs"
onClick={() => setNaming(true)}
>
<Users className="mr-1 h-3.5 w-3.5" />
Name this agent
</Button>
</div>
);
}
const peers = panel.agents.filter((a) => a.id !== panel.agent!.id);
return (
<div className="flex items-center gap-1.5 border-b px-2 py-1.5 text-xs">
<span className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full" aria-hidden />
<span className="font-medium">{panel.agent.name}</span>
<span
className="text-muted-foreground flex-1 truncate"
title={peers.length ? `Peers: ${peers.map((p) => p.name).join(', ')}` : undefined}
>
{peers.length === 0
? 'no peers yet'
: peers.length === 1
? `1 peer: ${peers[0]!.name}`
: `${peers.length} peers`}
</span>
</div>
);
};
@@ -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<string, string | undefined>;
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 (
<EmbeddableChat
className="h-full"
chat={chat}
cwd={cwdParam}
replaceUrl={false}
promptPrefix={promptPrefix}
{...chatContext}
/>
<div className="flex h-full min-h-0 flex-col">
<AgentIdentityBar panel={agentPanel} />
<EmbeddableChat
className="min-h-0 flex-1"
chat={chat}
cwd={cwdParam}
replaceUrl={false}
promptPrefix={promptPrefix}
{...chatContext}
/>
</div>
);
};
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<string | null>('chat:active-session', null);
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
const [, setFilesRefresh] = usePanelChannel<number>('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 (
<ChatPanelInner
key={key}
scoped={scoped}
cwdParam={cwdParam}
promptPrefix={promptPrefix}
chatContext={chatContext}
setActiveSession={setActiveSession}
onTurnComplete={onTurnComplete}
sessionKey={agentPanel.agent?.sessionKey}
agentPanel={agentPanel}
/>
);
};
@@ -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<AgentPanelConfig>(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<string | null>(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<typeof useAgentPanel>;