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>
After a merge decideEmbedState became the canonical guard and inlines the
cycle/too-deep logic, leaving these predicates called only by their own tests.
Remove them (and their test blocks); keep PAGE_EMBED_MAX_DEPTH (used by
decideEmbedState). Production behavior stays covered by decide-embed-state.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The widget hardcoded a generic 'Something went wrong' body and ignored
error.message, violating AGENTS.md. Render describeChatError(error.message, t) —
the same helper the internal chat uses — so a reader sees the real 402/429/503
cause instead of a bare 'try again'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed
iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin,
srcdoc) with postMessage auto-resize (validated by event.source) and an optional
manual height attr. The block now runs in an opaque origin and cannot reach the
viewer's cookies/session/API, so it is safe for any member.
Because the block is now harmless, remove the entire admin/role gating apparatus:
drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/
collectHtmlEmbedSources and every role-based strip on the write paths (collab
REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along
with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create
callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed
on the anonymous public-share read path when the workspace master toggle is OFF.
The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the
slash-menu and share rendering); when ON the block is available to all members.
Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected
verbatim into the <head> of public share pages only (ShareSeoController), for
trackers that genuinely need same-origin. Admin-gated via the existing CASL
Manage/Settings ability; never injected into the authenticated app shell.
Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from the parallel page-embed refactor that landed in develop
via #49:
- page-embed-view.tsx: keep develop's canonical decideEmbedState for the
cycle/depth/availability guard; keep #45's #39 chrome cleanup (single source
link, IconFileText fallback) and #40 refresh remount key. Drop #45's now-unused
isPageEmbedCycle/isPageEmbedTooDeep wiring.
- page-embed-picker.tsx: use develop's excludeHost util; drop #45's duplicate
filterPageEmbedOptions and its test.
- page-embed-ancestry-context.test.tsx: keep #45's superset suite.
- page-template-access.spec.ts: keep develop's constructor args; update the two
deleteByReferenceAndSources assertions to the new 4-arg workspace-scoped
signature introduced by #45 (#36 defense-in-depth).
Full suite green: server 624, client 219, editor-ext 56, mcp 247.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isExternalHttpUrl treated any http(s):// URL as external, so an absolute link
back to the app's own host (e.g. https://self/p/{uuid}, /settings/members)
emitted by the assistant stayed clickable on the anonymous share, leaking
internal UUIDs/structure and pointing at auth-gated routes. Classify a link as
external only when its host differs from window.location.host; unparseable URLs
are treated as internal (fail-closed). Tests cover own-origin absolute (flag
on -> inert), external host (kept with safe rel/target), dangerous schemes, and
no behavior change for the internal chat (flag off).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public-share widget was a separate minimal impl: plain-text answer, static
'Thinking…', no markdown, no tool-cards. Now it renders through the internal
chat's debugged presentational layer (MessageList/MessageItem/TypingIndicator/
ToolCallCard), so a share gets the same incremental streaming, animated typing
indicator, markdown, and tool-call cards. The share keeps its anonymous
transport (useChat + DefaultChatTransport '/api/shares/ai/stream',
credentials:'omit').
The shared components were already prop-driven (UIMessage[] + isStreaming) with
no transport/auth coupling; made the new props additive optionals (emptyState,
showCitations, neutralizeInternalLinks) all defaulting to current behavior, so
the internal chat is unchanged.
Security (review-caught): rendering assistant markdown on the ANONYMOUS share
made internal links (/p/{id}, /settings/...) clickable, which the old plain-text
render didn't. renderChatMarkdown gains neutralizeInternalLinks (true only on
the share): a one-shot DOMPurify afterSanitizeAttributes hook (added/removed by
reference around a single sanitize) strips href from internal/relative/non-http(s)
links (rendered inert) and keeps external http(s) links with
rel=noopener noreferrer nofollow target=_blank. Tests cover both the link
neutralization and the absence of any global-hook leak into internal renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The floating chat window covered page content; you could only collapse it
manually. Now it auto-collapses to its header (visual collapse only — ChatThread
stays mounted so an in-flight stream isn't interrupted) when you interact with
the page, and expands again from the header.
- document mousedown listener in the CAPTURE phase, armed only when
windowOpen && !minimized; collapses on a pointer-down outside the window.
Guards: ignore clicks inside the window and inside any Mantine [data-portal]
(the chat-list kebab menu + delete-confirm modal render in portals).
- Header click expands: startDrag distinguishes click vs drag by a 4px
threshold (minimizedRef avoids a stale closure); an expand-click doesn't
persist geometry.
- Reset minimized=false when the window opens (no sticky collapsed state).
- a11y: when minimized, the title is the keyboard expand affordance
(role=button, tabIndex, aria-label Expand, Enter/Space) — kept off the
dragBar container so no role=button wraps the Minimize/Close buttons.
- Pure helpers shouldCollapseOnOutsidePointer + isHeaderClick with vitest tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ~330 tests across server (Jest), client (Vitest), editor-ext (Vitest)
and packages/mcp (node:test) for the gitmost features added since
053a9c0d: AI chat, AI agent roles, public-share assistant, MCP per-user
auth, HTML embed, page templates/embed, realtime tree, tree
expand/collapse, and the AI-settings UI.
Test-tooling fixes (prerequisite, were silently hiding coverage):
- Repair 3 page-template specs broken by the 11-arg TransclusionService
constructor; they never compiled, so template access-control / content
-leak / unsync-strip coverage was fictitious.
- Build @docmost/editor-ext before server tests via a `pretest` hook;
the stale dist omitted the new HtmlEmbed/PageEmbed exports (TS2305).
- Let jest resolve the .tsx email templates: add `tsx` to
moduleFileExtensions and widen the ts-jest transform to (t|j)sx?.
Behaviour-preserving "extract pure core" refactors that the tests drive:
- server: resolveShareAssistantRequest + uiMessageTextLength
(public-share controller), decideBasicGate + mapAuthResultToResponse
(mcp), buildErrorAssistantRecord (ai-chat), jsonbObject export (roles).
- client: render-raw-html + shouldExecute/canEdit, decide-embed-state,
page-embed picker utils, tree-socket reducers, open/close branch maps,
isEndpointConfigured/resolveKeyField; buildTreeWithChildren now treats
a permission-trimmed orphan as a root instead of crashing.
Deferred (need a test DB or HTTP harness, documented in the specs):
repo-level Postgres integration tests and the public-share XFF E2E.
Pre-existing DI/lib0-ESM suite failures are untouched and out of scope.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cycle/self-embed/depth guard (PAGE_EMBED_MAX_DEPTH=5) lives only on the
client and is the sole protection against runaway nested rendering — and was
untested. Extract the inline predicates into pure, behavior-identical exported
helpers (isPageEmbedCycle, isPageEmbedTooDeep in the ancestry context;
filterPageEmbedOptions in the picker) so they're unit-testable without mounting
the heavy Tiptap NodeView, and add vitest coverage (20 tests): ancestry chain/
host accumulation, cycle (ancestor-in-chain + top-level self-embed), too-deep at
the cap, and picker host-exclusion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts at shared registration points by unioning both features
(footnotes + the already-merged html-embed / page-embed work):
- slash-menu/menu-items.ts, editor extensions.ts: keep both imports + configures
- collaboration.util.ts: register footnote nodes and pageEmbed
- editor-ext marked.utils.ts: register footnote + html-embed markdown extensions
- editor-ext package.json/tsconfig.json/vitest.config.ts: union of test config
(jsdom env for footnote DOM tests + combined test/spec include glob)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AiChatWindow derived the open page via useParams(), but it's mounted in a
pathless parent layout route where :pageSlug isn't matched, so useParams()
returned {} and openPage was ALWAYS null — the agent never received current-page
context (couldn't resolve 'this page'/'the current page'). Derive pageSlug from
useMatch('/s/:spaceSlug/p/:pageSlug') against the full pathname instead, so it
resolves regardless of where the component sits in the route tree. No-match
behavior is unchanged (undefined -> query disabled -> openPage null).
Addresses Hardness #1 of #43. Hardness #2 (proxy resilience: a get_current_page
tool / hidden user-message context so identity doesn't depend on the system
prompt surviving CLIProxyAPI) remains open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the page-embed lookup flush(), the success branch cleared inFlightRef and
resolved waiters only for ids present in the response items. A short/partial
server response would leave a requested id stuck in inFlightRef forever (the
subscribe/refresh path is guarded by !inFlightRef.has(id)) and its refresh()
promise would never resolve. After processing returned items, also clear +
resolve any requested id that wasn't returned, mirroring the catch branch.
Cannot trigger under today's exact-mapping server contract; this is hardening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The footnote definition number ('1.') sat ~19px from its text because two
spacings stacked: the 1.5em (24px) marker min-width box (wider than the ~15px
glyph) plus a 10px flex gap. Reduce the flex gap to 0.4em (about one space) and
right-align the number within the 1.5em column so the period sits next to the
text and multi-digit numbers (10, 11, ...) stay aligned. Reads like '1. text'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The read-only embed renderer mounts a Tiptap EditorProvider with the looked-up
content, but Tiptap consumes the `content` option only at initial mount. After
Refresh busted the lookup cache and re-fetched fresh content, the new content
prop never reached the sub-editor, so the embed appeared not to update at all.
Key PageEmbedContent on result.sourceUpdatedAt (the source page's updatedAt,
already returned by the lookup and bumped on every persisted content change) so
the component and its EditorProvider remount and apply the refreshed content
when the source changes.
Note: server-side freshness vs. live collab edits is bounded by the 10s persist
debounce (collaboration.gateway.ts) — that separate limitation stays documented
in #40 and is out of scope here; this commit fixes the client never re-rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two design problems on the whole-page embed (pageEmbed) node:
- Double selection frame: the generic square cyan .ProseMirror-selectednode
outline stacked on top of the rounded .includeWrap border. Add node-pageEmbed
to the existing outline:none rule (already covering the transclusion nodes) so
only the single rounded border remains.
- Redundant 'open source' controls: the floating toolbar's external-link button
duplicated the header badge title link. Remove the toolbar button; the badge
title is now the single way to open the source (kept Refresh + ... menu).
Also swap the badge fallback icon IconArrowsMaximize (read as 'expand') for a
neutral IconFileText.
Follow-ups from review: render the badge whenever the source resolves (so the
only open-source link can't vanish when title+icon are empty), and label the
link (title/aria-label) + add the 'Open source page' i18n key (en-US, ru-RU).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Template pages were toggleable but indistinguishable in the sidebar tree.
Render an IconTemplate next to the title when node.isTemplate is true, wrapped
in a Tooltip(label='Template') with an aria-label + role='img' for AT. The
icon is a child of the row Link so clicks navigate as normal; pointer events
stay enabled so the tooltip's hover handlers fire. Adds the 'Template' i18n
key to en-US and ru-RU (other locales fall back to en-US).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Anonymous public-share AI assistant:
- Add a workspace setting `publicShareAssistantRoleId` so an admin can pick which
agent role (identity/persona) the anonymous assistant adopts. The role's
instructions REPLACE the built-in persona while the immutable safety framework
is still always appended; the role's optional model override takes precedence
over the cheap publicShareChatModel. Resolved server-authoritatively
(workspace-scoped, soft-delete aware; disabled/missing roles fall back to the
built-in persona, so the tool scope remains the real security boundary).
- Plumb the field through the update DTO, ai-settings service, the workspace.repo
ALLOWED whitelist, resolve()/getMasked(), stream-time role resolution and the
prompt/model, plus the settings UI: a new "Assistant identity" Select listing
enabled roles (and surfacing a saved-but-disabled role explicitly).
Public-share branding / floating icon:
- Fix the AI assistant FAB overlapping the "Powered by ..." button (both were
Affixed bottom-right): stack the FAB above the bottom-right branding.
- Rename "Powered by Docmost" -> "Powered by Gitmost" and point the link at the
gitmost repo.
Tests: extend public-share-chat.spec (role persona replacement still appends the
safety framework, resolveShareRole edge cases, model-override precedence).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The html-embed feature toggle was enforced CLIENT-side in the NodeView (reads
settings.htmlEmbed from the logged-in workspace), so an anonymous public-share
viewer — who has no workspace context — always saw it as OFF and got a
placeholder instead of the executing embed. That broke the whole point (a
tracker must run for anonymous visitors).
Make it server-authoritative:
- share.service prepareContentForShare (the single path both share-content
flows use) strips htmlEmbed from served content when the workspace toggle is
OFF; both callers (updatePublicAttachments host page + lookupTransclusionForShare)
resolve the toggle once and pass it. Fail-closed: missing workspace -> OFF ->
stripped.
- NodeView executes whatever it was served in read-only/share mode
(shouldExecute = !editor.isEditable || htmlEmbedEnabled); the disabled
placeholder now only shows in the editable editor when OFF.
Net: anonymous share + toggle ON -> server serves the (admin-authored) embed ->
it executes for everyone; toggle OFF -> stripped server-side from every
share-content path (true kill switch); a non-admin embed can never be served
(save-path strip). No XSS regression in the editable editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- make addTreeNode receivers idempotent (invalidateOnCreatePage guard +
buildTree dedup) so the author's self-echo no longer duplicates the node
- broadcast realtime tree updates for bulk copy/duplicate and import via a
root refetch: PAGE_CREATED now carries spaceId and the WS listener falls
back to refetchRootTreeNodeEvent when no per-node snapshot is present
- remove the now-dead client-relay inbound path (isTreeEvent/handleTreeEvent)
that remained a stale-restriction-cache attack surface
- honest string|null cast for a root move's parent id
- add tests: buildTree dedup; onPageCreated per-node vs refetch branching
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin-only raw HTML/JS embed is a deliberate stored-XSS surface, so gate the
whole feature behind a workspace toggle that is OFF by default; it only works
when a workspace admin explicitly enables it.
- settings.htmlEmbed (boolean, default false) + workspace-update field htmlEmbed,
persisted via WorkspaceRepo.updateSetting with an audit diff. Flipping it is
admin-only (same Manage Settings CASL as other workspace toggles).
- New gate htmlEmbedAllowed(featureEnabled, role) = featureEnabled && admin/owner.
All 7 server write paths (create, duplicate, collab onStoreDocument, REST/MCP/AI
updatePageContent, single + zip import, transclusion unsync) now read the
workspace's settings.htmlEmbed and strip unless (toggle ON AND admin). OFF
(default, or a failed/empty workspace lookup) strips htmlEmbed for EVERYONE
including admins -> existing embeds are cleaned up on next save, none persist.
- Client (defense-in-depth): the /html slash item is hidden unless toggle ON +
admin; the NodeView executes nothing and shows a 'disabled in this workspace'
placeholder when OFF; an admin Switch in Workspace Settings -> General with a
description of the behavior.
- docs/html-embed-admin.md documents the toggle + admin-only + fail-closed
coedit (a non-admin save strips an admin's embed) + execution semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts with the independently-merged ai-agent-roles feature:
- ai-chat.module.ts: keep BOTH AiAgentRolesModule and the public-share
wiring (Share/Search modules, PublicShareChatController, services).
- ai.service.ts: take develop's getChatModel ChatModelOverride superset,
which already covers the public-share model-id-only override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up fixes on the agent-roles feature:
- ai.service: a cross-driver override to the ollama driver (when the
workspace driver is not ollama) now fails with an explicit 503 instead
of silently reusing the workspace base URL, which belongs to a different
provider. Same-driver ollama and openai/gemini overrides are unchanged.
- migration: add a partial unique index on (workspace_id, name) WHERE
deleted_at IS NULL so role names are unique per workspace without
soft-deleted rows blocking re-creation; map Postgres 23505 to a 409
ConflictException on create/update.
- dto: validate the role id as @IsUUID instead of @IsString.
- roles list: do not expose instructions/modelConfig to non-admin members.
The list endpoint now returns a picker view (id/name/emoji/description/
enabled) to members and the full view only to admins (same gate as the
CRUD endpoints). Client IAiRole fields made optional accordingly.
Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and
the non-admin picker-view security invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integrate the already-merged step-limit work from develop. Only conflict was
ai-chat.service.spec.ts: both sides appended a describe block and edited the
import line. Resolved as a union — keep compactToolOutput + the assistantParts/
serializeSteps/rowToUiMessage suites (this branch) AND the prepareAgentStep
suite (develop), importing all symbols from ai-chat.service.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Behaviour change (split out of the test commit per review).
In AI SDK v6 the useChat `onFinish` callback does NOT fire when the stream
errors. A brand-new chat whose very first turn fails would therefore never run
the post-turn path: the chat list was not invalidated and the client never
adopted the server-created chat id — so the failed chat only appeared in
history after a manual refresh (the server already creates the row and stores
the error message). Running the same `onTurnFinished()` handler on `onError`
makes the failed chat show up immediately. The error itself is still surfaced
to the user via the existing `error` state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests for the safety-critical paths: crypto secret-box (round-trip,
tamper detection, wrong key), the SSRF guard (blocked ranges + DNS-rebinding),
the ai-chat tools service, the page-embedding repo, and the
assistant-parts/serialization helpers. Those server helpers (assistantParts,
rowToUiMessage, serializeSteps) are exported ONLY for the tests — no runtime
change.
Also: keyboard a11y on the chat history header and conversation rows
(role/tabIndex/Enter+Space), and DRY refactors that move shared logic into one
place (isToolPart -> tool-parts util; buildInitialValues in the MCP form).
The behaviour-changing edits that previously rode along in this commit are
split out into the following two commits, per review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand all kept the menu open (closeMenuOnClick={false}) while Collapse all
closed it. Make both close on click for consistent behavior, and drop the
now-pointless in-menu isExpanding loading state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- extract collectAllIds / collectBranchIds into tree/utils and use them in
space-tree.tsx instead of inline closures
- drop the duplicate SidebarPageTreeDto, reuse the existing SidebarPageDto
for the /pages/tree endpoint
- type the getSpaceTree client call as api.post<{ items: IPage[] }>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>