Resolve the pre-merge review items for the #146 NodeView content-first fix:
- Export collectScrollAncestors/reflowAfterPaste and add editor-paste-handler
unit tests covering ancestor selection (overlay included, non-overflowing
auto excluded, X axis), the scrollHeight>clientHeight gate, scrollingElement
dedup, the docEl==null branch, and the double-rAF nudge.
- Extend the structural guard with CodeBlockView and merge the two it.each
blocks into one document-order assertion (handles the <pre> nesting where the
contentDOM is not the literal first child).
- Simplify the post-paste nudge to a single scrollTo(scrollLeft, scrollTop).
- Document that the post-paste reflow runs on every paste path intentionally,
and cross-reference the two #146 mitigations in both fixes.
- a11y: aria-hidden the decorative footnotes heading and number marker, and
label the footnotes list via role="group" + aria-label so the visual reorder
does not break screen-reader reading order (WCAG 1.3.2).
- CHANGELOG: add a Fixed entry noting the caret fix is macOS-verified manually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the #147 review (Approve with comments):
- Add footnote-views.structure.test.tsx: a structural regression guard asserting
the editable NodeViewContent is the FIRST child of FootnotesListView and
FootnoteDefinitionView, with no contenteditable=false chrome before it. The
whole #146 fix rests on this DOM-order invariant; the macOS caret symptom needs
a real browser, but the order proxy is testable in jsdom. Stubs @tiptap/react
so the views render as plain DOM — the test passes on the fixed order and fails
on the pre-fix chrome-first order.
- Reword the code-block-view comment: it claimed a "top-right overlay (the
transclusion pattern)", but the menu stays fully in flow as a full-width row
lifted via flex `order: -1` (the .codeBlock wrapper is a flex column). No
overlay/absolute positioning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pasting markdown/code inserts React NodeViews that mount asynchronously; until
the next reflow the browser's hit-test geometry is stale, so ProseMirror's
posAtCoords/caretRangeFromPoint maps a click to the wrong (offset) line — which
users reported clears itself on any scroll. Reproduce that scroll's side effect
with a ZERO-delta nudge (re-assign scrollTop/scrollLeft to their current value)
on every scrollable ancestor + the document scrolling element, run across two
animation frames so it lands after the pasted content + NodeViews commit. The
nudge does not move the viewport.
Wired into editor-paste-handler's handlePaste, which ProseMirror's someProp runs
(as an editorProps handler) before the MarkdownClipboard plugin that performs the
markdown/code insert — so the nudge is scheduled on exactly the paste path that
triggers the bug. Complements the structural NodeViewContent-order fix in this
branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three editable NodeViews rendered a contentEditable=false "chrome" element IN
FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing
(posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the
caret to the previous node — the caret/selection lands a line (code block) or
several lines (footnotes, into the body) above where the user clicked.
Fix (the transclusion pattern / issue #146 plan): make the editable
NodeViewContent the FIRST child in the DOM and move the non-editable chrome
AFTER it, restoring its visual position with CSS:
- code-block-view: <pre><NodeViewContent/></pre> first; the language/copy menu
follows and is lifted above via flex `order` (.codeBlock is now a flex column).
- footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and
is lifted above via flex `order` (.list is a flex column; the separator border
stays on the container).
- footnote-definition-view: NodeViewContent first; the "N." marker follows with
`order:-1` to stay on the left; the ↩ back-link stays on the right.
Layout is visually unchanged. Verified in a real browser (Chromium): the
contentDOM is now the first child of every editable NodeView wrapper (no
contentEditable=false element precedes it), and the menu/heading/marker still
render in their original positions.
NOTE: the caret-offset itself is macOS-specific text hit-testing and does not
reproduce in headless Chromium/WebKit on Linux (verified extensively), so the
visible fix is best confirmed on macOS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While a multi-step agent turn is "thinking between tool steps", the
assistant identity name (e.g. the role name) was rendered twice, stacked:
once by the assistant message row (MessageItem) and once by the standalone
TypingIndicator below it. The indicator's name label only makes sense when
it stands in for a not-yet-started assistant row; between steps the row
above already shows the same name.
Render the indicator's dimmed name label only when it is standalone (no
assistant row at the tail yet); otherwise show just the "Thinking…" dots.
- typing-indicator.tsx: optional showName prop (default true); the name
label renders only when showName !== false
- message-list.tsx: exported typingIndicatorShowsName(messages) helper;
pass showName to the indicator at the render site
- typing-indicator-shows-name.test.ts: unit-cover the four cases
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The custom undici RetryAgent + aiFetch transport added for issue #140
did not actually heal mid-stream provider drops: undici's retry path is
a Range-based download-resume that SSE/chat-completions endpoints cannot
satisfy, so a reset after the first byte only swapped ECONNRESET for a
"server does not support the range header" error. Its only real effect
was reconnecting a poisoned keep-alive socket before the first byte, and
PR #141 on top of it turned the 60s headers timeout into deterministic
~61s failures (plus CONTENT_LENGTH_MISMATCH from retrying a POST body
after a timeout abort). The root cause is the z.ai coding endpoint, not
our transport.
Remove the whole layer and return all AI provider calls to Node's
default global fetch.
- delete integrations/ai/ai-http.ts and its spec
- ai.service.ts: drop the aiFetch import, the AI_BYPASS_RESILIENT_FETCH
diagnostic toggle, and fetch:aiFetch from every chat/embedding/STT
factory; raw STT call back to global fetch
- ai-chat.controller.ts: drop the stream-timing START log + startedAt
- ai-chat.service.ts: drop the first-chunk/FINISHED/ERROR timing logs
- .env.example: drop AI_BYPASS_RESILIENT_FETCH
Reverts: 1af5d34a, 7c308728, b7abb7ea, 35fc58ea, d6cd2754, 6efb8656.
Preserved (not part of the rollback): client-disconnect abort, title
generation in onFinish, partial-answer persistence, Safari SSE heartbeat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds co-located unit tests for ten targets (client → vitest *.test.ts(x),
server → jest *.spec.ts), plus minimal behavior-preserving extractions/exports
where the issue required a pure function to test:
- encode-wav: WAV header + PCM16 clamping
- editor-ext embed-provider / utils (sanitizeUrl, isInternalFileUrl) / indent
(export clampIndent)
- label.dto @Matches regex
- move-page.dto vs generateJitteredKeyBetween parity (bug locked via test.failing)
- new-note-button canCreatePage (extracted to can-create-page.ts)
- history-editor diff (extracted pure computeHistoryDiff into history-diff.ts)
- notification getTypesForTab + repo contract (direct-tab divergence locked via
test.failing)
- search buildTsQuery (extracted + sanitizes operator inputs so adversarial
queries no longer risk a to_tsquery 500)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend ai-http.spec with two loopback-server tests: a provider that stalls
without sending headers triggers the (lowered) headersTimeout and is retried on a
fresh connection, recovering; a healthy fast response passes through in one
attempt. No external network calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The z.ai GLM coding endpoint intermittently accepts the chat request but never
sends response headers; undici's default 300s headersTimeout then hung the user
for five minutes before failing, and UND_ERR_HEADERS_TIMEOUT was not in the
RetryAgent's retried error set, so there was no recovery.
headersTimeout only bounds time-to-FIRST-headers (before any body) — it is NOT
the streaming budget, so lowering it does not truncate live SSE streams. Cap it
(env AI_HTTP_HEADERS_TIMEOUT_MS, default 60s) so a header stall fails fast, and
add UND_ERR_HEADERS_TIMEOUT to the retried error codes so the stalled request is
retried on a fresh connection (which usually responds in seconds). bodyTimeout
kept generous (env AI_HTTP_BODY_TIMEOUT_MS, default 300s) so slow streams with
sparse chunks survive. UND_ERR_BODY_TIMEOUT is deliberately NOT retried (mid-body,
partial SSE already delivered).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:
- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
-> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
filename, mimeType}): validate/decode the audio first (so a bad payload
leaves no junk page), resolve the space slug via getSpaceById (no-space
probe), createPage(), navigate via the router (no reload), wait for the
new page's editor to be mounted+editable+Yjs-connected, then run the same
uploadAudioAction path as insertRecording. Resolve-only error contract:
no-space | create-failed | editor-timeout | insert-failed.
DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).
Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the 4th PR #138 review (3 suggestions, no blockers).
- Double-call safety: a failed turn fires both useChat onFinish AND onError, so
onTurnFinished can run twice in one turn (streamed id, then no id) before a
re-render. onTurnFinished now reads the live id from a ref (set imperatively on
primary adoption) instead of the stale closure, so the 2nd no-id call cannot
re-arm the error-path fallback at the source; the render-phase reconciler is the
second layer. Added a hook test for the sequence — verified it fails only if
BOTH layers are removed (non-tautological).
- Conventions: extracted named UseChatSessionOptions / UseChatSessionResult
interfaces (was an inline param literal + ChatSession); the test derives its
driver props from them.
- Simplification: extracted the chatIdSnapshot(chats) projection used at both the
fallback arm site and the resolver effect.
Architecture notes from the review (caller-driven disarm contract; cross-process
{chatId} type) intentionally left as Variant A per the reviewer's recommendation.
tsc clean; 128 ai-chat tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the 3rd PR #138 review.
Warning fix: the render-phase reconciler only disarms the error-path adoption
fallback when activeChatId actually changes. Pressing 'New chat' while ALREADY
in a new chat keeps activeChatId === null (a no-op atom write), so the reconciler
never fired and a stale armed fallback could adopt the just-failed chat from a
late refetch, yanking the user out of their fresh chat. useChatSession now
returns cancelPendingAdoption(); the window calls it from startNewChat AND
selectChat. (The hook call moved above those callbacks so they can reference it.)
Added a hook test that fails without the explicit disarm, plus a test for the
existing-chat onTurnFinished branch (no adoption + per-chat invalidation).
Cleanups: removed the dead pickNewlyCreatedChatId (the fallback effect uses
newlyAddedChatIds directly with the 0/1/>1 decision inline) and its tests/doc
mention; inlined the two invalidation closures (onTurnFinished is read live by
useChat's onFinish, never in an effect dep array, so memoizing them was needless
ceremony).
Verified: tsc clean, 127 ai-chat tests green; live (z.ai glm-5.2) new chat + 2nd
turn recalled the number in the SAME row (1 chat / 4 messages), no page errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Node's fetch returns a generic "fetch failed" error, hiding the actual
reason (e.g., ECONNRESET, timeout) in the error's cause chain. This
change extracts up to three levels of the cause, formats each with its
code and message, and includes the chain in the warning log, making
failures more actionable.
A new-chat turn fired the chat stream (streamText) and title generation
(generateText) concurrently to the same z.ai coding endpoint. That plan
stalls one of two concurrent requests, so the chat stream black-holed for
~300s (undici headers timeout) and the turn hung forever in every browser;
the AI SDK then retried 3x. Server logs showed two concurrent POSTs to
/chat/completions per turn — one 200 in ~8s, the other "fetch failed after
301209ms". Bypassing the custom undici transport did not help, confirming
the cause is the concurrency, not the transport.
Move generateTitle from before the response pipe into onFinish, so it runs
solo AFTER the stream's provider call completes. A first turn that errors or
aborts no longer auto-titles (fallback "Untitled chat" already handles a
null title) — acceptable, and it removes the request that was stalling.
Add a per-card "Save and test" button to the AI provider settings (save the
whole form, then probe the endpoint on success).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single global "Save endpoints" button sat far below the fold and the
per-card "Test endpoint" button probed the server-stored settings, so it
ignored unsaved form edits. Replace each endpoint card's "Test endpoint"
button with a combined "Save and test" button that persists the whole form
first and only runs the card's connection probe on a successful save; the
global "Save endpoints" button is kept for save-only.
- Add handleSaveAndTest: save (rethrows on failure) then probe; skip the
test if the save fails (the mutation already surfaces the error).
- Add savingTestCapability state so only the clicked card spins during the
shared save while all save controls stay disabled (no concurrent saves).
- Reset the previous probe result when a new save+test starts.
- Add the "Save and test" en-US translation key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the 2nd PR #138 review (test debt + the Variant-B architecture ask).
The new→persisted chat id lifecycle (mount key, both adoption paths, the
history-load latch, the render-phase reconciler, onTurnFinished) is moved out of
the 768-line window into a new useChatSession hook driven by a pure
threadSessionReducer (reconcile/adopt), so adopt-vs-switch is one explicit
dispatch point and the scattering the review flagged is gone (window: 768→~620).
Tests (the blockers):
- use-chat-session.test.tsx — hook-level locks incl. the #137 regression
(adopts the authoritative streamed id 'A', NOT chats.items[0]='B' — fails on
the old heuristic), the error-path fallback (arm/adopt/ambiguous/add+delete),
the disarm-on-reconcile lock (a fallback armed then switched away must not be
adopted by a late refetch), in-place-adopt-keeps-key vs external-switch-remount,
and the waitingForHistory latch.
- extractServerChatId (reading message.metadata.chatId) and newlyAddedChatIds
extracted as pure helpers with unit tests; threadSessionReducer tested.
Cleanups: single canonical #137 explanation in adopt-chat-id.ts (other sites
reference it); fallback effect computes the set diff once; invalidate callbacks
memoized; redundant invariant tests folded.
Behavior preserved — re-verified live (z.ai glm-5.2): new-chat adopt + 2nd turn
in the same row, no mid-conversation remount, two-tab race leak-free, switch to
an existing chat reseeds full history, reload restores history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a stable native-host JS-API on window.gitmost so the gitmost.app
WKWebView wrapper can hand a recorded audio file to the current page as an
audio block without depending on editor internals (atoms/Tiptap/Yjs).
- page-editor.tsx: register/tear down window.gitmost only while an editable
page editor is mounted (ready=true, version=1). insertRecording validates
mime/size, decodes base64 in 4-char-aligned chunks, rejects oversized
payloads before decoding (too-large), reuses the existing uploadAudioAction
pipeline, resolves machine-readable error codes
(no-editor/bad-type/too-large/insert-failed) and never throws. Cleanup is
identity-guarded so an unmounting PageEditor cannot disable a newer live
registration.
- editor-ext audio-upload: return the uploaded attachment from the upload fn
so the bridge can report success + attachmentId. Backward compatible:
existing fire-and-forget callers ignore the return value; the error path
still swallows and returns undefined (no re-throw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The streaming chat turn hangs in all browsers while the non-streaming test
endpoint works — both use the same model/transport (createOpenAI + aiFetch),
so the suspect is the streaming path / custom undici RetryAgent transport.
- ai-http.ts: wrap aiFetch with per-request timing logs (start, ms-to-headers
on success, elapsed ms + cause on failure). Chat at info, embeddings at
debug. Only host+path logged.
- ai-chat.controller.ts / ai-chat.service.ts: log turn START, first-chunk
latency, FINISHED duration, and elapsed ms on disconnect/error/abort.
- ai.service.ts: AI_BYPASS_RESILIENT_FETCH=true makes the CHAT model omit
fetch:aiFetch and use the default global fetch — isolates transport vs
request-shape. Chat-only; embeddings/STT untouched; reversible via env.
- .env.example: document the flag.
No timeout/retry change. tsc clean; ai-chat + ai suites pass (292).
Add a gated "Transcribe" action to the audio block's bubble menu so an
already-embedded audio file can be transcribed (previously only live
microphone dictation was supported). The button fetches the embedded
file, normalizes its MIME type to the STT whitelist, reuses the existing
POST /ai-chat/transcribe endpoint, and inserts the result as a paragraph
right below the audio block.
- Mount the previously-unwired AudioMenu in page-editor (edit mode only),
which also surfaces the existing Download/Delete actions for audio.
- Gate the Transcribe button on settings.ai.dictation; show a spinner and
block double-submits while transcribing; map errors like the mic hook.
- Disambiguate duplicate-src blocks by re-scanning the doc and inserting
after the audio node closest to the originally selected one.
- Add i18n keys (en-US, ru-RU): Transcribe, Transcribing…, No speech
detected, plus ru-RU translations for the transcription error messages.
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.
Gate streaming (silence-cut) dictation behind the per-workspace
settings.ai.dictationStreaming flag (default off); batch dictation stays the
default and fallback. Removes the implemented backlog entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Migrations auto-run on boot only in production (the built image / start:prod);
the local dev stand (pnpm dev / nest start --watch) does NOT auto-run them, so
after pulling or switching branches you must apply them with
'pnpm --filter server migration:latest' or endpoints touching new columns 500
(e.g. a freshly-added ai_chats.page_id blanket-500s all of AI chat).
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>
Design entry: hide the silence-cut streaming dictation path behind a per-
workspace settings.ai.dictationStreaming flag, default false, with batch
dictation as the default and fallback. Reuses the existing STT model and
/ai-chat/transcribe — no new provider/model/endpoint fields. Lists the server
+ client touch points, acceptance criteria, and edge cases.
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>
Update the task lifecycle documentation from Russian to English to improve
readability for English‑speaking contributors and ensure consistency across
the repository.