diff --git a/app/api/agent/__tests__/invoke.test.ts b/app/api/agent/__tests__/invoke.test.ts
index a0bb1133..c106a802 100644
--- a/app/api/agent/__tests__/invoke.test.ts
+++ b/app/api/agent/__tests__/invoke.test.ts
@@ -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)
+ })
})
diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts
index 3732ff10..1627e0eb 100644
--- a/app/api/agent/invoke/route.ts
+++ b/app/api/agent/invoke/route.ts
@@ -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) {
diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx
index 0c82fa3d..0b54bc48 100644
--- a/components/agent/AgentChat.tsx
+++ b/components/agent/AgentChat.tsx
@@ -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
| null = null
+
+/** Start the markdown chunk before anything needs to render with it. */
+function prefetchMarkdown(): Promise {
+ 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 (
{!isUser && message.reasoning && (
@@ -694,7 +742,14 @@ function MessageBubble({
message.text || (streamingTail ?
: '')
) : message.text ? (
-
+ {markdownLoaded ? (
+
+ ) : (
+ // 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.
+
{message.text}
+ )}
) : streamingTail ? (
diff --git a/components/agent/AgentSheetProvider.tsx b/components/agent/AgentSheetProvider.tsx
index 88c35097..13c504bb 100644
--- a/components/agent/AgentSheetProvider.tsx
+++ b/components/agent/AgentSheetProvider.tsx
@@ -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: () =>
,
+})
+
+function AgentSheetSkeleton() {
+ return (
+
+ )
+}
+
+/** 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
(null)
// Collapsed = session alive but hidden. Kept separate from activeArgs so
// collapsing never unmounts AgentChat (which would wipe the conversation).
diff --git a/components/common/CommandPalette.tsx b/components/common/CommandPalette.tsx
index 45e8b516..5c2efa82 100644
--- a/components/common/CommandPalette.tsx
+++ b/components/common/CommandPalette.tsx
@@ -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
diff --git a/lib/agent/chat/run-turn.ts b/lib/agent/chat/run-turn.ts
index ded60b39..d045a39a 100644
--- a/lib/agent/chat/run-turn.ts
+++ b/lib/agent/chat/run-turn.ts
@@ -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 {
} = 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),
])