Addresses the PR #138 review's architecture note (the deferred 'non-blocking'
item). The brand-new -> persisted chat identity was spread across two separate
useState slots (threadKey + liveThreadChatId) plus a render-phase guard, so the
mount key and the live thread's chat id could in principle diverge.
Consolidate them into ONE atomic state object { key, chatId } with pure,
unit-tested transitions in thread-identity.ts:
- newThread(newKey) — a brand-new id-less chat (fresh session key);
- switchThread(chatId) — switch to an existing chat (key := chat id) -> remount;
- adoptThread(prev, id) — a new chat learns its real id IN PLACE (key unchanged
-> no remount, live useChat store preserved).
The 'key vs chatId diverged' state is now unrepresentable. The render-phase
reconciliation stays (the atom is also set externally, e.g. page-history's
history-item opening a referenced chat), but adoption vs switch is now explicit.
Behavior is unchanged; verified live: new-chat adopt + 2nd turn in the same row,
no mid-conversation remount, the two-tab race stays leak-free, switch-to-existing
remounts + reseeds, and reload restores full history. Adds thread-identity.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switched the comments ActionIcon to a Button component, using a text label instead of an icon. This improves clarity and aligns the header menu with the current design guidelines.
Addresses the PR #138 review.
Blocker 1 — duplicate chat row: a brand-new chat whose first turn errors BEFORE
the SSE 'start' chunk never receives the authoritative chatId, so metadata
adoption can't run; a retry then sent chatId:null and the server inserted a
SECOND chat row, orphaning the first turn. Keep metadata adoption as the primary
path (resolveAdoptedChatId) and add a bounded, unambiguous fallback: on a
new-chat finish with no server id, snapshot the known chat ids and, once the
list refetch lands, adopt the SINGLE newly-appeared id (pickNewlyCreatedChatId).
Zero or >1 new ids (e.g. two tabs racing) → no adoption — no items[0] guessing,
so #137 stays fixed. The wait-for-refetch guard compares set membership (robust
to a concurrent delete), and the diff dedupes so a repeated id from a paginated
list never reads as ambiguous.
Blocker 2 — tests: new adopt-chat-id.test.ts covers both pure helpers (adopt
decision + newly-created-id diff incl. dedupe/reorder); the server
messageMetadata callback is extracted to chatStreamStartMetadata and unit-tested
(start -> {chatId}, otherwise undefined).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Safari/WebKit dropped the AI chat answer stream mid-turn ("Load failed",
shown as "Lost connection to the server") while Chrome/Firefox were fine.
Two Safari-specific causes: (1) during model think/tool gaps the UI-message
SSE stream emits no bytes and WebKit aborts a non-progressing fetch far more
aggressively than Chrome; (2) the AI SDK sets a hop-by-hop `Connection:
keep-alive` header which is illegal on HTTP/2 — Chrome/Firefox ignore it,
Safari rejects the whole response. Earlier commits only improved the error
text, never the drop itself.
Add apps/server/src/core/ai-chat/sse-resilience.ts with two helpers wired into
both stream paths (authenticated + public share):
- startSseHeartbeat: writes a `: ping` SSE comment every 15s (ignored by the
client's EventSourceParserStream) so bytes keep flowing; unref'd timer,
guarded writes, auto-clear on finish/close.
- stripStreamingHopByHopHeaders: wraps writeHead once to drop Connection/
Keep-Alive before the head is sent, so they can never leak into an HTTP/2
response.
Add sse-resilience.spec.ts (7 tests). tsc + eslint clean.
Streaming (silence-cut) dictation was hardcoded on. Put it behind a per-workspace
flag settings.ai.dictationStreaming, default off, with batch dictation as the
default and fallback. Mirrors the existing settings.ai.dictation flag end to end:
- server: aiDictationStreaming on UpdateWorkspaceDto + workspace.service writes
settings.ai.dictationStreaming via updateAiSettings (jsonb merge keeps siblings)
- client: IWorkspaceAiSettings.dictationStreaming, an optimistic "Streaming
dictation" sub-toggle under "Voice dictation" (disabled when dictation is off)
- gate the MicButton streaming prop in the editor toolbar and chat composer on
the flag instead of a literal true
When the flag is absent/false both call sites pass streaming=false, so the VAD
model/wasm are never fetched and behavior is unchanged. Reuses the existing STT
model and /ai-chat/transcribe — no new provider/model/endpoint fields.
Removes the backlog entry now that it is implemented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A brand-new chat (activeChatId === null) had no way to learn the id of the row
the server created: the SSE stream never returned it, so the client adopted the
NEWEST chat in the per-user list (chats.items[0]). With two tabs open, a second
tab creating a chat at ~the same time made its row the newest, so the first tab
adopted the wrong id — its later turns persisted into the other chat and the
agent rebuilt history from it (commands leaked between chats), while the live UI
still showed the original conversation. (#137)
The server now attaches the authoritative chatId to the streamed assistant
message via the AI SDK messageMetadata on the 'start' part, so it reaches the
client on the first chunk. The client reads message.metadata.chatId in useChat's
onFinish and adopts that id in place (no remount, so the live turn and the
thread's chatIdRef follow the real id and the next turn targets the right chat).
The chats.items[0] guess and the adoptNewChat ref are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add the two new strings to en-US locale ('Go to login page', 'Move to
space') so they aren't missing from the base locale (review note 1).
- Avatar upload: accept any image/* MIME instead of a hardcoded png/jpeg/jpg
list, so webp/gif/etc. are no longer wrongly rejected client-side while
genuine non-images still surface the error (review note 2).
- Reindex polling: align the deadline-clearing effect with the refetchInterval
stop condition (indexed >= total, empty workspace included) so the deadline
clears promptly instead of waiting out the cap (review note 3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A turn that ends without a clean finish now shows a neutral marker, so an
interrupted answer is visible instead of trailing off silently. Errors keep
their existing red banner; this covers the aborted case.
- chat-stopped-notice.tsx: new neutral (gray) notice component
- chat-thread.tsx: live marker driven by useChat onFinish flags — distinguishes
a manual Stop (isAbort) from a dropped connection (isDisconnect); cleared when
the next turn streams; flushNext still runs only on a clean finish
- message-item.tsx: per-message marker in reopened history for finishReason
'aborted' with no error (combined wording, since the server can't tell a
manual Stop from a dropped connection)
- ai-chat.types.ts: add metadata.finishReason; rowToUiMessage now carries it
- en-US: three new strings
Frontend only — the server already persists partial work and finishReason and
replays it to the model on the next turn (continue, not restart).
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.
Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
reconnected; read the token via a ref, guard the decode (incl. missing exp),
and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
polls coverage (bounded) so the counter climbs live; misleading server comment
reworded.
UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
style the invalid-invitation page with a CTA; hide Share for read-only users;
align the dictation "not configured" message; "Go to login page" typo.
Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AI chat button used IconSparkles and the page comments button used
IconMessage, which read as visually similar speech bubbles. Replace the
AI icon with IconMessageCircleStar (chat bubble + star) and the comments
icon with IconMessages (overlapping bubbles) so the two are clearly
distinct.
- app-header.tsx: IconSparkles -> IconMessageCircleStar
- page-header-menu.tsx: IconMessage -> IconMessages
A provider error (e.g. read ECONNRESET) routed the turn through the
streamText onError callback, which persisted an EMPTY assistant record
(buildErrorAssistantRecord -> text:'', parts:[]). The answer text already
streamed to and shown by the client was therefore lost from the persisted
row, the chat export, and reopened history — leaving only the error line.
The AI SDK v6 onError callback receives only { error } (no steps/text),
and the visible final answer streams in the last, not-yet-finished step,
so it is absent from every finished step.text. Accumulate it ourselves:
onChunk folds each 'text-delta' into inProgressText; onStepFinish moves a
finished step into capturedSteps and resets inProgressText. onError and
onAbort now persist the partial answer (finished steps' text + tool parts
via assistantParts, then the in-progress text appended last) through a new
shared pure helper buildPartialAssistantRecord, recording the cause in
metadata.error on the error path. Replaces buildErrorAssistantRecord; its
empty-turn shape is preserved when nothing streamed.
Complementary to the resilient-fetch reconnect: that reduces how often a
turn dies; this preserves what was produced when it dies anyway.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outbound LLM calls used Node's default global undici agent (default
keep-alive pooling, no transport-level reconnect), so a TCP RST on a
reused/poisoned keep-alive socket surfaced as
"Cannot connect to API: read ECONNRESET" and failed the chat stream and
title generation after the AI SDK's own retries were exhausted.
Add a dedicated resilient outbound HTTP layer (ai-http.ts): a shared
undici RetryAgent over a tuned Agent, exposed as `aiFetch` and injected
into every AI provider factory (createOpenAI chat/embeddings/STT,
createGoogleGenerativeAI, createOllama) plus the raw JSON STT fetch. The
RetryAgent reconnects on connection-level errors (ECONNRESET, ...) on a
FRESH socket, opts POST into the retry methods (undici's default list
excludes POST), and leaves HTTP-status retries (429/5xx + Retry-After) to
the AI SDK to avoid double-retry.
- ai-http.ts: shared RetryAgent(Agent) + aiFetch (maxRetries 2,
conservative keep-alive, connect timeout, streaming-safe timeouts)
- ai.service.ts: inject fetch: aiFetch into every provider factory
- ai-http.spec.ts: regression test that aiFetch injects the RetryAgent
dispatcher into the underlying fetch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The error banner is a flex child of the chat panel column. Mantine's
Alert root is `overflow: hidden`, which (per the CSS flexbox spec) drops
its automatic min-height to 0, so when the message history fills the
panel the flexbox compressed the banner below its content height and the
overflow:hidden clipped the detail text (e.g. "Please try again.").
Set flex-shrink: 0 on the banner so it always shows its full content; the
scrollable message list absorbs the height pressure instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Copy chat" export read only persisted DB rows (messageRows), so an
assistant reply that was still streaming — and the user message that
triggered it — were absent from the export until the turn finished and
the messages query was refetched.
ChatThread now mirrors its live useChat snapshot ({ messages,
isStreaming }) into a parent-owned ref; the effect clears the ref on
unmount so a thread switch can't leak its tail into the next chat.
AiChatWindow.handleCopy computes the not-yet-persisted live tail
(messages whose id is absent from messageRows, only while streaming) and
passes it to buildChatMarkdown as `pending`. buildChatMarkdown appends
pending messages after the persisted rows (continuing the heading
numbering), flags the streaming assistant message with an
"still being generated" note, and reuses an extracted renderMessageParts
helper so persisted and pending rendering stay identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AI chat error Alert stranded the warning icon in the top-left while
the detail text hung indented under the heading, wrapping to 3 narrow
lines with empty space below. Switch to a "full-width detail" layout
(icon + bold heading on the first row, detail spanning full width below)
and extract the markup, previously duplicated in ChatThread and
MessageItem, into a single shared ChatErrorAlert component.
- add apps/client/src/features/ai-chat/components/chat-error-alert.tsx
- use it for the live stream error in chat-thread.tsx (mb="xs")
- use it for the persisted history error in message-item.tsx (mt={4})
- heading/icon use the adaptive --mantine-color-red-light-color so the
banner stays correct in dark mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fork's server-authoritative WS redesign rebroadcasts PAGE_UPDATED
(updateOne) title changes to the whole space room including the author.
The author's own client applied that echo to the ["pages", slugId] cache,
which feeds the title prop; the setContent effect then overwrote the
in-progress title with the lagging echo, dropping just-typed characters
and jumping the cursor.
Guard the setContent effect so it skips while the title editor is focused
(and when destroyed): external/echo title updates are applied only when
the field is not being actively edited. Page navigation is unaffected
because TitleEditor remounts per page (key={page.id}) and seeds content
at creation.
Previously a message composed while the AI agent was streaming a reply was
silently dropped (the composer early-returned on isStreaming). Now such
messages are queued FIFO and sent automatically once the current turn
finishes cleanly.
- chat-input: submit() enqueues while streaming (via new onQueue prop) and
sends otherwise; during streaming show a queue Send button (when text is
present) alongside the Stop button; the textarea stays usable.
- chat-thread: per-conversation queue in local state (mirrored in a ref);
flush the next message in onFinish ONLY on a clean finish - ai@6 useChat
fires onFinish from a finally on Stop/disconnect/error too, where the queue
must be preserved. Pending messages render as removable chips above the
composer. Queue is cleared on chat switch (parent remount) and survives
in-place new-chat id adoption.
- queue-helpers: pure FIFO helpers (enqueue/dequeue/removeQueuedById) + tests.
- i18n: add en-US/ru-RU keys (Queue message, Remove queued message,
Send when the agent finishes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The streaming mic button only began recording on the SECOND click. The VAD
library creates its AudioContext inside vad.start() and never resumes it; on the
first click the lazy model load (import + MicVAD.new) ran first, so the context
was created after the user-gesture window expired and started suspended — the
audio worklet never ran, so nothing happened. The second click was fast (model
cached) so the context landed inside the gesture and worked.
Create and resume our own AudioContext synchronously at the top of start()
(inside the click gesture, before the model load) and inject it into MicVAD,
which then does not take ownership of it; it is reused across start/stop and
closed only on unmount. Add a "loading" status so the first click is shown as a
spinner (disabled) while the model loads, which also blocks a confusing second
click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "copy chat" button serialized `messageRows` (persisted rows loaded via
`useAiChatMessagesQuery`), which were incomplete in two ways, so the exported
Markdown dropped messages (e.g. "Messages: 2" for a multi-turn chat).
- Exhaust pagination: `useAiChatMessagesQuery` is a useInfiniteQuery that only
ever loaded the first page (server page size 50, oldest-first), silently
truncating longer chats. Add an effect that calls `fetchNextPage()` until
`hasNextPage` is false. Guard on `isFetchNextPageError` so a failed page fetch
does not loop on the app's global `retry: false`.
- Re-sync after each turn: `onTurnFinished` invalidated only the chat-list
query, never the per-chat messages query, so `messageRows` went stale during
a live session. Also invalidate `AI_CHAT_MESSAGES_RQ_KEY(activeChatId)` so the
export and token counters reflect the just-finished turn.
- Avoid tearing down the live thread: a render-phase latch (`historyLoadedKeyRef`)
keeps the history loader gating the FIRST mount only, so the post-turn
background refetch (which can transiently flip `hasNextPage` for a chat whose
message count is an exact multiple of the page size) no longer unmounts the
open thread and loses its in-progress useChat state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createPage always failed with "generateJSON can only be used in a Node
environment". Root cause: the MCP module (packages/mcp/.../collaboration.ts)
sets `global.window = dom.window` (jsdom) at load time and is imported
in-process by the server's AI-chat tools, leaking a global `window` into the
Node process. The server's self-contained ProseMirror helpers guarded with
`if (typeof window !== 'undefined') throw`, which then became a false positive
and broke POST /pages/import (the endpoint createPage calls).
- server: drop the vestigial `typeof window` guard in generateJSON.ts and
generateHTML.ts; both helpers create their own happy-dom Window and never
read the global one. Replace it with an explanatory comment.
- mcp: in DocmostClient.getPage, pass the resolved UUID (resultData.id) to
listSidebarPages instead of the original pageId, which may be a slugId and
triggered a Postgres "invalid input syntax for type uuid" (and a silent
empty subpages list).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Streaming dictation sends one transcription request per ended speech segment.
With redemptionMs=640 the VAD cut on every ~0.64s gap, so normal halting speech
fragmented into many segments and flooded /ai-chat/transcribe — tripping the
per-user rate limit even at modest real usage.
Raise redemptionMs to 1500 so a cut only happens on a real sentence/thought
pause (~the "couple seconds" the feature was meant to use). Request count now
tracks actual pauses rather than inter-word gaps; the server throttle is left
unchanged (the earlier limit bump was treating the symptom).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a description to the "Authorization header" input in the external
MCP server form explaining that the entered value is sent verbatim as
the Authorization HTTP header value (e.g. "Bearer <token>" or
"Basic <base64>"), since there is no implicit Bearer prefix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the "Use Tavily preset" quick-fill button from the Add server form
and its now-dead supporting code:
- remove the preset JSX block, applyTavilyPreset handler and TAVILY_PRESET
constant in ai-mcp-server-form.tsx
- drop the now-unused McpTransport import
- remove the unused "Use Tavily preset" i18n key from en-US translations
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ship the plain onnxruntime-web wasm variant alongside JSEP so production builds
can load the VAD backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The production (rolldown) build resolves onnxruntime-web to the plain wasm
backend and fetched /vad/ort-wasm-simd-threaded.mjs, which we did not ship —
we only copied the JSEP variant that the Vite dev build uses. That 404'd into
the SPA fallback, reproducing "no available backend found / Failed to fetch
dynamically imported module" in production.
Copy BOTH the JSEP and the plain ort-wasm-simd-threaded.{mjs,wasm} into
public/vad/, so the runtime fetch finds a real file regardless of which build
the bundler picked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-host Silero VAD / onnxruntime-web assets under /vad/ so streaming
dictation loads its wasm backend (fixes the 'text/html' MIME runtime error).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Streaming dictation failed at runtime with "no available backend found /
'text/html' is not a valid JavaScript MIME type": @ricky0123/vad-web 0.0.30
defaults baseAssetPath/onnxWASMBasePath to "./" (relative to the page URL),
so the worklet, Silero model and ORT wasm/mjs were requested against the SPA
catch-all and came back as index.html.
Serve them from a fixed /vad/ instead:
- scripts/copy-vad-assets.mjs copies the 4 runtime assets (vad worklet,
silero_vad_v5.onnx, ort-wasm-simd-threaded.jsep.{mjs,wasm}) from node_modules
into apps/client/public/vad/ (gitignored — the ORT wasm is ~26 MB)
- client dev/build scripts run the copy first so the assets are always present
- useStreamingDictation points both path constants at "/vad/"
Verified: dev server serves all four under /vad/ with HTTP 200 and correct
Content-Type (js/wasm, never text/html); tsc clean. Prod (Docker) build runs
the copy step, so dist/vad/* ships in the image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Soft-deleting a page no longer opens a "Move this page to trash?"
confirmation modal. The page is moved to trash immediately and the
"Page moved to trash" toast now exposes an inline Undo action that
restores the page via the existing restore flow.
- Add move-to-trash-notification.tsx helper that builds the toast body
(status text + Undo button) as a ReactNode, so it can be used from the
non-TSX page-query module.
- useRemovePageMutation: show the toast with a stable id, 8s autoClose,
and an Undo handler that hides the toast and triggers restore.
- space-tree-node-menu / page-header-menu: call handleDelete directly and
drop the now-unused useDeletePageModal usage. Permanent delete keeps its
confirmation modal.
- useRestorePageMutation: read the live tree from the jotai store at
execution time and insert via a functional updater, so Undo restores
child pages correctly even after the originating component unmounted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a lightweight "streaming" dictation mode as a simpler alternative to the
realtime-websocket path: detect speech with Silero VAD (@ricky0123/vad-web),
cut each segment on a pause and POST it to the existing /ai-chat/transcribe
endpoint, so text appears progressively. No server changes.
- new useStreamingDictation hook (same API as useDictation), lazy-loads VAD,
in-order seq emission, session-epoch guard against stop->start races
- new encodeWavPcm16 util (Float32 -> mono PCM16 WAV, accepted by the server)
- MicButton gains a `streaming` prop; enabled in the editor toolbar and chat
- VAD tuning: redemptionMs 640 / preSpeechPadMs 320 / minSpeechMs 96
- batch dictation kept as the fallback (streaming=false)
- deps: @ricky0123/vad-web@0.0.30, onnxruntime-web@1.27.0
Note: VAD assets load from the library CDN by default; for self-hosted/offline
set VAD_BASE_ASSET_PATH/VAD_ONNX_WASM_BASE_PATH and copy assets to public/vad/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each chat row in the AI-chat history now shows a dimmed second line with
how long ago the chat was created and the document it was created in
("N ago / <document>", or "No document" when started outside a page).
Server:
- New migration: nullable ai_chats.page_id (FK pages.id, ON DELETE SET NULL).
- Capture the origin page at chat creation from the client-supplied openPage,
but validate it first: it must be a real page in the same workspace that the
user may read (PageAccessService.validateCanView), else null. This keeps the
"openPage.id is attacker-controllable but harmless" invariant - preventing a
cross-workspace/cross-space page-title leak and a post-hijack FK crash.
- findByCreator left-joins pages (scoped by workspace, defense-in-depth) and
returns pageTitle.
Client:
- IAiChat gains pageId/pageTitle; ConversationList renders a ChatMetaLine
(useTimeAgo + origin document) as a dimmed second line.
- Add i18n key "No document" (en-US, ru-RU).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update the halo's border-radius from a fixed 50% circle to the theme's default radius variable. This ensures the red pulse follows the button's rounded‑square outline instead of appearing circular.
Add detection for browser fetch‑failure messages (e.g., “Failed to fetch”, “Load failed”, “NetworkError”) and return a clear error indicating the streaming connection to the server was lost. Refine the connection‑error regex to avoid overlapping patterns while preserving provider‑side error handling.
reindexWorkspace isolated every per-page failure, so an invalid/missing
API key (401 "User not found") made all pages fail identically while the
batch kept issuing hundreds of doomed requests against the provider.
Add isFatalProviderError() (401/403 auth, 402 billing) and abort the
whole batch on such errors; 429 rate-limit and embedding timeouts stay
per-page isolated. Adds unit tests for the predicate and a regression
test for the abort/iterate control flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A mid-stream connection drop showed a generic "Something went wrong / Load
failed" banner and left no server-side trace.
- error-message: classify the browsers' own fetch-failure strings ("Load
failed" on WebKit, "Failed to fetch" on Chrome, "NetworkError" on Firefox)
as a lost connection, so the banner names the cause instead of the generic
heading.
- ai-chat.controller: log a warning in the request close handler when the
client disconnects before completion, so a drop that reaches the app (e.g. a
reverse proxy cutting the SSE) is visible in the server logs before the abort.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adjust AppShell padding to responsive values and add a CSS module that
handles container top and side padding for different breakpoints,
replacing the previous fixed `pt="xl"` usage.
The button rendered with Mantine's default blue primary, which clashes
with the app's neutral/dark accent design. Switch both the single-space
and the space-picker variants to variant="light" color="gray".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The button never rendered: it filtered spaces to writable ones via a
CASL ability built from membership.permissions, but the /spaces list
endpoint returns membership.role only (permissions come from
/spaces/info). The empty ability hid the button for everyone.
Resolve writability from membership.role instead, mirroring the server
space-ability mapping (ADMIN and WRITER can manage pages, READER is
read-only). Drop the now-unused CASL imports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The byline mic rendered blue and with a smaller (16px) glyph next to the
gray 20px info icon, so it looked misaligned with an uneven gap. Add
optional color/iconSize props to MicButton (forwarded through
DictationGroup) and render the byline mic gray at 20px, wrapping it and
the info icon in a tight nowrap group so they read as a snug, aligned
pair. The AI chat mic is unchanged (passes neither prop).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dictation button now lives in the always-rendered page byline, so the
copy in the fixed toolbar was redundant: with the toolbar enabled the mic
showed up twice. Drop the DictationGroup render, its isDictationEnabled
guard, and the unused import from the toolbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-workspace `sttLanguage` setting (ISO-639-1 hint; empty =
auto-detect) and a searchable language picker in the Voice / STT settings
card. The hint is forwarded to the transcription endpoint:
- multipart path via the AI SDK `providerOptions.openai.language`
- JSON (OpenRouter) path via a top-level `language` body field
only when non-empty, so auto-detect behaves exactly as before.
Threaded through the whole stack: ai.types, update DTO, AiSettingsService
(resolve/getMasked/update), the workspace.repo SQL allowlist, the client
ai-settings service types, and the provider-settings form. Adds en-US
source keys and ru-RU translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a big "New note" action to the Home screen that creates a new page
and opens it. Since the home screen has no active space, the target
space is resolved from the user's writable spaces (CASL Manage/Page
gate, mirroring the space sidebar): created directly when there is one
writable space, picked from a dropdown when there are several, hidden
when there are none. Menu items are disabled while a create is in
flight to avoid duplicate pages.
- New component features/home/components/new-note-button.tsx
- Render it at the top of pages/dashboard/home.tsx (above the carousel)
- Add i18n keys "New note" / "Create in space" to en-US and ru-RU
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>