Commit Graph
724 Commits
Author SHA1 Message Date
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
Sergey KozyrenkoandClaude Opus 4.8 bfc6b7a0d1 fix(markdown-editor): reject root-relative paths instead of mangling the host
normalizeLinkUrl/normalizeImageSrc prepended https:// after stripping ALL
leading slashes (url.replace(/^\/+/, '')), so a root-relative path was
re-hosted onto its first segment: '/settings/profile' → 'https://settings/profile',
'/uploads/logo.png' → 'https://uploads/logo.png'. The result was non-null, so the
Apply button stayed enabled and the bogus href/src was persisted.

Extract toAbsoluteCandidate: only //host (protocol-relative) is promoted (prepend
the scheme); a single leading slash returns null (this field only accepts absolute
external URLs). Scheme-less hosts and existing protocol-relative cases are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:22:40 +07:00
Sergey KozyrenkoandClaude Opus 4.8 3ea784bc2f fix(markdown-editor): stop a stale table hover-grip from crashing or misediting
A hover grip captures its cell position once on mousemove and was never
invalidated when the document changed. Editing elsewhere (without moving the
mouse) then left cellPos pointing past the shifted doc, so two things could
happen: RowHeaderToggleItem's useEditorState selector called
hasHeaderRow(editor, cellPos) → doc.resolve(pos) → a synchronous RangeError
that takes down the whole editor render tree; and a "Delete row/column" ran
setTextSelection against the stale offset, editing the wrong line.

Guard doc.resolve in hasHeaderRow (an out-of-bounds position has no header
row), and extend the grip's existing scroll-invalidation to fire on editor
'update' too, so the target drops on any doc change and reappears on the next
hover. Adds the first unit tests for table-commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:19:10 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c87a31f38b fix(markdown-editor): drop the U+001F cell-join byte from saved table markdown
Pressing Enter in a table cell makes a second paragraph, and
renderTableToMarkdown joins a cell's block children with a raw U+001F
separator (outside renderChildren). That control byte was written verbatim
into the saved .tmpl/knowledge markdown and round-tripped back unchanged —
an invisible, persisted corruption of the file.

A GFM cell is single-line, so collapse the separator to a space on the
table's rendered output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:13:11 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2067b50359 fix(markdown-editor): protect tables inside a blockquote from cell loss
The line scanner only recognized top-level tables (^ {0,3}), so a `> `-prefixed
delimiter row failed TABLE_DELIMITER_LINE and the whole blockquote table went
unprotected — marked then stripped the prefix, parsed the table, and dropped
cells whose code span / template / URL held a pipe.

Detect a blockquote run, strip each line's prefix, recurse on the inner content
(reusing every rule here, so nested `> >` and fenced blocks inside the quote are
handled too), then re-apply the original prefix. Escaping never touches the
prefix, so untouched rows stay byte-exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:04:12 +07:00
Sergey KozyrenkoandClaude Opus 4.8 5e7dbb7441 fix(markdown-editor): escape URL pipes by cell-count, killing a table-row DoS
escapeRowPipes escaped pipes inside any `scheme://` run via a greedy
/[^\s]*:\/\/[^\s]*/g. Two bugs: (1) a long non-URL token (base64, a hash)
made that regex backtrack quadratically — 120k chars froze for ~11s on every
rich load, a stored, target-influenceable client-side DoS; (2) in a compact,
spaceless row like `|http://a.com|b|` the run swallowed the structural pipes
and escaped them, collapsing the row into one cell.

A URL pipe and a spaceless cell separator are indistinguishable in isolation,
so disambiguate by cell count: only escape scheme-run pipes when the row splits
into MORE cells than the header expects (a genuine phantom-cell URL pipe). A row
already at the right count keeps its structural pipes. The guard also skips the
scan on ordinary rows, and the replacement is a linear /\S+/g pass, so a long
token can no longer drive it superlinearly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:00:25 +07:00
Sergey KozyrenkoandClaude Opus 4.8 19fc4b3092 fix(markdown-editor): track fence length so a 4-backtick block isn't closed early
The escapeTablePipes fence tracker matched a fixed 3-char run (```/~~~) and
compared truncated tokens, so a 4-backtick block — which renderTunedCodeBlock
emits whenever the block's content holds a ``` line — was "closed" by that inner
3-backtick line. Two failures followed: pipes inside the real code block got
`\|` injected into the saved bytes, and a genuine table AFTER the block lost its
protection (the tracker thought it was still inside a fence), dropping cells.

Capture the full run and its trailing text; close only on a same-char run of
length >= the opener with no info string, mirroring CommonMark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:56:09 +07:00
Sergey KozyrenkoandClaude Opus 4.8 81e448cf05 fix(markdown-editor): normalize CRLF in escapeTablePipes so tables keep cells
The pre-lex pipe protection split on '\n', leaving a trailing '\r' on every
line. The '$'-anchored TABLE_DELIMITER_LINE then failed to match '| --- |\r',
so the whole module no-op'd on CRLF input and marked dropped cells whose code
spans / templates / URLs held a pipe — silently losing data on the first load
of Windows- or API-authored content.

Normalize '\r\n' and lone '\r' to '\n' up front (marked re-normalizes the
returned string anyway). Original bytes are preserved when nothing is escaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:53:11 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b015f3ba98 fix(markdown-editor): bound paste link cue to kill ReDoS on large pastes
The link cue in MARKDOWN_CUES was /\[.+\]\(.+\)/ — a double-`.+` that
catastrophically backtracks. isMarkdownLike runs synchronously in
handlePaste on every paste carrying an html flavor, so a large
bracket-heavy non-link paste froze the tab: 40k `[x]` reps → 3.7s,
120k → 33s (measured against the exact regex).

Bound both spans with negated classes (`[^\]]+`/`[^)]+`): linear-time,
matches real links identically (120k adversarial input drops to <1ms),
and now also catches multi-line link text. Add a ReDoS-budget regression
test and an invariant comment for future cue additions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:46:15 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6326719ae1 fix(file-manager): mark the built-in delete action destructive
The built-in single deleteAction's JSDoc promised a "destructive variant"
but the object never set variant:'destructive', so the row-menu Delete
rendered as a default (non-red) item — unlike the bulk delete. Now that
the DropdownMenu/ContextMenu items support variant="destructive" and
file-manager-row forwards action.variant, set the flag so single deletes
read as destructive, matching the bulk action and the documented intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:15:02 +07:00