Commit Graph
683 Commits
Author SHA1 Message Date
Sergey KozyrenkoandClaude Opus 4.8 d49e740821 test(observability): cover dial-timeout + ErrNotConfigured; close conn on exporter error
Unit coverage for NewTelemetryClient: an unset OTEL_HOST yields ErrNotConfigured,
and an unreachable collector returns within the dial timeout instead of hanging
(a TCP-accepting, silent listener drives the WithBlock path).

Also close the grpc.ClientConn on the exporter-creation error paths — a
successful dial followed by a failed exporter New() previously leaked it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:09:38 +07:00
Sergey KozyrenkoandClaude Opus 4.8 62a1445d32 refactor(queue): drop the running-context for a flag; reject restart-without-stop
The two-context design in ddd3916 was correct but fragile: the field named
`ctx` was the liveness signal goroutines must NOT bail on, while the real
cancellation signal was `stopCtx`. That inversion is the exact trap that
produced the earlier happy-path regression (bailing on `ctx`, which a normal
input-close cancels, dropped in-flight results). It also left a latent
restart-without-Stop hazard: a second Start() after input-close orphaned the old
stopCtx and shared wg, deadlocking a later Stop() (with a data race).

Collapse `ctx` into a plain `running` bool (it was only ever read via .Err(),
never awaited), keep `stopCtx` as the single cancellation signal, and guard
Start() on stopCtx so a restart requires a prior Stop() — removing both the
naming trap and the restart hazard.

Comprehensive tests (queue_scenarios_test.go): boundary-N full delivery,
contiguous-prefix-on-abandon, a randomized prefix-invariant fuzz, goroutine-leak,
restart-reject, process-error, double-stop, Running() transitions. The happy-path
tests hang on the pre-rework code and pass here (side-by-side under -race). Live:
a flow's non-empty container dirs of 1/40/100/500 files each deliver every entry;
the error path returns 500 without hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:09:36 +07:00
Sergey KozyrenkoandClaude Opus 4.8 ddd3916e9c fix(queue): only hard-stop workers on Stop(), not on normal input-close
The previous fix (97e5730) had workers and the reader bail on q.ctx, but the
reader also cancels q.ctx on a normal input-close — so the ListContainerDir
happy path regressed: after input closed (buffer names, close, read all N),
workers dropped still-undelivered results and the consumer hung waiting for the
last one. Any non-empty container directory that didn't error early hung the
file-manager request.

Split the signals: q.ctx still tracks "running" (cancelled by input-close or
Stop), and a new q.stopCtx is cancelled only by Stop(). Workers and the reader
bail on q.stopCtx, so a normal input-close drains and delivers every result
while a hard Stop() still unblocks a consumer that abandoned output. Stop()'s
already-stopped guard now checks q.stopCtx (input-close alone must not
short-circuit it, or blocked workers leak).

Tests: DeliversEveryResultAfterInputClose (hangs on the old fix, verified
side-by-side under -race) + StopHardStopsAfterInputClose; deadlock + ordering
still green under -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:02:51 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d6fa58e5d4 fix(observability): don't let an unreachable telemetry collector down the app
A set-but-unreachable OTEL collector hung startup: NewTelemetryClient dialed
with grpc.WithBlock() and no timeout on the deadline-free signal context, so
the process blocked forever before it ever served. And a non-ErrNotConfigured
init error from either observability client was log.Fatalf, killing the
process. Both let an OPTIONAL integration take the whole app down.

Bound the dial with a 10s timeout, and on init failure degrade to a no-op
observer with a logged warning instead of exiting. Verified live: a bad
OTEL_HOST that hung startup indefinitely now boots in ~14s with a warning and
runs a full flow; a reachable collector still boots in ~4s with no warning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:20:43 +07:00
Sergey KozyrenkoandClaude Opus 4.8 97e5730a53 fix(queue): don't deadlock Stop() when the consumer stops reading output
ListContainerDir aborts on the first stat error and stops reading the queue's
output channel, leaving workers blocked on the unbuffered `q.output <- result`
send and the reader blocked on a full `q.queue`; Stop() -> wg.Wait() then hung
forever. Select every pipeline send/wait on q.ctx so a stopped queue unwinds.
On stop a worker returns without msg.cancel(), so later workers also bail via
q.ctx and output ends at a contiguous prefix instead of developing gaps.

Regression test drives the exact hang (unread output -> Stop must return),
verified with -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:29:07 +07:00
Sergey KozyrenkoandClaude Opus 4.8 9876c13ea8 docs(providers): restore the DeepSeek legacy reasoning-format contract
The openai-compat consolidation dropped the comment warning that DeepSeek needs
the legacy top-level "reasoning_effort" string form; without it a maintainer
could add openai.WithModernReasoningFormat() to the shared opts and silently
break DeepSeek thinking mode. Restore it at the shared opts choke point.

Also fix two stale pointers: glm/qwen config.yml cited
WithPreserveReasoningContent() "in glm.go/qwen.go", but it moved to
openaicompat.go during that same consolidation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:02:21 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a87ac2db18 test(api-tokens): cover the token-name length cap
Export tokenNameSchema and pin the 100-character boundary (100 accepted, 101
rejected) plus its trim/default behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:34:05 +07:00
Sergey KozyrenkoandClaude Opus 4.8 f5f7974826 test(validation): cover REST prompt validation and knowledge length limits
- prompts_test.go: PatchPrompt rejects a syntax error, an undeclared variable,
  and a whitespace-only template over REST (the path a raw client hits when the
  UI is bypassed) and does not persist them; valid templates still create/update.
- validation_test.go: validateKnowledgeFieldLengths accepts each field at its
  max length and rejects one character over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:34:04 +07:00
Sergey KozyrenkoandClaude Opus 4.8 72da224033 feat(prompts): validate template syntax on the REST update endpoint
The GraphQL createPrompt/updatePrompt mutations run validator.ValidatePrompt
(Go text/template parse + declared-variable check + trial render), but the
REST PUT /prompts/:type handler only checked the field was present, so a
prompt with a syntax error or an undeclared variable could be stored over
REST and later break rendering. Mirror the GraphQL check in PatchPrompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:01:36 +07:00
Sergey KozyrenkoandClaude Opus 4.8 f92aafb09a fix(api-tokens): align token-name length cap with the backend (100)
The create/edit form allowed 255 characters, but the backend caps token
names at 100 — a longer name cleared client validation and was rejected on
save. Lower the zod cap to 100 to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:44:20 +07:00
Sergey KozyrenkoandClaude Opus 4.8 0cf85ef029 feat(graphql): validate knowledge and API-token mutation inputs
The REST handlers reject empty or oversized knowledge fields and over-long
API-token names through their request-model validate tags, but the GraphQL
mutations — the path the web UI uses — accepted them unchecked, so the same
entity could be stored past its documented limits depending on the caller.

Mirror the REST caps at the resolver boundary:
- createKnowledgeDocument / updateKnowledgeDocument: require content (and
  question on create), and cap content/question/description/codeLang lengths.
- createAPIToken / updateAPIToken: cap the token-name length.

Limits are kept in sync with server/models via mirror comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:44:19 +07:00
Sergey KozyrenkoandClaude Opus 4.8 bcef80de99 fix(graph): reject empty flow title, flow input, and assistant input
The renameFlow / putUserInput / callAssistant GraphQL resolvers passed the
title/input straight to the controller with no non-empty check, while the
equivalent REST handlers reject them with 400. An empty flow title in
particular then fails the Flow model's `required` invariant and breaks the
REST GET /flows listing for that user. Mirror the REST guard at the resolver
boundary, matching the existing createFlow "... is required" checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:16:33 +07:00
Sergey KozyrenkoandClaude Opus 4.8 e1b7d8e5d9 fix(resources): pre-flight blob stats so a ZIP download can't truncate under 200
The streaming ZIP refactor made `ZipResources` open each blob inline while
`streamZipArchive`'s writer commits HTTP 200 on the first byte. A blob missing
on disk (a DB record whose blob file is gone) failed mid-stream, so the client
received a 200 with a central-directory-valid but incomplete archive — the
missing files silently dropped (regression vs main, which buffered then sent).

Stat every blob up front; a missing one now returns before any byte is written,
so `streamZipArchive` emits a clean structured error instead. Keeps the
streaming memory benefit.

Tests (both proven fail-on-unfixed / pass-on-fixed): a unit test asserts the
writer stays empty when a blob is missing, and a download-handler test asserts
a missing blob in a multi-file ZIP returns a clean 500, not a truncated 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:51:54 +07:00
Sergey KozyrenkoandClaude Opus 4.8 9bda603de3 refactor(frontend): prune restatement comments
Drop comments that restate the code they sit on (public-route auth block,
app.tsx catch-all-route label, image-handle overlay description) per the
project's default-zero comment policy. Kept the load-bearing inline note that
password_change_required is local-users-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:35 +07:00
Sergey KozyrenkoandClaude Opus 4.8 dc1ebef25f fix(file-manager): use OKLCH token directly for the multi-drag badge
The multi-drag "N items" drag image set `hsl(var(--primary))`, but the theme
tokens are OKLCH values — `hsl(oklch(...))` is invalid CSS and dropped, leaving
a transparent badge with default text. Reference the tokens directly via
`var(--primary)` / `var(--primary-foreground)` (theme-aware, valid).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:35 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cd9f45d69c fix(oauth-result): drive status text through state so it updates in production
`updateMessage` only mutated a ref, so the visible status ("Authentication in
progress...") never changed in production — it appeared to work only in dev via
StrictMode's double-mounted useLayoutEffect. Call setStatusMessage directly and
drop the dead ref + layout-effect machinery. The OAuth flow itself was already
correct (close/redirect use locals); only the visible text was frozen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:34 +07:00
Sergey KozyrenkoandClaude Opus 4.8 ee9393d4c0 fix(a11y): name sidebar new-actions + mark active editor-menu options
- main-sidebar.tsx: the three icon-only "New" quick-action links wrapped a bare
  <Plus/> with no accessible name (WCAG 2.4.4/4.1.2) — add aria-label
  "New flow"/"New template"/"New knowledge".
- editor toolbar heading/list/table-align menus: single-select options marked
  the active one with only a visual <Check>. Add role="menuitemradio" +
  aria-checked (mirroring the shipped Header-row menuitemcheckbox) so screen
  readers announce which option is active. Visual affordance unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:34 +07:00
Sergey KozyrenkoandClaude Opus 4.8 933a80147e perf(context): memoize providers + system-settings context values
Both provider components built their context `value` as a fresh object literal
each render (providers-provider also re-sorted into a new array and recreated
its setter), forcing every consumer to re-render on each provider render.
Memoize the sorted array, the setter, and both value objects — the two
non-memoized outliers among the app's context providers. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:14 +07:00
Sergey KozyrenkoandClaude Opus 4.8 605b271214 perf(settings-provider): stop per-keystroke full-form validation
Subscribing to `formState.isValid` via useFormState flips RHF's internal
`_proxyFormState.isValid`, which makes the zod resolver re-run over the WHOLE
form (13 agent accordions × ~19 fields + refines) on every keystroke — even
in onSubmit mode. `isValid` is only needed to gate the unsaved-changes dialog,
so validate lazily when that dialog opens instead of subscribing. Live INP on
the Name field: steady-state per-keystroke processing 152ms worst-case → ~5ms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:13 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c517ccbb1e chore(frontend): remove dead code (monaco chunk, setSelection, isNew branch)
- vite.config.ts: drop the manualChunks 'monaco' branch — monaco was removed
  in the monaco→tiptap migration (82b82c5), so the regex never matches.
- use-file-manager-selection.ts: remove `setSelection` — no caller destructures
  it (file-manager.tsx never did); all writes go through rawSelectedPaths.
- settings-prompt.tsx: remove the unreachable `isNew` (promptId === 'new')
  branch — nothing routes to /settings/prompts/new, and it would render a
  "Create Prompt" header over a "Prompt not found" card. The real create path
  is `isUpdate === false`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:10:12 +07:00
Sergey KozyrenkoandClaude Opus 4.8 bfd5b9d42f fix(markdown-editor): give the editor contenteditable an accessible name
The rich editor is a contenteditable div with role="textbox", which is
NOT a labelable element — a sibling `<FormLabel htmlFor>` can't name it,
and two of the three consumers (settings-prompt, template) render no
visible label at all, so the field had no accessible name (Lighthouse
A5). Thread `aria-label` through MarkdownEditorField to the contenteditable
(via the same view.dom passthrough as aria-describedby/aria-invalid) and
to the raw textarea, with each consumer supplying its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 03:46:33 +07:00
Sergey KozyrenkoandClaude Opus 4.8 44b5fef6dc fix(api-tokens): clear the table filter when opening the create row
The inline "Create Token" row is a data row prepended at index 0, so an active
table filter (globalFilterFn) or a non-first page hid it — clicking Create Token
appeared to do nothing. handleCreateNew now calls setFilter(''), which clears the
filter and resets pageIndex to 0 (clearPageOnFilterChange default), so the create
row is always visible.

Live-verified (docker/HEAD backend): with a non-matching filter, Create Token now
shows the create row and clears the filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:37:49 +07:00
Sergey KozyrenkoandClaude Opus 4.8 bbdbdb8666 refactor(resizable): fix panel minSize at 390px instead of a percentage
Follow-up to a55cac3 / 2a3a553. A percentage minSize scales with the screen:
30% is ~700px on a 2560px display and ~1000px on an ultra-wide — far too large
a floor. Give both resizable splits (DetailSplitLayout + flow.tsx) a fixed 390px
per-panel minimum (numeric = px in react-resizable-panels v4). defaultSize stays
a percentage: flow 50%, DetailSplitLayout 45%/55%.

Verified live (chrome-devtools, dev server, real flow + settings-prompt):
- 1280px (narrowest desktop): group 1024px, both mins 390px — no collision
  (2x390 < 1024), drag range 390..633px.
- 2560px: min stays 390px (16.9%), not 691px (30%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:32:41 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2a3a553065 fix(flow): resizable panel sizes as % strings, not bare numbers
Same react-resizable-panels v4 pixels-not-percent pitfall as detail-split-layout
(a55cac3): flow.tsx's desktop two-panel split passed minSize={30}/defaultSize={50}
as numbers, so the 30 was a 30px floor — a user could drag either the central-tabs
panel or the detail panel down to a ~30px sliver instead of the intended 30%.

Use string percentages: minSize "30%", defaultSize "50%".

Verified live (chrome-devtools @1440px, dev server on a real flow): separator
aria-valuemin=30, dragging a panel to its minimum floors at 29.97% (355px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:24:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a55cac3225 fix(detail-split-layout): pass panel sizes as % strings, not bare numbers
react-resizable-panels v4 reads numeric size props as PIXELS, not percent
("Numeric values are assumed to be pixels"). So `minSize={30}` was a 30px
floor — a user could drag either panel down to a ~30px sliver (~2.5% on a
1184px group) instead of the intended 30%. `defaultSize={45/55}` were px too
(they only rendered ~45/55% because default sizes normalize to a ratio;
minSize is an absolute per-panel constraint and is not normalized).

Use string percentages so the constraints mean what they read: minSize "30%",
defaultSize "45%"/"55%".

Verified live (chrome-devtools @1440px): separator aria-valuemin 2.536 -> 30;
dragging a panel to its minimum now floors at 29.97% (355px) instead of 30px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:14:28 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cc908e5108 docs(frontend): prune restatement/narration comments, keep only real gotchas
Re-reviewed every frontend comment against the "names a concrete wrong action"
rubric. Cut pure restatements, change-narration, self-defense and bug-history;
trimmed mixed comments down to their load-bearing gotcha/contract; kept genuine
framework/API/security notes. Relocated a misplaced JSDoc in resources-provider
that sat on `error` but described `resources` (fields are alphabetically sorted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:35:17 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6b01c109d2 docs(breakpoints): drop the comment (768/1280 are recognizable Tailwind md/xl)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:51:48 +07:00
Sergey KozyrenkoandClaude Opus 4.8 330223bc28 refactor(breakpoints): align useBreakpoint to Tailwind tokens; field height goes pure CSS
The JS "desktop" threshold was 1200px, which is no Tailwind breakpoint, so anything
mixing useBreakpoint with a CSS `md:`/`xl:` variant for the same decision could drift
in the 1024–1279 zone. Move the threshold to Tailwind `xl` (1280) — mobile already
sat on `md` (768) — so JS layout switches and CSS variants now share the same values.

With the breakpoint a real token, MarkdownEditorField drops useBreakpoint and expresses
its height in plain CSS: fixed `h-[calc(100dvh-5rem)]` below xl (stacked forms, internal
scroll), `xl:min-h-0 xl:flex-1` to fill its pane on the desktop split. No JS hook, no
comment. Note: the split view now appears at ≥1280 instead of ≥1200.

Verified <xl live (fixed 1194px, internal scroll); the ≥1280 branch resolves to the same
`min-h-0 flex-1` fill already measured at 1440px — this browser window caps at ~1141px so
the wide layout could not be re-observed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:47:34 +07:00
Sergey KozyrenkoandClaude Opus 4.8 217ee785c4 docs(markdown-editor): drop the height comment (obvious CSS + bug-history)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:24:54 +07:00
Sergey KozyrenkoandClaude Opus 4.8 fdc533aba4 docs(markdown-editor): trim the height comment to the two load-bearing gotchas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:22:06 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d378b9f582 fix(markdown-editor): cap the mobile editor height so a long doc scrolls inside
The mobile/tablet branch used `min-h-[calc(100dvh-5rem)]`, only a floor — the
stacked forms have no flex parent to cap it, so a long document (a big agent
prompt is ~10k px of text) stretched the editor to its full content height and
the whole page grew with it. Make it a FIXED `h-[calc(100dvh-5rem)]` so the box
stays one viewport tall and the content scrolls inside it. Desktop (fills its
pane) is unchanged. Live-verified: the Adviser prompt editor is now 1194px with
an internal scrollbar instead of 10.6k px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:08:22 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b4949eada3 refactor(markdown-editor): field owns its responsive height for every consumer
The mobile fixed height was knowledge-only; make it the field's job for all. The
field now derives its own height from useBreakpoint — the SAME hook the layouts
switch on — so it fills its flex parent on desktop and takes a near-viewport fixed
height on mobile/tablet, everywhere. Knowledge drops its last height className.
This also fixes the settings-prompt and template editors, which had no mobile
height and were cramped in their stacked mobile layouts. Live-verified all three
on desktop (fills) and mobile (min-h calc(100dvh-5rem)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:55:08 +07:00
Sergey KozyrenkoandClaude Opus 4.8 57988fba58 refactor(markdown-editor): default the field to fill its flex parent
Every consumer embedded MarkdownEditorField in a `flex min-h-0 flex-col` column and
repeated `min-h-0 flex-1` to make it fill. Bake that default into the field (applied
first so a consumer can still override the height), and drop the boilerplate:
settings-prompt and template pass no className now, and the knowledge field only
keeps its one deviation — a fixed `min-h-[calc(100dvh-5rem)]` for the mobile stack
where it isn't inside a flex box. Live-verified all four surfaces: rich editor fills
(flex-1, min-h 0) on prompt/template/knowledge-desktop, and knowledge-mobile keeps
the fixed 100dvh-5rem height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:41:33 +07:00
Sergey KozyrenkoandClaude Opus 4.8 05394d1492 fix(settings): widen two dialogs pinned to 512px by the same sm:max-w-lg default
The prompt-validation dialog (max-w-2xl) and the provider-test-results dialog
(max-w-3xl) had the same latent bug as the Diff dialog: a bare max-w-* cannot
override DialogContent's default sm:max-w-lg at ≥sm, so both rendered at 512px
instead of 672/768px. Prefix with sm: so they reach their intended width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:46:13 +07:00
Sergey KozyrenkoandClaude Opus 4.8 be04e03239 fix(settings-prompt): widen the Diff dialog (max-w-7xl never applied on desktop)
The Diff dialog passed a bare `max-w-7xl`, but DialogContent's default already
sets `sm:max-w-lg`. tailwind-merge keeps both (different variants), and at ≥sm the
`sm:` rule wins the cascade — so the dialog was pinned at 512px and the split diff
overflowed with a horizontal scrollbar. Use `sm:max-w-7xl` so it overrides the
default at the same breakpoint, matching the app's other wide dialogs. Verified
live: the real Diff dialog now renders at 1280px with no horizontal scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:43:52 +07:00
Sergey KozyrenkoandClaude Opus 4.8 87662d419a refactor(ui): rename DetailTwoPanelLayout → DetailSplitLayout, left/right → panel/content
Renames the shared two-pane detail layout to DetailSplitLayout (file
detail-split-layout.tsx) and its slots left/right/rightClassName to
panel/content/contentClassName, which name the actual roles — a form/meta panel
beside the main editor content — instead of bare position. Updates all four
consumers (knowledge form, template, settings prompt, settings provider).

Also drops a dead GripVertical child passed to <ResizableHandle withHandle /> (the
handle renders its own grip and ignores children) and gives the left panel a
bg-card fill so the gutters around the centered card match the card colour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:34:39 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a1d3f13793 test(markdown-editor): exhaustive HeadingAutoformat coverage; fix multi-line promotion
Adds markdown-editor-heading-autoformat.test.ts (57 cases): levels, marker stripping,
negatives, hardBreak protection, every trigger path (delete/split/merge/insertText/
setContent/insertContent), the canReplaceWith container gate, multi-target ordering,
mark/variable/tag preservation, byte-fidelity interaction, and re-entrancy. The 6
ad-hoc Case B tests move here from the extensions suite.

Writing the coverage surfaced a soundness bug: promoting a MULTI-LINE paragraph whose
first line starts with `# ` (e.g. `# a`+Shift+Enter+`# b`, then deleting the lead-in)
emitted a heading containing a hardBreak, which re-parsed as TWO headings on the next
load. HeadingAutoformat now skips any paragraph that contains a hardBreak — a heading
is single-line — so such a block stays body text and escapeLineLeadingBlockMarkers
round-trips it. Single-line promotion is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:11:01 +07:00
Sergey KozyrenkoandClaude Opus 4.8 9d0e1924a1 feat(markdown-editor): promote a block that starts with # to a heading on any edit
The heading input-rule fires only on the keystroke that types `# ` at a block
start. A block that comes to start with `# ` any other way — deleting the text
before an existing `#`, pressing Enter in front of it, pasting — stayed a
paragraph, diverging from CommonMark (a leading ATX marker IS a heading) and from
what the same text becomes on reload.

HeadingAutoformat, a ProseMirror appendTransaction plugin, promotes such a
paragraph to the matching heading (stripping the marker) on every doc-changing
transaction. It keys off the block's first child, not textContent, so a `# ` after
a hardBreak (Shift+Enter) correctly stays body text — that is a soft break inside
the paragraph, so the block does not start with `#`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:22:38 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cb9012ce11 fix(markdown-editor): keep line-leading # / > in a paragraph as body text
A paragraph line starting with "# " or "> " (reachable by Shift+Enter then such a
line — the heading input-rule only fires at a block start, not after a soft break)
serialized verbatim through the identity encoder and re-parsed as a heading /
blockquote on the next load, silently changing the block TYPE of body text.

Fix it as a symmetric pair: the paragraph serializer escapes a line-leading
`# `/`> ` to `\# `/`\> `, and the tuned marked Lexer's escape tokenizer — otherwise
off to keep `\d`/`\|`/`\\` literal — unescapes exactly `\#`/`\>` back on load. Only
these two markers are handled; `-`/`*`/`+`/`1.`/fences overlap with literal
regex/glob/backref escapes (`\*`, `\1`, `\|`) the editor must preserve, so they are
left alone. Real headings/blockquotes, regex/glob literals, and mid-line `#` are
unchanged (full byte-fidelity corpus stays green); adds pin tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:14:56 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2b7931732a refactor(markdown-editor): drop the toolbar roving-focus childList observation
The roving-tabindex MutationObserver watched childList:true/subtree:true, justified
by a comment claiming "the table control swaps button↔menu". Verified against the
source: the toolbar's item set is static (every control renders unconditionally;
state only drives pressed/disabled props), and each menu keeps its `data-toolbar-item`
trigger in the bar while swapping its content in a Radix body portal — outside the
observed subtree. So childList observed nothing. Keep only the load-bearing
attributeFilter:['disabled'] + subtree (a disabled toggle changes the roving set),
and correct the comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:24:33 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2835fd3302 test(markdown-editor): close four coverage gaps found in review
- history: assert resetUndoHistory actually empties the undo stack
  (editor.can().undo() === false), not just that it doesn't throw — a silent
  no-op regression would otherwise pass.
- field: assert insertAtCursor is a no-op (no onChange) when the field is
  disabled — the documented "a mid-save variable click can't dirty the form"
  guard was untested.
- content-integrity: the pipe-table generative test now also asserts each
  pipe-bearing atom survives as an escaped code span, not just the row sentinels.
- code-fence: fix the pinned-limitation comment — an upstream fix turns this
  test RED (the canary), it does not "flip green".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:45:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 786e416dc5 fix(markdown-editor): add a sub-pixel tolerance to the toolbar wheel end-check
The horizontal wheel-scroll handler treated the strip as "at end" only when
scrollLeft + clientWidth exactly reached scrollWidth. At fractional browser zoom
(125%/150%) those metrics never line up to the pixel, so the end was never
detected and the handler kept preventDefault-ing the wheel — locking page
scroll while the pointer was over the toolbar. Allow a 1px tolerance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:41:26 +07:00
Sergey KozyrenkoandClaude Opus 4.8 cf34a7b3df refactor(markdown-editor): drop a dead barrel export, a band-aid, and rename a param
- index.ts: stop re-exporting findVariableUseRanges — every consumer imports it
  from the module directly, so the barrel entry was dead.
- textarea.ts: drop Math.max(0, …) around textarea.selectionStart/End; both are
  spec non-negative and non-nullable for a real HTMLTextAreaElement, so the guard
  protected nothing. (The Math.max in the scrollTop math stays — it guards a real
  negative subtraction.)
- link/image handles: rename the Popover onOpenChange param `next` → `isOpen`,
  matching the boolean-naming convention and TableHandles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:39:50 +07:00
Sergey KozyrenkoandClaude Opus 4.8 bca7b6747f refactor(markdown-editor): trim restating/defending comments per the zero-comment rule
Drop comments that restate adjacent code or defend a design choice, keeping only
the clauses that carry a non-derivable contract:
- useMarkdownEditor / useTableHandles: delete the responsibility-enumeration and
  "rewriting only this hook" banners.
- clearLineContents: drop the "(Notion's Clear contents)" trivia, keep the
  "structure intact, not a delete" distinction.
- normalizeImageSrc: keep the SVG-can-carry-script rationale, drop the restatement.
- nextVariableRange: keep the shared-by-both-panels invariant, drop the algorithm
  walkthrough.
- image edit-form isEditing prop, two highlight-test negatives, and a table-pipes
  test perf war-story: drop the restatements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:37:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 fde073e393 fix(markdown-editor): give the link edit-form label/aria-describedby parity
The link edit-form relied on a placeholder + aria-label with no visible <Label>,
and its invalid-URL alert had no aria-describedby back to the input — both
present on the sibling image edit-form. A screen-reader user who tabbed onto the
field after the error fired never heard why it was invalid. Add a visible Label
(via useId) and wire aria-describedby to the error id, matching ImageEditForm.

Also trim two comments per the zero-comment rule: the normalizeLinkUrl-return
restatement (documented at the callee) and the "(matches Docs/Notion)"
competitor-justification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:33:31 +07:00
Sergey KozyrenkoandClaude Opus 4.8 215c1cd8c9 test(markdown-editor): raise the generative content-integrity test timeouts
The three property-based tests each run 120–300 full editor round-trips and sit
right at vitest's default 5s limit, so they flaked to a timeout under a loaded
or serialized full-suite run (never in isolation, and never on an assertion).
Give them an explicit 30s budget so the full suite is deterministic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:25:40 +07:00
Sergey KozyrenkoandClaude Opus 4.8 418b1eae65 fix(markdown-editor): write the table-grip open ref synchronously to close a race
openRef was mirrored from the `open` state through a passive effect, but the
document-level mousemove handler and the stale-target droppers read
openRef.current on continuous events. Between a grip click (setOpen) and the
effect flush, a mousemove saw the stale null and retargeted the grip — and the
about-to-open menu — onto a neighbouring cell. Set the ref synchronously inside
onMenuChange before setOpen and drop the mirroring effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:54:30 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b4fb572efa fix(markdown-editor): dismiss table hover-grips on window resize
Unlike the link and image handles, TableHandles registered only scroll (and,
now, editor update) invalidation — never resize. Resizing the window reflowed
the table but left the fixed-position grips glued to their old coordinates,
detached from the table, until the next hover. Mirror the sibling handles and
drop the target on resize too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:44:36 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6c3a011fe2 refactor(markdown-editor): share the .tiptap-content scroll-parent hook
The three overlay handles (link/image/table) each found their dismiss-on-scroll
parent with a hardcoded closest('.tiptap-content') ?? window, while the class
itself lived only inside markdown-editor.tsx's className string. A styling
refactor renaming that token would have silently sent all three to `window`
(whose scroll never fires for the inner overflow-auto), freezing popovers/grips
at stale coordinates — with no type error or failing test.

Export EDITOR_CONTENT_CLASS + getEditorScrollParent from the leaf styles module
and consume both from the className and the three handles. Behavior-neutral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:41:35 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b214bafe60 fix(markdown-editor): key the link popover on the link start, not its full range
The link edit popover was keyed on `${range.from}-${range.to}`. Typing inside a
link grows range.to on every keystroke (the character inherits the link mark), so
<LinkEditForm key={key}> unmounted and remounted each keystroke and re-seeded its
URL field from initialUrl — silently discarding an in-progress URL edit and
churning the popover DOM.

Key on range.from alone: it is stable while the caret stays in the same link, and
still changes when the selection moves onto a different link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:27:50 +07:00