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:
@@ -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 { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChat } from '../../hooks/useChat';
|
import { useChat } from '../../hooks/useChat';
|
||||||
import { EmbeddableChat } from './EmbeddableChat';
|
import { EmbeddableChat } from './EmbeddableChat';
|
||||||
|
import { AgentIdentityBar } from './AgentIdentityBar';
|
||||||
|
import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
|
||||||
|
|
||||||
type ChatPanelInnerProps = {
|
type ChatPanelInnerProps = {
|
||||||
scoped: boolean;
|
scoped: boolean;
|
||||||
@@ -11,6 +13,8 @@ type ChatPanelInnerProps = {
|
|||||||
chatContext: Record<string, string | undefined>;
|
chatContext: Record<string, string | undefined>;
|
||||||
setActiveSession: (id: string | null) => void;
|
setActiveSession: (id: string | null) => void;
|
||||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
onTurnComplete?: (hadToolCalls: boolean) => void;
|
||||||
|
sessionKey?: string;
|
||||||
|
agentPanel: UseAgentPanelType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatPanelInner = ({
|
const ChatPanelInner = ({
|
||||||
@@ -20,8 +24,10 @@ const ChatPanelInner = ({
|
|||||||
chatContext,
|
chatContext,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
|
sessionKey,
|
||||||
|
agentPanel,
|
||||||
}: ChatPanelInnerProps) => {
|
}: ChatPanelInnerProps) => {
|
||||||
const chat = useChat(undefined, undefined, {
|
const chat = useChat(sessionKey, undefined, {
|
||||||
replaceUrl: false,
|
replaceUrl: false,
|
||||||
projectScoped: scoped,
|
projectScoped: scoped,
|
||||||
onTurnComplete,
|
onTurnComplete,
|
||||||
@@ -33,18 +39,23 @@ const ChatPanelInner = ({
|
|||||||
}, [chat.sessionId]);
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbeddableChat
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
className="h-full"
|
<AgentIdentityBar panel={agentPanel} />
|
||||||
chat={chat}
|
<EmbeddableChat
|
||||||
cwd={cwdParam}
|
className="min-h-0 flex-1"
|
||||||
replaceUrl={false}
|
chat={chat}
|
||||||
promptPrefix={promptPrefix}
|
cwd={cwdParam}
|
||||||
{...chatContext}
|
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 { dashboardId, cwd, root, promptPrefix } = useWorkspace();
|
||||||
const scoped = cwd !== '~';
|
const scoped = cwd !== '~';
|
||||||
|
|
||||||
@@ -55,6 +66,8 @@ export const ChatPanelWrapper = () => {
|
|||||||
? { context: 'dashboard' as const, contextId: dashboardId }
|
? { context: 'dashboard' as const, contextId: dashboardId }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
const agentPanel = useAgentPanel(panelId);
|
||||||
|
|
||||||
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||||
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
const [, setFilesRefresh] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||||
@@ -69,16 +82,34 @@ export const ChatPanelWrapper = () => {
|
|||||||
[setPreviewRefresh, setFilesRefresh],
|
[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 (
|
return (
|
||||||
<ChatPanelInner
|
<ChatPanelInner
|
||||||
|
key={key}
|
||||||
scoped={scoped}
|
scoped={scoped}
|
||||||
cwdParam={cwdParam}
|
cwdParam={cwdParam}
|
||||||
promptPrefix={promptPrefix}
|
promptPrefix={promptPrefix}
|
||||||
chatContext={chatContext}
|
chatContext={chatContext}
|
||||||
setActiveSession={setActiveSession}
|
setActiveSession={setActiveSession}
|
||||||
onTurnComplete={onTurnComplete}
|
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>;
|
||||||
Reference in New Issue
Block a user