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.
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>
The editor dictation button previously lived only in the fixed toolbar,
which is hidden by default (gated by the per-user editorToolbar
preference), so dictation was effectively unavailable in the editor. Add
the same dictation control to the always-rendered page byline row, right
next to the Details "i" icon, so voice input stays reachable.
It is shown only when workspace dictation is enabled, the page is
editable, and the editor is in edit mode. Reuses the existing
DictationGroup/MicButton and its caret-insertion logic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Merge the comments side-panel header into the Open/Resolved tab row,
then drop the now-redundant "Comments" title; the panel keeps its
accessible name via the AppShell.Aside aria-label.
- Overlay the close (X) button on the right of the tab row and nudge it
up 4px to align with the tab labels; the tab list stays full-width so
its bottom border line is preserved. The toc/details tabs keep their
existing shared header and scroll area unchanged.
- Quote block (.textSelection): increase top margin (2px -> 8px) so it
no longer sticks to the timestamp when it is the first block, and add
margin-left: 6px so the quote's left bar lines up with the comment
body text left edge.
Merge the comments side-panel header into the Open/Resolved tab row to
save vertical space: title on the left, tabs centered, close button on
the right.
- comment-list-with-tabs: add optional `title`/`onClose` props; render
the title and close button as absolutely-positioned overlays around a
full-width centered Tabs.List. Keeping them outside Tabs.List preserves
the tablist ARIA contract (only role="tab" children) while the tab
list's full-width bottom border line is retained.
- aside: pass `title`/`onClose` to CommentListWithTabs for the comments
tab and drop the shared header for that tab; the toc/details tabs keep
their existing shared header and scroll area unchanged.
- Remove the large active-space name header in the space sidebar;
the active space stays highlighted in the spaces grid below.
- Move "Space settings" into the user avatar (top) menu next to
"Workspace settings"; it shows only while viewing a space and is
detected via useMatch("/s/:spaceSlug/*").
- Make the brand logo non-selectable/non-draggable (user-select:none
on .brand, draggable=false on the img).
- Remove the redundant "Home" button next to the logo (the logo
already links to /home).
- Remove the version label under the Settings sidebar menu.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live mic-level halo around the stop button was frozen at a constant
scale (1.15) whenever the OS "Reduce motion" setting was on, so it never
reacted to the voice while dictating. Make haloScale unconditional so it
always follows audioLevel (amplitude 0.9), and drop the now-unused
useReducedMotion import and reduceMotion local.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Widen the comments/aside panel from 350 to 420 (~20% wider)
- Remove double padding around the panel: AppShell.Aside p="md"->"sm"
and inner Box p="md"->p={0}; reduce header-to-tabs gap mb="md"->"sm"
- Reduce empty space below the add-comment input (paddingBottom 25->10),
align the avatar with the input box (marginTop 10->2) and re-anchor the
send button (bottom 30->15)
- Pull the timestamp closer to the nickname via tighter line-height
(lh 1.2 on the name, 1.1 on the "… ago" text)
The "Thinking…" indicator's bounce was fully disabled by the
prefers-reduced-motion rule (animation: none), leaving the dots
frozen for users with "Reduce motion" enabled. Drive the bounce
height with a --bounce custom property: -6px by default and a
smaller -3px under reduced-motion, so the indicator stays visibly
active everywhere instead of freezing.
A brand-new chat's first turn streamed and finished successfully, but the
whole assistant response vanished from the UI. On finish the window adopts
the server-created chat id, which changed the <ChatThread> key and remounted
it — discarding the live useChat store (the full answer) and re-seeding from
not-yet-persisted history, so only the user message remained.
- chat-thread: pin the useChat store id to a per-mount value so adopting the
chatId prop no longer recreates the store and wipes the live turn.
- ai-chat-window: derive the thread mount key via setState-during-render and
move the live-thread marker in lockstep with the adopted id, so in-place
adoption keeps the same mounted thread while real chat switches still
remount and re-seed; gate the history loader to a freshly opened chat.
- cancel a pending adoption on New chat / explicit chat selection.
- log the raw stream error to the browser console for debugging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the AI chat typing indicator text "AI is typing…" with
"Thinking…".
- typing-indicator.tsx: use t("Thinking…") instead of t("AI is typing…")
- en-US: drop the now-redundant "AI is typing…" key (the "Thinking…"
key already existed and was unused)
- ru-RU: rename the key to "Thinking…" with value "Думаю…"
- update related comments in message-list.tsx and the test file
showTypingIndicator treated any tool part in the latest assistant message
as visible content, so the "AI is typing…" dots were suppressed for the
rest of the turn once the first tool call appeared. During the model's
"thinking" pauses after a completed tool call, the chat showed only static
tool cards and no activity.
Inspect the last part of the assistant message instead of any part: hide
the dots only while output is actively rendering (a non-empty streaming
text part, or a tool still in the "running" state — which shows its own
Loader). Finished/errored tools and empty trailing text now keep the dots
visible, so the indicator reappears while the model thinks between steps.
Add tests covering the post-tool thinking gap and the running-tool case.
Add a pulsing halo behind the stop button that scales with the
microphone input level, giving real-time feedback that recording is
active and the mic is picking up sound.
- use-dictation: meter the captured MediaStream via AudioContext +
AnalyserNode (analyser only, never connected to destination), compute
a smoothed RMS audioLevel (0..1) in a requestAnimationFrame loop, and
tear the meter down on every recording-end path (stop/cancel/auto-stop/
unmount); meter failure is non-fatal to recording
- mic-button: render a translucent red halo whose scale follows
audioLevel; honor prefers-reduced-motion with a static halo
- stop(): recover and release resources when no live recorder remains
- fix unhandled rejection from AudioContext.resume()
Role cards in the new-chat empty state were capped at max-width 200px and
never grew, leaving large side gaps in a wide window. Make the cards flex
to fill each row (flex: 1 1 240px) and raise min/max width so they get
wider and use the available window width while still wrapping to ~2 columns
at the default window size.
Batches 6-9: behaviour-preserving extractions of testable pure cores plus the
tests they unblock, and a fix for the broken client test environment.
Full suites green: server 113 suites / 1117 + 1 todo, client 30 files / 338.
client (R0 infra):
- vitest.setup.ts: in-memory localStorage/sessionStorage Storage stub wired via
setupFiles. Unblocks menu-items.gating.test.ts (was 9 failing) -> client suite
fully green. + menu-items.suggestions.test.ts (getSuggestionItems filter/sort).
share:
- extract buildShareMetaHtml (share-seo.util.ts) from the SEO controller; tests
for reflected-XSS escaping in <title>/og/twitter meta, noindex, truncation;
extractPageSlugId; updateAttachmentAttr; prepareContentForShare comment-strip
(anonymous-viewer metadata-leak guard).
ai-chat (security extractions):
- selectAccessibleHits: CASL post-filter for semantic search (restricted page in
an accessible space must NOT leak to the agent).
- validateResolvedAddresses: SSRF connect-time guard (block if ANY resolved
address is private).
- resolveAudioFormat: mime whitelist (dead `?? 'webm'` fallback dropped, set
unchanged). + mcp-servers toView header-leak guard, MCP tool namespacing.
collaboration (data-loss area):
- extract computeHistoryJob (pins the "agent delay MUST stay 0" invariant) and
resolveSource. Integration: onAuthenticate read-only matrix (collab auth
bypass), HistoryProcessor (contributor restore on save failure), onStoreDocument
Approach-A boundary snapshot (human revision pinned before agent overwrite).
Reviewed (APPROVE WITH SUGGESTIONS): extractions behaviour-preserving, security
tests mutation-resistant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AI chat error banner always showed a generic "Something went wrong"
with no reason. The server already forwards the provider cause into the
stream (e.g. "Cannot connect to API: read ECONNRESET"), but the client
hid it behind a static heading.
- describeChatError now returns { title, detail }: a short heading naming
the cause category plus a one-line explanation.
- Add classifyProviderError: maps connection reset, timeout, rate limit,
context-window overflow, quota and auth failures to clear categories;
the 403/503 gating responses are preserved; unknown errors fall back to
the verbatim provider text.
- Match HTTP status codes only as the leading token and textual signatures
only against the message head (before "| response body:"), so a number
or phrase in the response-body snippet never mislabels the cause.
- Use the new {title, detail} in all three banners: chat-thread,
share-ai-widget and the persisted-error banner in message-item.
- Cover the classifier with 20 unit tests (categories + regressions).
The typing indicator rendered "<role name> is typing…". Show a generic
"AI is typing…" instead and keep the role/identity name only in the
dimmed interlocutor label above the typing dots.
- typing line now always renders t("AI is typing…")
- add the "AI is typing…" key to en-US and ru-RU locales
- sync stale doc comments that referenced the old text
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The colored role cards in the AI chat empty state truncated their
admin-configured description with an ellipsis and could clip the top row
when the cards overflowed. Make the full text fit:
- drop the description lineClamp so the whole text renders
- add overflow-wrap: anywhere so long unbreakable tokens (URLs) wrap
- switch the cards container to align-content: flex-start so an
overflowing top row stays reachable while scrolling (the parent
Mantine Center still vertically centers the block when it fits)
- widen the card max-width 180px -> 200px for more text room
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the new-chat role-card empty state:
- Remove the "Universal assistant" card; universal assistant is now the
implicit default the user gets by typing without picking a card.
- Show each role's description on its card (under the emoji and name).
- Clicking a card immediately starts the chat: it binds the role to the
new chat and sends the default opening prompt "Take a look at the
current document" (one click, no separate select step). roleIdRef is
set synchronously before sendMessage so the create request carries the
role.
- Show the current role's name in the window header badge and as the
assistant's display name (transcript label + "… is typing…"), falling
back to "AI agent" for a role-less chat. selectChat resets the picked
role so it cannot leak into an unrelated existing chat.
- Add the "Take a look at the current document" i18n key (en-US, ru-RU).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ru-RU was missing most AI-chat keys, so the chat/typing widgets rendered
mixed-language (some keys fell back to en-US). Fill the full AI-chat string
set in ru-RU and document the maintenance policy.
- ru-RU/translation.json: add the 24 missing AI-chat keys (labels, typing
indicator, Ask-AI widget, public-share, error messages); keep the typing
keys grouped; existing translations untouched.
- i18n.ts: add a policy comment near fallbackLng — en-US is the source of
truth; en-US + ru-RU are fully maintained; the other 10 locales
intentionally rely on the en-US fallback until contributed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the new-chat <Select label="Agent role"> picker with colored role
cards rendered as the empty-state of a brand-new chat (centered in the window),
per docs/backlog/ai-chat-role-cards-empty-state.md. Clicking a card selects that
identity; sending without a pick falls back to the Universal assistant; the
cards disappear once the chat is non-empty. Purely client-side — the existing
selectedAiRoleIdAtom + roleId request wiring (server role fixation on chat
creation) is unchanged.
- new RoleCards rendered through the existing emptyState prop chain
(AiChatWindow -> ChatThread -> MessageList); MessageList already supported it.
- Universal assistant card (gray, value null, default-selected) + one card per
enabled role, color cycled from a 10-name Mantine palette via the pure
roleCardColor() helper; theme-aware CSS vars (light/-light-color/-filled).
- each card is an UnstyledButton with aria-pressed for a11y + testability.
- tests: role-card-color (palette cycling, negative-safe) + role-cards.test.tsx
(render, emoji/name, selection highlight, click -> onSelect). 9 tests green,
client tsc clean.
Verified live in-browser: cards (not a Select) show for a new chat; selecting
Пират binds the chat to that role end-to-end (badge + pirate reply); no pick =>
Universal; cards vanish after the first message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract makeConnectHandler(queryClient) (owning the firstConnect flag) from
UserProvider and test it: first connect does NOT invalidate; a reconnect
invalidates both root-sidebar-pages + sidebar-pages. Behavior-identical (#66).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ru-RU had only '{{name}} is typing…' but not 'AI agent' / 'AI agent is typing…',
so the Russian typing indicator was mixed-language. Add them (AI-агент / AI-агент
печатает…) grouped with the named key. en-US is already complete; other locales
intentionally keep the en-US fallback (full translation is a separate effort).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Export + test isHtmlEmbedFeatureEnabled: the 'HTML embed' slash item is hidden by
default / when the toggle is off / on broken localStorage (no throw), shown only
when the workspace toggle is exactly true.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract clampHeight + isTrustedHeightMessage + the HTML_EMBED_SANDBOX token
constant from the NodeView and test them: clamp bounds; reject a resize message
from a foreign window / wrong type / NaN/Infinity; accept a valid same-source
finite message; assert the sandbox is exactly 'allow-scripts allow-popups
allow-forms' (no allow-same-origin) and rendered via srcDoc (not src).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the shared assistant-name predicate (resolveAssistantName: trimmed name
or null) used by typing-indicator + message-item, and unit-test the branches
(name shown; whitespace-only -> 'AI agent' fallback; undefined -> fallback).
Behavior-identical (|| -> ?? since the helper returns null).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reconcile the diverged develop (13 ahead / 20 behind) with gitea/develop.
Conflict resolution — html-embed: keep the local sandboxed-iframe model
(opaque-origin srcdoc, no role-gating) and supersede gitea's same-origin
strip/kill-switch hardening (#26/#28/#29/#30). The 4 conflicted html-embed
source files resolve to the local version; the 3 strip-era spec files stay
deleted. The strip apparatus (stripDisallowedHtmlEmbedNodes,
collectHtmlEmbedSources, canAuthorHtmlEmbed, htmlEmbedAllowed) is fully gone.
Integrate gitea's page-templates / page-embed work (#31-#40) cleanly.
Fix an auto-merge arity mismatch: two new gitea page-template specs
constructed TransclusionService with the pre-sandbox 11-arg signature; drop
the trailing workspaceRepo argument to match the reduced 10-arg constructor.
Verified: server + client tsc --noEmit clean; jest (html-embed + transclusion)
14 suites / 119 tests passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The anonymous public-share "Ask AI" chat labeled every assistant turn
with the generic "AI agent" even when an Assistant identity (agent role)
was configured. Surface the configured identity name instead, falling
back to "AI agent" when no identity is set.
- server: AiSettingsService.resolvePublicShareAssistantName resolves the
configured role's name (null when unset/missing/disabled), mirroring
PublicShareChatService.resolveShareRole; ShareController returns it as
aiAssistantName on /shares/page-info (only when the assistant is on).
- client: thread aiAssistantName -> ShareAiWidget -> MessageList ->
MessageItem/TypingIndicator via an optional assistantName prop; the
internal chat omits it and keeps showing "AI agent".
- i18n: add "{{name}} is typing…" (en-US, ru-RU) for the typing line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deduplicate the "save a workspace setting" plumbing shared by HtmlEmbedSettings
and TrackerSettings (workspace atom read, isLoading state, updateWorkspace + atom
merge forcing settings[key], success/error notifications) into a new
feature-scoped hook useWorkspaceSetting(key).
- Each component keeps its own interaction model: html-embed is an optimistic
toggle with revert-on-failure; tracker is edit-then-save on an explicit button.
- Unify error handling on the better pattern: surface err.response?.data?.message
and use console.error (html-embed previously used console.log + a generic message).
No user-facing behavior change; client typecheck clean.
Test-coverage follow-ups (untested trackerHead injection in ShareSeoController and
the no-op audit branch) tracked in #100.
The three collect*FromPmJson collectors shared the same recursion (and the #55
depth cap) but were copy-pasted, so a future edit could diverge them. Extract a
generic collectNodes(doc, {type, map, key, lastWins, skipChildrenOfType}) and
reimplement all three on it, byte-output-identical (transclusions last-wins;
references/embeds first-wins + transclusionSource skip). Documents (not removes)
the write-only page_template_references graph and the near-duplicate client
lookup-context as tracked follow-ups, per the issue's guidance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the non-test code-review findings on the htmlEmbed sandbox change
(test-coverage gaps are tracked in issue #99):
- html-embed-view: track the iframe's reported content height even while a
fixed height is set, so clearing the height (fixed -> auto) without editing
the source no longer leaves the frame pinned to the stale value. Derive the
fixed-height predicate once; seed autoHeight to the default.
- html-embed-view: drop width/border from the iframe inline style (the
.htmlEmbedFrame CSS class already provides them).
- html-embed-sandbox: coalesce height reports via requestAnimationFrame and
skip <=1px deltas to damp the self-measure feedback loop; fix the misleading
bootstrap comment.
- tracker-settings: add an aria-label to the snippet Textarea (a11y).
- CHANGELOG: note the removal of server-side role-based HTML-embed stripping.
chatModel was a free string accepted with empty/garbage values, failing only at
runtime as a provider 503; tighten it (trim + non-empty + max 200). Driver was
already @IsIn(AI_DRIVERS). Collapse the client driver list to one AI_DRIVER_VALUES
source and add a contract test that reads the server AI_DRIVERS and fails on
client/server drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up fixes to the htmlEmbed-sandbox / trackerHead change:
- share-seo: inject trackerHead via a function replacer so `$`-sequences
($&, $', $`, $$) in the admin snippet are inserted literally instead of
being treated as String.replace substitution patterns; warn when the
</head> marker is absent instead of silently skipping injection.
- mcp: register a passthrough `htmlEmbed` node in the schema mirror so an
AI/MCP edit of a page containing an embed no longer throws
"Unknown node type: htmlEmbed" in TiptapTransformer.toYdoc.
- editor-ext + client: treat a non-finite `data-height` as auto (null) so a
crafted/corrupted height cannot disable auto-resize or yield a NaN iframe
height; extract a shared clampHeight helper.
- client: rename render-raw-html.{ts,test.ts} -> html-embed-sandbox.{...} and
shouldExecute -> shouldRender so the seam name matches the sandbox model.
- client: i18n the iframe title; surface the real error reason in
tracker-settings (console.error + err.response.data.message).
- docs: note hasHtmlEmbedNode is now a test-only helper; add an Unreleased
CHANGELOG entry; drop the dangling "arbitrary HTML embed" planning-doc ref.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WS events missed during a disconnect (wifi blip, sleep) were lost, so the
sidebar tree silently diverged until a manual reload. On RECONNECT (not the
first connect) invalidate the root-sidebar-pages + sidebar-pages queries so the
tree refetches through the authorized API and re-converges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>