fix(assistant): stop the chat loading in stages (#1210)
* fix(assistant): stop the chat loading in stages PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7). No redesign; this is the "it loads in different stages" complaint, traced to four separate staging points and one dead link. Resumed conversations rendered a column of EMPTY bordered cards until the markdown chunk arrived, then filled in all at once and reflowed the thread. The chunk was deferred with a null fallback, which is invisible while a reply streams (nobody reads that fast) but very visible on hydrate, where every assistant bubble is already text. The chunk is now prefetched as soon as any chat surface mounts, and until it resolves the raw text renders instead of nothing, so a bubble is never blank. Clicking the assistant launcher showed NOTHING until the sheet chunk loaded: the dynamic import had no loading state at all. It now renders a skeleton in the same geometry, and the chunk is warmed on idle so the click usually hits an already-loaded module. /chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed as a 48px rail, so every load snapped one to the other. The skeleton now matches what actually mounts, per breakpoint. The first turn read agent_profiles twice: once in the route to build the intent's prompt template, once again in run-turn for the system prompt. The route now hands its result over. Ranked memory is deliberately NOT shared: the two queries differ (the route's selects fewer columns and orders without is_pinned, and run-turn needs ids to stamp last_accessed_at), so reusing it would silently change both the prompt and memory touch. Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new reads ?prompt=, so the typed question was silently dropped and the user landed on an empty state. Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): make the prefetches fail-safe and bounded Review follow-ups on the staged-loading batch. A rejected markdown import left the cached promise permanently rejected, so every bubble for the rest of the session stayed on the plain-text fallback and the rejection went unhandled. The cache is now cleared on failure so a later surface retries, and the rejection is swallowed. requestIdleCallback can defer indefinitely on a page that never goes idle; the 2s fallback only applied where the API is missing. The idle request now carries a 2s timeout, and the warm import cannot produce an unhandled rejection either. Adds the first-turn test for the profile-summary handover: it asserts the value read for the prompt template is what reaches the turn, so a regression that re-introduces the second read (or drops the template) fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee8ddb3849
commit
f0f3050f54
@@ -25,7 +25,15 @@ export default function DashboardLoading() {
|
||||
if (pathname.startsWith('/chat')) {
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<aside className="flex w-full flex-col border-r border-border bg-card/40 md:w-80 shrink-0">
|
||||
{/* Desktop mounts ChatSidebar COLLAPSED (a 48px rail), so the skeleton
|
||||
must be a rail too: a 320px skeleton that snapped to 48px on hydrate
|
||||
was a visible layout jump on every /chat load. Mobile mounts the
|
||||
full-width list, so that shape stays there. */}
|
||||
<aside className="hidden md:flex md:w-12 shrink-0 flex-col items-center border-r border-border bg-card/40 py-3 gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
</aside>
|
||||
<aside className="flex w-full flex-col border-r border-border bg-card/40 md:hidden shrink-0">
|
||||
<div className="space-y-3 border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
|
||||
|
||||
@@ -221,4 +221,40 @@ describe('POST /api/agent/invoke', () => {
|
||||
await res.text()
|
||||
expect(runChatTurnMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hands the first-turn profile summary to the turn instead of re-reading it', async () => {
|
||||
// On a first turn the route reads agent_profiles to build the intent's
|
||||
// prompt template. run-turn needs the same value for the system prompt, so
|
||||
// it is passed through rather than read a second time.
|
||||
getIntentMock.mockReturnValue({
|
||||
id: 'general.help',
|
||||
sheetTitle: 'Assistenten',
|
||||
capture: vi.fn().mockResolvedValue({ some: 'context' }),
|
||||
promptTemplate: vi.fn().mockReturnValue('templated first turn'),
|
||||
})
|
||||
|
||||
enqueuePreamble()
|
||||
enqueue({ data: { id: CONVERSATION_ID } }) // conversation insert
|
||||
enqueueAcceptedTail()
|
||||
enqueue({ data: { profile_summary: 'Byggkonsult, K2' } }) // agent_profiles
|
||||
enqueue({ data: [] }) // agent_memory
|
||||
|
||||
const res = await POST(
|
||||
createMockRequest('/api/agent/invoke', {
|
||||
method: 'POST',
|
||||
body: { intent_id: 'general.help' }, // no user_message => first turn
|
||||
}),
|
||||
)
|
||||
await res.text()
|
||||
|
||||
expect(runChatTurnMock).toHaveBeenCalledTimes(1)
|
||||
const args = runChatTurnMock.mock.calls[0]![0] as {
|
||||
preloadedProfileSummary?: string | null
|
||||
userMessage: string
|
||||
userMessageHidden?: boolean
|
||||
}
|
||||
expect(args.preloadedProfileSummary).toBe('Byggkonsult, K2')
|
||||
expect(args.userMessage).toBe('templated first turn')
|
||||
expect(args.userMessageHidden).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,6 +219,9 @@ export async function POST(request: Request) {
|
||||
// client can also explicitly request a hidden turn (rejection correction)
|
||||
// even when it DID supply a user_message.
|
||||
let userMessageHidden = body.user_message_hidden === true
|
||||
// Set on a first turn, where the prompt template needs it anyway; handed to
|
||||
// runChatTurn so the system prompt reuses it instead of re-reading.
|
||||
let preloadedProfileSummary: string | null | undefined
|
||||
if (!effectiveUserMessage) {
|
||||
try {
|
||||
const captured = await intent.capture(body.intent_args ?? {}, {
|
||||
@@ -226,13 +229,17 @@ export async function POST(request: Request) {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
const profileSummary = await loadProfileSummary(supabase, companyId)
|
||||
const memory = await loadRankedMemory(supabase, companyId, 30)
|
||||
const [profileSummary, memory] = await Promise.all([
|
||||
loadProfileSummary(supabase, companyId),
|
||||
loadRankedMemory(supabase, companyId, 30),
|
||||
])
|
||||
effectiveUserMessage = intent.promptTemplate({
|
||||
captured,
|
||||
profileSummary,
|
||||
activeMemory: memory,
|
||||
})
|
||||
// Hand it to the turn so it doesn't re-read it for the system prompt.
|
||||
preloadedProfileSummary = profileSummary
|
||||
userMessageHidden = true
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
@@ -279,6 +286,7 @@ export async function POST(request: Request) {
|
||||
userMessage: effectiveUserMessage,
|
||||
userMessageHidden,
|
||||
persist: true,
|
||||
preloadedProfileSummary,
|
||||
emit: (event) => emit(event),
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -20,16 +20,63 @@ import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import ApprovalCard from './ApprovalCard'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
// Markdown parser loads on the first assistant message instead of with the
|
||||
// chat surface itself; react-markdown + remark-gfm pull in the whole
|
||||
// unified/remark tree. The chunk starts fetching as soon as a reply begins
|
||||
// rendering, well before a human can read it, so a null fallback is invisible
|
||||
// in practice.
|
||||
// Markdown parser loads separately from the chat surface: react-markdown +
|
||||
// remark-gfm pull in the whole unified/remark tree.
|
||||
//
|
||||
// It used to render `null` while the chunk loaded. That is invisible while a
|
||||
// reply streams (nobody reads that fast) but very visible on RESUME: every
|
||||
// assistant bubble in a hydrated conversation was an empty bordered card until
|
||||
// the chunk landed, then all the text appeared at once and reflowed the thread.
|
||||
// Two changes: the chunk is prefetched as soon as any chat surface mounts, and
|
||||
// until it resolves the raw text renders in place of nothing, so a bubble is
|
||||
// never blank.
|
||||
const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
})
|
||||
|
||||
// Module-scoped so the chunk is fetched once per page load and every later
|
||||
// chat surface (sheet, /chat, a resumed conversation) renders markdown on its
|
||||
// first frame instead of falling back to plain text again.
|
||||
let markdownReady = false
|
||||
let markdownPromise: Promise<unknown> | null = null
|
||||
|
||||
/** Start the markdown chunk before anything needs to render with it. */
|
||||
function prefetchMarkdown(): Promise<unknown> {
|
||||
if (!markdownPromise) {
|
||||
markdownPromise = import('./MarkdownMessage')
|
||||
.then((mod) => {
|
||||
markdownReady = true
|
||||
return mod
|
||||
})
|
||||
.catch(() => {
|
||||
// A chunk can 404 after a deploy, or the network can blip. Clear the
|
||||
// cached promise so a later surface retries, instead of every bubble
|
||||
// for the rest of the session being stuck on the plain-text fallback,
|
||||
// and swallow the rejection so it is not an unhandled one.
|
||||
markdownPromise = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return markdownPromise
|
||||
}
|
||||
|
||||
/** True once the markdown chunk is usable; triggers the fetch if it isn't. */
|
||||
function useMarkdownReady(): boolean {
|
||||
const [ready, setReady] = useState(markdownReady)
|
||||
useEffect(() => {
|
||||
if (ready) return
|
||||
let alive = true
|
||||
void prefetchMarkdown().then(() => {
|
||||
if (alive) setReady(true)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [ready])
|
||||
return ready
|
||||
}
|
||||
|
||||
// Reusable chat surface: used both inside the right-hand AgentSheet and on
|
||||
// the full-page /chat route. Owns:
|
||||
// * Message state (rendered list)
|
||||
@@ -676,6 +723,7 @@ function MessageBubble({
|
||||
// suppress the empty cursor bubble underneath it.
|
||||
const isThinking = !isUser && streamingTail && !message.text && !!message.reasoning
|
||||
const hideEmptyBubble = (!isUser && !message.text && !streamingTail) || isThinking
|
||||
const markdownLoaded = useMarkdownReady()
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}>
|
||||
{!isUser && message.reasoning && (
|
||||
@@ -694,7 +742,14 @@ function MessageBubble({
|
||||
message.text || (streamingTail ? <Cursor /> : '')
|
||||
) : message.text ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
|
||||
<MarkdownMessage text={message.text} />
|
||||
{markdownLoaded ? (
|
||||
<MarkdownMessage text={message.text} />
|
||||
) : (
|
||||
// One frame at most, and only before the chunk resolves. Plain
|
||||
// text keeps a resumed thread readable instead of showing a
|
||||
// column of empty cards.
|
||||
<p className="whitespace-pre-wrap">{message.text}</p>
|
||||
)}
|
||||
</div>
|
||||
) : streamingTail ? (
|
||||
<Cursor />
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const AgentSheet = dynamic(() => import('./AgentSheet'))
|
||||
// The sheet is a lazy chunk, and it used to have no loading state at all: a
|
||||
// click on the launcher produced NOTHING until the chunk arrived, then the
|
||||
// whole panel appeared at once. Two fixes: a skeleton in the same geometry so
|
||||
// the surface is there on the first frame, and a prefetch once the page is
|
||||
// idle so the chunk is usually already loaded before anyone clicks.
|
||||
const AgentSheet = dynamic(() => import('./AgentSheet'), {
|
||||
loading: () => <AgentSheetSkeleton />,
|
||||
})
|
||||
|
||||
function AgentSheetSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-lg"
|
||||
style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-4">
|
||||
<div className="h-8 w-8 shrink-0 animate-pulse rounded-full bg-secondary" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Warm the sheet chunk when the browser is idle, never on the critical path. */
|
||||
function useSheetPrefetch() {
|
||||
useEffect(() => {
|
||||
const warm = () => {
|
||||
// Swallow a failed prefetch: the real import on click will surface any
|
||||
// genuine problem, and warming must never produce an unhandled rejection.
|
||||
void import('./AgentSheet').catch(() => {})
|
||||
}
|
||||
const w = window as Window & {
|
||||
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number
|
||||
cancelIdleCallback?: (id: number) => void
|
||||
}
|
||||
if (typeof w.requestIdleCallback === 'function') {
|
||||
// A busy page can stay non-idle indefinitely, so cap the wait: the point
|
||||
// is to have the chunk ready before the first click, not to hold out for
|
||||
// a quiet moment that may never arrive.
|
||||
const id = w.requestIdleCallback(warm, { timeout: 2000 })
|
||||
return () => w.cancelIdleCallback?.(id)
|
||||
}
|
||||
const t = setTimeout(warm, 2000)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
}
|
||||
|
||||
export interface AgentIdentity {
|
||||
displayName: string | null
|
||||
@@ -69,6 +115,7 @@ interface AgentSheetProviderProps {
|
||||
}
|
||||
|
||||
export function AgentSheetProvider({ children, identity }: AgentSheetProviderProps) {
|
||||
useSheetPrefetch()
|
||||
const [activeArgs, setActiveArgs] = useState<OpenAgentSheetArgs | null>(null)
|
||||
// Collapsed = session alive but hidden. Kept separate from activeArgs so
|
||||
// collapsing never unmounts AgentChat (which would wipe the conversation).
|
||||
|
||||
@@ -141,14 +141,14 @@ export default function CommandPalette({ initialOpen = false }: { initialOpen?:
|
||||
id: 'anna-fallback',
|
||||
label: `Fråga Anna: "${query.trim()}"`,
|
||||
icon: Wand2,
|
||||
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
|
||||
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
|
||||
}
|
||||
: q
|
||||
? {
|
||||
id: 'anna-followup',
|
||||
label: `Fråga Anna istället: "${query.trim()}"`,
|
||||
icon: Wand2,
|
||||
href: `/chat?prompt=${encodeURIComponent(query.trim())}`,
|
||||
href: `/chat/new?prompt=${encodeURIComponent(query.trim())}`,
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ interface RunTurnArgs {
|
||||
// still persisted for Anthropic context on subsequent turns, but flagged
|
||||
// hidden=true so /chat/[id] hydration doesn't surface it as a user bubble.
|
||||
userMessageHidden?: boolean
|
||||
// Profile summary the caller already loaded for this turn (the invoke route
|
||||
// reads it to build a first-turn prompt template). Passed through so the same
|
||||
// read doesn't happen twice per turn.
|
||||
//
|
||||
// Ranked memory is deliberately NOT shared: the route's variant selects fewer
|
||||
// columns and orders without is_pinned, and this one needs ids to stamp
|
||||
// last_accessed_at. Reusing it there would silently change both the prompt
|
||||
// and memory touch.
|
||||
preloadedProfileSummary?: string | null
|
||||
// Emit events back to the caller. Returns false if the stream was cancelled
|
||||
// and the loop should stop emitting (best-effort).
|
||||
emit: (event: StreamEvent) => boolean
|
||||
@@ -173,8 +182,14 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
} = args
|
||||
|
||||
// 1 + 2: load profile + ranked memory + atoms + tools.
|
||||
//
|
||||
// On a first turn the caller already read the profile summary to build the
|
||||
// intent's prompt template, so it hands it over rather than making the same
|
||||
// round trip again for the system prompt.
|
||||
const [profile, memory, vatStatus] = await Promise.all([
|
||||
loadProfileSummary(supabase, companyId),
|
||||
args.preloadedProfileSummary !== undefined
|
||||
? Promise.resolve(args.preloadedProfileSummary)
|
||||
: loadProfileSummary(supabase, companyId),
|
||||
loadRankedMemory(supabase, companyId, 30),
|
||||
loadVatStatus(supabase, companyId),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user