a new chat gets its name without a refresh, and can be renamed

Start a chat and the header said "New chat" until you navigated away and back, and the rename control was
missing entirely. Both halves came from one cause, and it was not the titling.

The header resolves its own title by finding the session in the cached LIST —
`sessions.find((s) => s.id === sessionId)` — and a newborn conversation is not in that list. It has
staleTime 30s and nothing invalidated it, so `current` was undefined: the label fell through to "New chat",
and rename was absent because rename is a per-row action on a list the session was not in yet. Navigating
away and back refetched it, which is exactly what made this look like a rendering bug.

Verified first that the titling itself is fine and needs no change. Claude Code writes an `ai-title` entry
early — line 8 in a real transcript, after the user message and BEFORE the first assistant line — and
Officer's own `title: summaryTitle || firstUserText || '(untitled)'` means a session is never nameless once
its transcript has a user message. The server could always answer; nothing asked it.

So the conversation announces itself. On `session:claude` — the START of the turn, when the transcript
becomes real on disk — and again at the end of every turn, because the harness may have bettered the name
while it ran. The second call sits OUTSIDE the id check deliberately: that is true whether or not a
particular `result` happened to carry the id.

Cost is one small request per turn per open chat, which is the honest price of a list that is right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 18:15:20 +00:00
co-authored by Claude Opus 5
parent 75affec091
commit f9a3699603
@@ -200,6 +200,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(resumeSessionId ?? null);
const client = useClient();
const queryClient = useQueryClient();
/*
Tell the session LISTS that this conversation exists.
The chat header resolves its own title by finding this session in the cached list —
`sessions.find((s) => s.id === sessionId)`. A newborn conversation is not in that list: it has
`staleTime: 30s` and nothing invalidated it, so for up to half a minute the header read "New chat" and
the rename control was absent, because rename is a per-row action on a list the session was not in yet.
Navigating away and back fixed it, which is what made it look like a rendering bug rather than a stale
cache.
Fired when the transcript id arrives — the START of the turn, not the end — because that is the moment
the session becomes real on disk and the server can already name it from the first user message. Fired
again when the turn finishes, because the harness's own `ai-title` may have improved on it by then.
*/
const announceSession = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['CLAUDE_SESSIONS'] });
void queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
}, [queryClient]);
// Fetch the next older window and prepend it. The scroll container restores its position from the
// height delta so the view stays put. Guarded against overlap and against running once fully paged in.
@@ -343,6 +363,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
// The lists do not know this conversation exists yet — see `announceSession`.
announceSession();
break;
case 'assistant:delta':
@@ -408,6 +430,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
}
// Again at the end of every turn, and OUTSIDE the id check above: the harness writes its own
// `ai-title` while the turn runs, so the name the list is showing may now be bettered by the one on
// disk — and that is true whether or not this particular `result` happened to carry the id.
announceSession();
commitStreaming();
setMessages((prev) => [
...prev,