diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index d11f347e..846f3ae7 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -38,6 +38,8 @@ type ChatPanelInnerProps = { onSessionId?: (id: string | null) => void; /** The transcript to open on, already fetched. Absent for a new conversation. */ resume?: ResumeState; + /** Identifies this panel's queue, so switching conversations does not hand it to another panel. */ + panelId?: string; }; const ChatPanelInner = ({ @@ -51,6 +53,7 @@ const ChatPanelInner = ({ agentPanel, onSessionId, resume, + panelId, }: ChatPanelInnerProps) => { /** * Resuming needs the TRANSCRIPT, not just the id. @@ -99,6 +102,8 @@ const ChatPanelInner = ({ cwd={cwdParam} replaceUrl={false} promptPrefix={promptPrefix} + panelId={panelId} + sessionId={sessionKey ?? undefined} {...chatContext} /> @@ -241,6 +246,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp return ( ({ id: raw, raw, prefix: '', ids: [], images: [] }); + +const KEYS = ['p1:s1', 'p2:s1', 'p1:new', 'p2:new', 'screen:s1']; +beforeEach(() => KEYS.forEach((k) => writeQueue(k, []))); + +describe('queueKey', () => { + test('a panel and a session together identify a queue', () => { + expect(queueKey('p1', 's1')).toBe('p1:s1'); + }); + + test('two panels on the SAME conversation do not share a queue', () => { + expect(queueKey('p1', 's1')).not.toBe(queueKey('p2', 's1')); + }); + + test('two panels both on a new chat do not share one either', () => { + // The case that would otherwise collide: `sessionId` is undefined for both until the first turn. + expect(queueKey('p1', undefined)).not.toBe(queueKey('p2', undefined)); + }); + + test('one conversation in one panel is stable across remounts — that is the whole point', () => { + expect(queueKey('p1', 's1')).toBe(queueKey('p1', 's1')); + }); + + test('the screen has no panel and still gets a key of its own', () => { + expect(queueKey(undefined, 's1')).toBe('screen:s1'); + }); +}); + +describe('the store', () => { + test('what was queued is what comes back', () => { + writeQueue('p1:s1', [prompt('first'), prompt('second')]); + expect(readQueue('p1:s1').map((p) => p.raw)).toEqual(['first', 'second']); + }); + + test('switching panels does not leak a queue sideways', () => { + writeQueue('p1:s1', [prompt('mine')]); + expect(readQueue('p2:s1')).toEqual([]); + }); + + test('an unknown key is empty, not undefined — callers seed state from this', () => { + expect(readQueue('never:seen')).toEqual([]); + }); + + test('emptying a queue forgets it rather than keeping an empty entry', () => { + writeQueue('p1:s1', [prompt('x')]); + writeQueue('p1:s1', []); + expect(readQueue('p1:s1')).toEqual([]); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/queue-store.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/queue-store.ts new file mode 100644 index 00000000..32ea6bee --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/queue-store.ts @@ -0,0 +1,41 @@ +import type { QueuedPrompt } from './useEmbeddableChat'; + +/** + * Queued prompts, kept across a switch to another conversation. + * + * ── Why they were being lost ── + * + * `ChatPanelInner` is keyed on the session id, so opening a different conversation REMOUNTS it. That is + * deliberate and load-bearing — `useChat` seeds its state from the first session id it sees, so a remount + * is the honest way to say "this is a different chat". The queue was local state, so it died with the + * mount. + * + * Not a bug so much as a case nobody had reached: the queue was built for the minute between writing a + * message and a turn ending, and switching away mid-turn was never part of that picture. It is now, and + * losing text somebody typed is the worst thing this could do. + * + * ── Keyed by panel AND session ── + * + * Two panels can both be on a new chat, where the session id is `new` for both. Keying on the session + * alone would hand one panel's queue to the other — which is the same class of mistake as a queue being + * dropped, wearing the opposite face. + * + * ── Module state, so it dies with the tab ── + * + * Deliberately not persisted. A queue is "these go when the agent is free", which is a promise about the + * next minute; restoring one on a page loaded tomorrow would fire a message into a conversation whose + * context has moved on. Surviving a panel switch is the whole requirement. + */ +const queues = new Map(); + +/** A queue belongs to one panel's view of one conversation. */ +export const queueKey = (panelId: string | undefined, sessionId: string | undefined): string => + `${panelId ?? 'screen'}:${sessionId ?? 'new'}`; + +export const readQueue = (key: string): QueuedPrompt[] => queues.get(key) ?? []; + +/** Empty is the same as absent — an entry per conversation ever opened would grow without bound. */ +export function writeQueue(key: string, items: QueuedPrompt[]): void { + if (items.length === 0) queues.delete(key); + else queues.set(key, items); +} diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index c3a9d6a3..13a936fb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -4,6 +4,7 @@ import { useUserVisibleModels } from 'state/useModels'; import { useChat, type UseChatType } from '../../../hooks/useChat'; import { useAttachments } from '../useAttachments'; import { useSlashCommands } from '../useSlashCommands'; +import { queueKey, readQueue, writeQueue } from './queue-store'; import { usePromptHistory } from './usePromptHistory'; /** A prompt written mid-turn, held with the attachments it was composed with. */ @@ -26,6 +27,11 @@ export type QueuedPrompt = { type UseEmbeddableChatParams = { sessionId?: string; + /** + * Which panel this is, so a queue survives switching conversations without being handed to the wrong + * panel. Absent on the full screen, where there is only one chat. + */ + panelId?: string; initialModel?: string | null; initialMessage?: { text: string; @@ -81,6 +87,24 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp const attachmentManager = useAttachments({ sessionId }); const slashCommands = useSlashCommands({ sessionId }); + /** + * A queue restored by coming back to this conversation goes now, if the agent is free. + * + * The drain below only fires on a turn ENDING, and a remount has no such transition to watch — so + * without this, returning to a chat whose turn finished while you were elsewhere would leave your + * messages sitting in the tray with nothing about to happen. "These go when the agent is free" is the + * whole promise of queueing; the agent being free is exactly this case. + * + * Only on mount, and only when nothing is running: while a turn IS running the ordinary drain owns + * them, and firing here as well would deliver twice. + */ + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + restoredRef.current = true; + if (!isGenerating && queuedRef.current.length > 0) void drainQueueRef.current(); + }, []); + // Detect when agent finishes a message (isGenerating: true → false) const wasGeneratingRef = useRef(false); useEffect(() => { @@ -98,11 +122,16 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp // Prompts written while a turn was running, waiting their turn. The ref is what the drain reads — it // runs from an effect and from async continuations, both of which would otherwise see the array as it // was a render ago — and the state is only so the tray can show them. - const queuedRef = useRef([]); - const [queued, setQueued] = useState([]); + // + // Seeded from `queue-store`, because opening another conversation remounts this whole component and the + // queue would otherwise go with it. Text somebody typed must not be lost by navigating. + const storeKey = queueKey(params.panelId, params.sessionId); + const queuedRef = useRef(readQueue(storeKey)); + const [queued, setQueued] = useState(queuedRef.current); const setQueue = (next: QueuedPrompt[]) => { queuedRef.current = next; setQueued(next); + writeQueue(storeKey, next); }; const drainQueueRef = useRef<() => Promise>(async () => {});