Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/architecture/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Interface strings are `dictation_<surface>_<purpose>`, and a server code is `dic
- Replies, the trace, and Studio's Markdown documents and podcast transcripts render through one Streamdown setup ([`streamdown-config.tsx`](../../surfsense_local/frontend/src/features/studio/viewers/streamdown-config.tsx)) whose options keep their identity between renders, and whose links open the app's link popup, translated, rather than Streamdown's own ([egress](egress.md#not-egress)), so a token re-parses only the block it changed and a page re-render around a finished reply or an open artifact parses nothing. The trace repairs an unclosed marker at its tail only, as the answer does, not across the whole trace at every token, so a marker left open in an earlier paragraph stays as written instead of closing at the trace's end. The live trace scrolls to its newest line at most once a frame, and only while open; the follow's own scroll, reported a frame later, never counts as the reader scrolling up. Code stays plain monospace while its reply or trace streams and takes its colours when that ends: highlighting each partial state of a growing block cost the main thread seconds and kept every state's tokens until reload, a 4,000-character block streamed 4 characters at a time making 995 highlight calls and holding 80 MB after garbage collection. The highlighter ([`code-highlighter.ts`](../../surfsense_local/frontend/src/features/studio/viewers/code-highlighter.ts)) is @streamdown/code's, Shiki with the same themes, languages and tokens, keeping those of the last 100 blocks. It is built from Shiki's fine-grained bundle: every language Shiki bundles, but only the github-light and github-dark themes and the JavaScript regex engine, so the SPA ships none of Shiki's 63 other themes or its Oniguruma wasm. Streamdown asks for the plugin's `getThemes()` and assistant-ui's StreamdownTextPrimitive, which renders replies, for its own default; both are github-light and github-dark. Nothing in the app passes `shikiTheme` (a lint rule refuses it), and a test keeps the two equal, so no other theme is ever asked for. A block it has not tokenized yet comes back as plain code at once, never as nothing: given nothing, Streamdown keeps its last result, which for a block whose text just changed is older text.
- Images use assistant-ui's own attachments ([`image-attachments.ts`](../../surfsense_local/frontend/src/features/chat/image-attachments.ts)). The runtime gets `SimpleImageAttachmentAdapter`, narrowed to the accepted formats, only while the selection's `reads_images` is true, so a model that cannot see has no adapter and the composer takes no image. The composer's "+" opens a menu ([`composer-add-menu.tsx`](../../surfsense_local/frontend/src/features/chat/composer-add-menu.tsx)) with separate "Attach images" and "Upload sources" items, plus the Thinking switch, so a file is never a guess between the two, and dropping a file onto the composer does nothing. "Upload sources" opens the same picker and upload as the sources panel's Add, and is disabled while an upload runs. Images come in two ways: pasting into the composer (`Input`'s `addAttachmentOnPaste`), and "Attach images", which is assistant-ui's `ComposerPrimitive.AddAttachment` and opens a picker limited to the adapter's formats. With a model that cannot see, the item stays in the menu, disabled but still hoverable, and its tooltip says "This model can’t read images". An Agentic chat, new or open, takes no image either, since the agent refuses a turn that carries one: attach and paste are off and the tooltip says "Agentic mode doesn’t read images yet". Images attached before a new chat was switched to Agentic stay in view to be removed, and sending them opens nothing: the question and images go back into the composer with a toast. Each row's tooltip is also in the row as screen-reader text, since Base UI tooltips are visual only ([`menu-item-hint.tsx`](../../surfsense_local/frontend/src/features/chat/menu-item-hint.tsx)); `ComposerPrimitive.Attachments` draws the pending ones with `AttachmentPrimitive` and `MessagePrimitive.Attachments` the sent ones ([`attached-image.tsx`](../../surfsense_local/frontend/src/features/chat/attached-image.tsx)). Until a turn is stored its images show from what was picked; after, from the image route. Retry resends a failed turn's images, read back from the image route once the window no longer holds them.
- Sending is disabled without a usable model or while threads or messages load, and in a thread whose reply is still running. Stop calls `run/stop`, in either engine. A `409` "no chat model selected" opens model setup. A reply's error and stopped mark come from its stored `ending`, and from live frames only while its run is followed, so both survive a thread switch and a reload: auth, not-found and model-cannot-run errors, and network errors from a remote endpoint, offer Model setup; a network error from the local runtime, a context-too-long error and a used-up plan offer nothing, since no button fixes them; the rest, and an interrupted reply ("Interrupted when the app closed."), offer Retry, on the latest reply only, which sends the question again with `retry_of`.
- Every turn sends the sources panel's ticks as `source_scope`, so a ticked folder counts whole however many sources it holds, and beside it the ids of the ready sources those ticks include, which the server ignores when a scope comes with them; past the API's 1,000-id cap only the scope goes. Unticking a source takes it out of retrieval, and unticking all of them leaves the model with no context.
- Every turn sends the sources panel's ticks as `source_scope`, so a ticked folder counts whole however many sources it holds, and beside it the ids of the ready sources those ticks include, which the server ignores when a scope comes with them; past the API's 1,000-id cap only the scope goes. Unticking a source takes it out of retrieval, and unticking all of them leaves the model with no context. The ticks belong to the thread ([`use-thread-scope.ts`](../../surfsense_local/frontend/src/features/chat/use-thread-scope.ts)): opening one puts its stored `source_scope` on screen, every source for a thread that never stored any, and a tick made in it between turns goes to `PUT /chat/threads/{id}/source-scope` at once, one store after another per thread, so the thread shows it again when opened next; a store that fails is said in a toast, and the next turn sends the ticks anyway. A tick made while the thread's reply runs is held outside the page, like the run itself, and stored once the reply ends even if the page has been left since, since the agent's tools read the stored scope on every call and a turn keeps the ticks it started with ([agent](agent.md#the-ticked-sources)); the next turn sends the ticks anyway. A tick made while the thread's entry is still on its way, or while the thread is being opened for a new chat, is the user's for that thread: it stays and is stored once the thread is there. A new chat starts from every source and keeps its ticks in `localStorage` (`surfsense:draft-scope:<workspace>:v1`) until it is sent, so they survive a reload; the send opens the thread with them (`POST .../chat/threads` with `source_scope`, left out when every source is ticked), which spends the draft, so they outlive a first message that fails.
- An agent turn is a run like a chat reply: leaving its thread leaves it going, and it is followed again on return, marked unread when it ends elsewhere, and its stored ending is read back. Retry sends the question again without `retry_of`, which names a stored chat message. Its ids are opencode's strings, so a live turn counts as stored once both ids are in the refetched thread, whatever their type; only the placeholders sent before `accepted` never do. Each `agent-step` frame adds or updates one step of the live reply, and a stored reply's `content.steps` carries the same. They ride in `metadata.custom` beside the citations and show above the answer as one line each, with a spinner while it runs and a mark when it fails, opening to the step's output or error ([`features/agent/agent-steps.tsx`](../../surfsense_local/frontend/src/features/agent/agent-steps.tsx)). A `permission-request` waits in the runtime, kept per thread, until `permission-replied`, until it is answered or until the turn ends, so returning to a thread whose turn asked while another was open shows the request again. Each waiting request is asked on the step it holds back, inline in the reply, with what it will run and Deny and Allow once; Deny answers `reject`, and when others are waiting each ask says denying refuses them too, as opencode does ([agent](agent.md#approvals)).

## Citation panel
Expand Down Expand Up @@ -299,7 +299,6 @@ A note that fails to save reads as `interrupted`, which offers Retry rather than

## Known gaps

- The sources panel's ticks belong to the workspace for the session, not to a thread: opening a thread does not load its stored `source_scope`, and a new chat's draft is not kept across a reload. Each turn sends the ticks on screen, which the server then stores on that thread.
- The composer's and Studio's source counts come from the client's own listing, not from `POST /source-scope/resolve`, so they show ready sources only, never how many are still indexing or failed.
- A scope list past 50,000 ids is refused with `422`. The panel sends folders where it can, so only more than 50,000 sources ticked or unticked one by one reach it.
- The images `409` prices each image at Gemma 3's 256, so a dearer projector can still overflow unrefused: four Qwen2.5-VL images at the 1,024 px cap with five excerpts outgrow the 8,192 floor and fail as `context_too_long`.
Expand Down
37 changes: 37 additions & 0 deletions surfsense_local/frontend/src/features/chat/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export type ChatThread = {
title: string | null
// The agent answers this thread: it opened in Agentic mode, and keeps it.
uses_agent: boolean
// The ticks it stored; null is every source. Absent on a thread the client
// made itself before the list was read again.
source_scope?: SourceScope | null
created_at: string
updated_at: string
// A reply is being generated for it right now. Absent on a thread the
Expand Down Expand Up @@ -93,6 +96,26 @@ export type ChatMessage = {
completed_at: string | null
}

/** A scope as the server keeps it, pruned of sources that are gone, and what it holds now. */
export type ScopeRead = {
source_scope: SourceScope
counts: { ready: number; indexing: number; failed: number; removed: number }
}

/** Store the ticks made in a thread between turns, so opening it again shows them. */
export function storeThreadScope(
threadId: number,
scope: SourceScope,
signal?: AbortSignal
): Promise<ScopeRead> {
return requestJson<ScopeRead>(`/chat/threads/${threadId}/source-scope`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(scope),
signal,
})
}

export function listThreads(
workspaceId: number,
signal?: AbortSignal
Expand All @@ -102,18 +125,32 @@ export function listThreads(
})
}

/** Every source, which a thread opened without a scope uses anyway. */
const isEverySource = (scope: SourceScope) =>
scope.all &&
scope.folder_ids.length === 0 &&
scope.excluded_folder_ids.length === 0 &&
scope.document_ids.length === 0 &&
scope.excluded_document_ids.length === 0

export function createThread(
workspaceId: number,
title: string,
// Null leaves the API to the selected model's default.
choice: NewChatChoice | null,
// The new chat's ticks, stored with the thread so they outlive a first
// message that fails. Every source, or null, leaves the thread its default.
sourceScope: SourceScope | null,
signal?: AbortSignal
): Promise<ChatThread> {
return requestJson<ChatThread>(`/workspaces/${workspaceId}/chat/threads`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
...(sourceScope && !isEverySource(sourceScope)
? { source_scope: sourceScope }
: {}),
...(choice ? { mode: choice.mode } : {}),
}),
signal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from "./mentions/mention-text"
import { isClaudeCodeRefusal } from "./claude-code-refusal"
import { chatKeys } from "./query-keys"
import { clearDraftScope } from "./use-thread-scope"
import { useRefreshQueuedVersions } from "./use-refresh-queued-versions"
import type { LivePair } from "./runs/apply-frame"
import {
Expand Down Expand Up @@ -299,12 +300,14 @@ export function useChatRuntime({
mutationFn: ({
title,
mode,
scope,
signal,
}: {
title: string
mode: NewChatChoice | null
scope: SourceScope | null
signal: AbortSignal
}) => createThread(workspaceId, title, mode, signal),
}) => createThread(workspaceId, title, mode, scope, signal),
})
const deleteThreadMutation = useMutation({
mutationFn: (threadId: number) => deleteThread(threadId),
Expand Down Expand Up @@ -628,11 +631,16 @@ export function useChatRuntime({
const thread = await createThreadMutation.mutateAsync({
title: "New chat",
mode: newChatMode,
scope: sourceScope,
signal: new AbortController().signal,
})
if (requestVersion.current !== version) {
return
}
// The thread holds the ticks now, whatever its first message does. A
// tick made meanwhile waits for the thread (useThreadScope), and a
// newer chat has already returned above.
clearDraftScope(workspaceId)
threadId = thread.id
usesAgent = thread.uses_agent
queryClient.setQueryData<ChatThread[]>(
Expand Down
162 changes: 162 additions & 0 deletions surfsense_local/frontend/src/features/chat/use-thread-scope.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { act, renderHook } from "@testing-library/react"
import type { ReactNode } from "react"
import { afterEach, describe, expect, it, vi } from "vitest"

import type { SourceScope } from "@/features/sources/tree/scope-state"

import type { ChatThread } from "./api"
import type { NumberedEvent } from "./sse"
import { beginRun, pump, resetChatRuns } from "./runs/run-store"
import {
clearDraftScope,
readDraftScope,
useThreadScope,
} from "./use-thread-scope"

vi.mock("./api", () => ({ storeThreadScope: vi.fn() }))
vi.mock("sonner", () => ({
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() },
}))

const { storeThreadScope } = await import("./api")

const key = "surfsense:draft-scope:1:v1"
const draft: SourceScope = {
all: true,
folder_ids: [],
excluded_folder_ids: [2],
document_ids: [],
excluded_document_ids: [],
}
const without = (ids: number[]): SourceScope => ({
all: true,
folder_ids: [],
excluded_folder_ids: [],
document_ids: [],
excluded_document_ids: ids,
})

afterEach(() => {
localStorage.clear()
resetChatRuns()
vi.mocked(storeThreadScope).mockReset()
})

/** A reply that ends as soon as it is followed. */
async function* noFrames(): AsyncGenerator<NumberedEvent> {}

describe("a new chat's draft", () => {
it("reads only a whole draft back", () => {
expect(readDraftScope(1)).toBeNull()
localStorage.setItem(key, JSON.stringify(draft))
expect(readDraftScope(1)).toEqual(draft)
localStorage.setItem(key, JSON.stringify({ all: true, folder_ids: ["2"] }))
expect(readDraftScope(1)).toBeNull()
localStorage.setItem(key, "not json")
expect(readDraftScope(1)).toBeNull()
})

it("is spent once its thread is opened", () => {
localStorage.setItem(key, JSON.stringify(draft))
clearDraftScope(1)
expect(readDraftScope(1)).toBeNull()
})
})

describe("storing a thread's ticks", () => {
const thread: ChatThread = {
id: 10,
workspace_id: 1,
title: "Research only",
uses_agent: false,
source_scope: null,
created_at: "2026-09-05T00:00:00Z",
updated_at: "2026-09-05T00:00:00Z",
}
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
)

it("runs one store after another per thread, so an older one cannot land last", async () => {
const settle: Array<() => void> = []
vi.mocked(storeThreadScope).mockImplementation(
() =>
new Promise((resolve) => {
settle.push(() =>
resolve({
source_scope: draft,
counts: { ready: 0, indexing: 0, failed: 0, removed: 0 },
})
)
})
)
const setSourceScope = vi.fn()
const { rerender } = renderHook(
(props: { scope: SourceScope; edits: number }) =>
useThreadScope({
workspaceId: 1,
view: { status: "active", threadId: 10 },
activeThread: thread,
sourceScope: props.scope,
scopeEdits: props.edits,
setSourceScope,
}),
{ wrapper, initialProps: { scope: without([]), edits: 0 } }
)
// Opening the thread put its ticks on screen; a store is a microtask away.
expect(setSourceScope).toHaveBeenCalledTimes(1)
// Two ticks in a row: the second store waits for the first to settle.
rerender({ scope: without([20]), edits: 1 })
rerender({ scope: without([20, 21]), edits: 2 })
await act(async () => {})
expect(vi.mocked(storeThreadScope).mock.calls).toEqual([
[10, without([20])],
])
await act(async () => {
settle[0]?.()
})
expect(vi.mocked(storeThreadScope).mock.calls).toEqual([
[10, without([20])],
[10, without([20, 21])],
])
expect(setSourceScope).toHaveBeenCalledTimes(1)
// The thread's stores outlive the page: leave none pending behind.
await act(async () => {
settle[1]?.()
})
})

it("holds a tick made while the reply runs, and stores it once the reply ends even after the page is gone", async () => {
vi.mocked(storeThreadScope).mockResolvedValue({
source_scope: draft,
counts: { ready: 0, indexing: 0, failed: 0, removed: 0 },
})
beginRun(10, { workspaceId: 1 })
const { rerender, unmount } = renderHook(
(props: { scope: SourceScope; edits: number }) =>
useThreadScope({
workspaceId: 1,
view: { status: "active", threadId: 10 },
activeThread: thread,
sourceScope: props.scope,
scopeEdits: props.edits,
setSourceScope: vi.fn(),
}),
{ wrapper, initialProps: { scope: without([]), edits: 0 } }
)
rerender({ scope: without([20]), edits: 1 })
await act(async () => {})
expect(storeThreadScope).not.toHaveBeenCalled()
// The user leaves the page while the reply is still coming in.
unmount()
await act(async () => {
await pump(10, noFrames())
})
expect(vi.mocked(storeThreadScope).mock.calls).toEqual([
[10, without([20])],
])
})
})
Loading
Loading