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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f75ea2384d
commit
3edbf0a2e3
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user