From 5998492dcd3536e9e5bbd5610337972c02cd5c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 23:34:43 +0000 Subject: [PATCH] a chat panel opens on the last conversation, not a blank one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A panel that has never chosen now continues the newest conversation in its directory. Opening a dashboard onto a folder that already has history and starting a blank chat is the wrong default — the history is why you are there. It is also the case that prompted this: the conversation existed before the panel did. ── Three states, because two collapse wrongly ── undefined never chosen -> the newest conversation in this directory null "New chat" pressed -> a fresh one string that conversation `undefined` and `null` were the same value until now. Left that way, pressing New chat would set null, the default would read it as "nothing chosen", and the panel would snap straight back to the newest conversation — the button would look broken. ── Two races, both closed ── The panel does not mount until the session list has answered. Otherwise it starts a NEW conversation, the list arrives, the default applies, and the chat remounts onto something else — discarding anything typed or sent in the gap. And the id recorded on mount is only ever a REAL one. `chat.sessionId` is null for a moment while a resumed session connects, and writing that null would turn "never chosen" into "asked for a new chat", so the NEXT reload would open blank — a bug that only appears one reload later, which is the kind that gets blamed on something else. Asking for a new conversation is something the header says explicitly; this effect only reports what the harness decided. Once the default is applied the harness reports its id and it is recorded, so it stops being derived and becomes this panel's choice — "latest" the first time, "last used" thereafter. tsgo clean, frontend builds, 846 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 --- .../src/apps/Chat/ChatPanelWrapper.tsx | 42 +++++++++++++++++-- .../src/apps/Chat/use-panel-session.ts | 18 ++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 222c5924..fa542d1b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -6,6 +6,8 @@ import { EmbeddableChat } from './EmbeddableChat'; import { AgentIdentityBar } from './AgentIdentityBar'; import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel'; import { usePanelSession } from './use-panel-session'; +import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions'; +import { cwdToPath } from '../../components/Workspace'; type ChatPanelInnerProps = { scoped: boolean; @@ -40,9 +42,15 @@ const ChatPanelInner = ({ useEffect(() => { setActiveSession(chat.sessionId); - // A new chat starts with no id and is named by the harness on the first turn. Recording it here is - // what makes "New chat, say something, reload" come back to that conversation instead of a third one. - onSessionId?.(chat.sessionId); + // Only a REAL id, never null. + // + // A new chat is nameless until the harness names it on the first turn, and recording that id is what + // makes "New chat, say something, reload" return to that conversation instead of a third one. But + // `chat.sessionId` is also null for a moment while a resumed session connects — and writing that null + // would turn "this panel has never chosen" into "the user asked for a new chat", so the next reload + // would open blank. Asking for a new conversation is something the header says explicitly; this + // effect only ever reports what the harness decided. + if (chat.sessionId) onSessionId?.(chat.sessionId); }, [chat.sessionId]); return ( @@ -106,7 +114,28 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp // picker is for the ordinary case where a dashboard simply holds several conversations about the // directory it is rooted in. const [panelSessionId, setPanelSessionId] = usePanelSession(panelId, workspace); - const selectedSessionId = agentPanel.agent?.sessionKey ?? panelSessionId ?? undefined; + + /** + * A panel that has never chosen continues the newest conversation in its directory. + * + * Opening a dashboard onto a folder that already has history and starting a blank chat is the wrong + * default — the history is the reason you are there. This is also the case that prompted it: the + * conversation existed before the panel did. + * + * `undefined` from the hook means "never chosen" and `null` means "the user asked for a new one", which + * is why those are not the same value. Collapsing them would make New chat snap back to the newest. + * + * The absolute path, not the home-relative one — chat's cwd is the directory a turn really ran in, and + * `defaultCwd` is that home as the server sees it. Same join the header does. + */ + const { defaultCwd } = useChatPwds(); + const relativeCwd = cwdToPath(cwd); + const absoluteCwd = relativeCwd === '/' ? null : defaultCwd ? `${defaultCwd.replace(/\/$/, '')}${relativeCwd}` : null; + const { sessions, isLoading: sessionsLoading } = useClaudeSessions(absoluteCwd); + + const neverChose = panelSessionId === undefined; + const selectedSessionId = + agentPanel.agent?.sessionKey ?? (neverChose ? sessions[0]?.id : (panelSessionId ?? undefined)); // The 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 @@ -118,6 +147,11 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp // start a throwaway session and stream the answer into a transcript nobody can find again. if (agentPanel.isLoading) return null; + // And until the session list has answered, a panel that has never chosen must not mount either: it + // would start a NEW conversation, then the list would arrive, the default would apply, and the chat + // would remount onto something else — throwing away whatever had been typed or sent in between. + if (neverChose && !agentPanel.agent && sessionsLoading) return null; + return ( { if (!panelId) return; setBag((prev) => { - if ((prev[panelId]?.sessionId ?? null) === next) return prev; + if (panelId in prev && prev[panelId]?.sessionId === next) return prev; return { ...prev, [panelId]: { sessionId: next } }; }); },