fix(agent): chat console keeps its thread across turns and reloads (#1859)

Three user-reported failures in the assistant panel, one root cause each:

1. "The chat asks what I'm referring to" when continuing a thread. The
   single-call console (general.help, AskConsole -> /api/agent/ask) was
   stateless since the 08-20 model-agnostic cutover: conversationId was only
   the tool actor id, so every turn was answered blind, reload or not.
   The provider-agnostic GenerateTextRequest gains an optional `history`
   (real message turns before the prompt, in both the Anthropic-family and
   the OpenAI-compatible adapter; absent/empty leaves the request
   byte-identical to the single-turn call). The route loads the thread's
   earlier turns server-side (loadChatHistory: text only, hidden and tool
   rows dropped, alternation repaired, newest 16 rows / 10k chars) before
   writing the new question, and hands them to the model.

2. A full page reload (the deploy prompt's "Ladda om") closed the docked
   panel and dropped the thread from view. The panel now remembers its open
   thread per tab in sessionStorage (lib/agent-panel/session-restore) and
   the provider reopens it on mount; the sheet loads it exactly like a pick
   from "Tidigare konversationer". Close and "Ny konversation" forget it; a
   thread that no longer opens is dropped instead of retried on every reload.

3. "Can't type any more" once the update banner shows. DeployReloadPrompt's
   full-width wrapper sits at z-[60] after the panel in DOM order and
   swallowed clicks on the panel's composer; only the card takes input now.


Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 16:37:30 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent f75ea2384d
commit 3edbf0a2e3
18 changed files with 680 additions and 20 deletions
+50 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
X,
Expand,
@@ -41,6 +41,10 @@ import AgentSessionList from './AgentSessionList'
import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import {
clearAgentSheetSession,
writeAgentSheetSession,
} from '@/lib/agent-panel/session-restore'
import { cn } from '@/lib/utils'
// Undimmed non-modal side sheet: sits above the page on a hairline border +
@@ -55,6 +59,9 @@ interface Props {
intentArgs?: Record<string, unknown>
contextRef?: string
seedUserMessage?: string
// Open this existing thread on mount (a reload restore, see the provider)
// instead of starting a fresh one on the intent.
resumeConversationId?: string
// Hidden (display:none) but still mounted so the conversation survives. The
// provider keeps rendering this component; we just visually remove it.
collapsed: boolean
@@ -205,6 +212,7 @@ export default function AgentSheet({
intentArgs,
contextRef,
seedUserMessage,
resumeConversationId,
collapsed,
onStatus,
onDockWidthChange,
@@ -227,7 +235,10 @@ export default function AgentSheet({
// A past conversation the user picked from the list, hydrated for resume. When
// set, it replaces the intent-driven fresh chat.
const [loaded, setLoaded] = useState<LoadedConversation | null>(null)
const [loadingConversation, setLoadingConversation] = useState(false)
// Starts true on a restore so the first frame is the spinner, not a fresh
// chat on the intent: a fresh tool-loop chat fires its first turn on mount
// and would create a stray conversation before the restore replaced it.
const [loadingConversation, setLoadingConversation] = useState(!!resumeConversationId)
const [loadError, setLoadError] = useState<string | null>(null)
// Enlarge the panel IN PLACE (no navigation): the user stays on the current
// page (e.g. /bookkeeping) with a wider reading/verifying surface. Transient
@@ -468,15 +479,9 @@ export default function AgentSheet({
}
}
// Resume a past conversation inline: fetch its messages, hydrate, and swap the
// sheet back to the chat view. Picking the one already open just closes the
// list (keeps its live in-memory state instead of re-hydrating it).
async function handleSelectConversation(id: string) {
if (id === activeConversationId) {
setView('chat')
return
}
setView('chat')
// Open a past conversation inline: fetch its messages and hydrate. Shared by
// the session list and the reload restore below.
const loadConversation = useCallback(async (id: string) => {
setLoaded(null)
setLoadingConversation(true)
setLoadError(null)
@@ -514,10 +519,43 @@ export default function AgentSheet({
})
setConversationId(data.conversation.id)
} catch {
if (seq === selectSeqRef.current) setLoadError('Kunde inte öppna konversationen.')
if (seq === selectSeqRef.current) {
setLoadError('Kunde inte öppna konversationen.')
// A thread that no longer opens must not be retried on every reload.
clearAgentSheetSession()
}
} finally {
if (seq === selectSeqRef.current) setLoadingConversation(false)
}
}, [])
// Reload restore: the provider remounts the sheet on the thread this tab
// had open, and it is loaded here the same way a picked one is.
useEffect(() => {
if (resumeConversationId) void loadConversation(resumeConversationId)
}, [resumeConversationId, loadConversation])
// Remember the open thread for this tab so a reload brings it back (see
// lib/agent-panel/session-restore). Nothing to remember until the thread
// has an id: a fresh chat that never sent anything simply does not return.
const activeIntentId = loaded?.intentId ?? intentId
useEffect(() => {
if (!activeConversationId) return
writeAgentSheetSession({
conversationId: activeConversationId,
intentId: activeIntentId,
contextRef: activeContextRef,
collapsed,
})
}, [activeConversationId, activeIntentId, activeContextRef, collapsed])
// Resume a past conversation from the list and swap the sheet back to the
// chat view. Picking the one already open just closes the list (keeps its
// live in-memory state instead of re-hydrating it).
function handleSelectConversation(id: string) {
setView('chat')
if (id === activeConversationId) return
void loadConversation(id)
}
return (
+43 -1
View File
@@ -18,6 +18,10 @@ import {
type ResolvedAgentPanelPrefs,
} from '@/lib/agent-panel/geometry'
import { persistUiState } from '@/lib/ui-state/client'
import {
clearAgentSheetSession,
readAgentSheetSession,
} from '@/lib/agent-panel/session-restore'
import {
INITIAL_AGENT_STATUS,
reduceAgentStatus,
@@ -143,6 +147,11 @@ export interface OpenAgentSheetArgs {
// promptTemplate and sends this verbatim instead. Used by /chat empty-state
// suggestion chips to give the user a one-click starting prompt.
seedUserMessage?: string
// Reopen an existing thread instead of starting one. Set by the provider
// itself when it restores the panel after a page reload (see
// lib/agent-panel/session-restore); the sheet loads the thread on mount
// exactly as if it had been picked from "Tidigare konversationer".
resumeConversationId?: string
}
interface AgentSheetContextValue {
@@ -277,6 +286,28 @@ export function AgentSheetProvider({
}
}, [dockWidth])
// Bring back the thread this tab had open before a full page reload. The
// panel's state is React-only, so the deploy prompt's "Ladda om" (or any
// refresh) used to close it and drop the thread from view; the user then
// had to find it again under "Tidigare konversationer". The sheet records
// the open thread per tab (sessionStorage), and this reopens it on mount.
// Only on mount: client-side navigation keeps the provider, and a session
// the user closed has already been cleared from storage.
useEffect(() => {
const stored = readAgentSheetSession()
if (!stored) return
// sessionStorage is client-only: reading it in the state initializer would
// render the panel on the client but not on the server (hydration
// mismatch), so it has to be an effect that sets state once.
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveArgs({
intentId: stored.intentId,
contextRef: stored.contextRef ?? undefined,
resumeConversationId: stored.conversationId,
})
setCollapsed(stored.collapsed)
}, [])
const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => {
setActiveArgs(args)
setCollapsed(false)
@@ -286,12 +317,22 @@ export function AgentSheetProvider({
setActiveArgs(null)
setCollapsed(false)
setDockWidth(null)
// Close ends the session: a reload after this must not bring it back.
clearAgentSheetSession()
publishAgentStatus({ type: 'reset' })
}, [])
const collapseAgentSheet = useCallback(() => setCollapsed(true), [])
const expandAgentSheet = useCallback(() => setCollapsed(false), [])
const restartAgentSheet = useCallback(() => {
// Discarding the thread: forget it for reload purposes too, so the old
// one does not reappear while the new one has no id yet, and drop any
// restore id from the args, or the remount would reopen that thread
// instead of starting a fresh one.
clearAgentSheetSession()
setActiveArgs((args) =>
args?.resumeConversationId ? { ...args, resumeConversationId: undefined } : args,
)
setRestartNonce((n) => n + 1)
setCollapsed(false)
}, [])
@@ -337,11 +378,12 @@ export function AgentSheetProvider({
{children}
{activeArgs && (
<AgentSheet
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${stableArgsKey(activeArgs.intentArgs)}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${stableArgsKey(activeArgs.intentArgs)}:${activeArgs.seedUserMessage ?? ''}:${activeArgs.resumeConversationId ?? ''}:${restartNonce}`}
intentId={activeArgs.intentId}
intentArgs={activeArgs.intentArgs}
contextRef={activeArgs.contextRef}
seedUserMessage={activeArgs.seedUserMessage}
resumeConversationId={activeArgs.resumeConversationId}
collapsed={collapsed}
onStatus={publishAgentStatus}
onDockWidthChange={setDockWidth}
+7 -2
View File
@@ -53,8 +53,13 @@ export function DeployReloadPrompt() {
if (!stale) return null
return (
<div className="fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4">
<div className="flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 text-sm shadow-md">
// The wrapper spans the full width at the same z-index as the docked
// assistant panel and mounts after it, so without pointer-events-none it
// swallowed every click and tap in the strip behind it: the panel's
// composer sits exactly there, and "a new version is available" turned
// into "I can't type any more". Only the card itself takes input.
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 text-sm shadow-md">
<span className="text-foreground">{t('update_available')}</span>
<Button size="sm" onClick={() => window.location.reload()}>
{t('reload')}