Skip to content
Fusion

Carola — the conversation-first assistant

Carola is PulsN's AI assistant — and in PulsN the conversation is the home, not a separate destination. You land on /home ready to type to her; there is no dedicated chat app. (The old ChattN space is retired — /chattn now just redirects to /home.)

Naming: the assistant is Carola. PulsN is the app, Fusion is the stack underneath it. Keep them distinct.

Coming from React/Django? The chat itself is an ordinary streaming-chat UI. What's worth understanding is the server half — how a message turns into tool calls against your data — which is the proactive agent and AI machinery.

Where it lives

LayerFiles
Routessrc/routes/_authed.home.tsx (the landing) · _authed.c.$threadId.tsx (/c/$id) · _authed.project.$projectKey.c.$threadId.tsx (/project/$key/c/$id)
Surfacessrc/components/briefing/CarolaInline.tsx (the main pane) · src/components/CarolaRail.tsx (the companion rail)
Headerfusion-ui ConversationHeader · src/components/chat/ConversationHeaderBar.tsx (wired)
Endpointsrc/routes/api/agent/chat.ts (streams Carola + the tools)
Server functionssrc/lib/carola-threads-server.ts (load / save / list / rename / star / re-scope / delete)
Tablescarola_thread · chat_document + chat_document_attachment + chat_document_chunk

Scope is a lens, not a place

Carola is one continuous thread; scope decides which world she reads, and it's a lens you change in place — never its own page:

  • on /home she's portfolio/general — your own data and, on request, an overview across every building you can see;
  • under /project/$key she's scoped to that building — its tasks, protocols, news and handbook, gated by your membership.

A carola_thread is owner-private and carries that scope (+ scopeKey/scopeLabel for a project). Its id is a short base62 code — the URL you actually see (/c/$id, never a UUID). Opening a thread restores its exact scope, so the header follows the conversation; history persists ChatGPT-style in the sidebar.

Loading diagram...

The conversation header

Every open conversation gets its own slim header (fusion-ui ConversationHeader), distinct from the app header:

  • left — the chat title + a chevron menu: Rename (inline), Star, Add to project (re-home the thread under a building), Delete (with an inline confirm, no modal);
  • rightFiles (this chat's attachments) and pop-out (floating Carola).

Rename locks the title (title_edited) so the next auto-save doesn't re-derive it from the first message, and the sidebar entry updates with it.

Files live in the conversation

Attach files to a chat (the composer paperclip, or the Files panel) and they become per-conversation knowledge — owner-private, Claude-style; not a project-wide repository (WikiN is that). One file ↔ one chat through the chat_document_attachment join, so the file rides that conversation and survives the send. The composer stays clean — added files land in the Files panel, not as chips.

A background worker (kreuzberg) extracts → chunks → embeds (pgvector); Carola's search_attached_documents tool retrieves the exact passages and cites the file + page.

Three postures

Carola is the same live thread in three places:

  1. Inline — the conversation pane on /home and /c/$id (CarolaInline).
  2. Companion — the docked right rail on other pages (CarolaRail), an evolved DetailPanel that also renders conversational maps and the Files panel.
  3. Floating — popped into a real, always-on-top OS window via Document Picture-in-Picture (PopOutButtonsrc/lib/picture-in-picture.tsx). The live subtree is relocated with createPortal (same thread, same in-flight stream), never copied.

Floating Carola is the one deliberate exception to the inline-only rule (no in-page overlays / modals / popovers / floating widgets): it's a separate OS window, not an in-page widget. It's Chromium-only progressive enhancement, gated by features.floatingCarola so classified deployments can switch it off. While floating, Files opens as a file-list view inside that window rather than the main-window rail.

How it works

A message isn't just LLM text — Carola acts on your data through owner-scoped tools the proactive agent exposes: list_recent_findings, list_upcoming_deadlines, search_agent_memory, remember, run_agent_check_now, search_project_wiki (WikiN), search_project_news (NyhetN), show_project_map, and search_attached_documents. Everything is permission-scoped to the signed-in user; the project tools only reach buildings you're a member of.

The LLM provider is pluggable — local Ollama or a hosted model — and degrades gracefully to a deterministic demo model when none is configured, so the chat always works in the example. See AI for providers and the proactive agent for the tool-calling harness.

example/src/lib/carola-threads-server.ts
/** Rename a thread (locks `title_edited` so saves stop re-deriving it from the first message). */
export const updateCarolaThreadTitle = createServerFn({ method: "POST" })
	.inputValidator(z.object({ id: z.string().min(1), title: z.string().min(1).max(160) }).parse)
	.handler(async ({ data }) => {
		const session = await requireSession();
		await db
			.update(carolaThread)
			.set({ title: data.title.trim().slice(0, 160), titleEdited: true, updatedAt: new Date() })
			.where(and(eq(carolaThread.id, data.id), eq(carolaThread.ownerId, session.user.id)));
		await reindexEntity("conversation", data.id).catch(() => {});
		return { ok: true };
	});

SSR gotcha: the chat uses fusion-ui's <Chat> (TanStack AI's useChat), which is client-only — rendering it during SSR throws a duplicate-React useId error. It's gated behind a mounted flag, like other browser-only UI (SSR safety).

Find vs. ask

The header search bar (Universal search) sits next to Carola with three verbs that never blur: Ask hands the query to Carola, Find returns ranked results across everything you can see, and Scope re-lenses the conversation onto a building.

Extending it

  • A new tool (let Carola query a new app) → register it in the agent harness; see the proactive agent and the AI sandbox.
  • A new posture or header action → the surfaces (CarolaInline / CarolaRail) share the ConversationHeader; wire the action to a carola-threads-server.ts server function and refresh the sidebar.
  • A different model → swap the fusion-ai provider via env; no app code changes.