198 Commits

Author SHA1 Message Date
Sergey Kozyrenko 1ee2a6f7c9 chore(frontend): replace 19 explicit any with precise types
Lint warnings 44 → 25. The remaining 14 `any` warnings sit in
settings-provider.tsx (provider config form + test results dialog,
needs domain knowledge — deferred per scope). The other 11 warnings
are React Compiler "Compilation Skipped" notices, not code issues.

- lib/log.ts: any → unknown (5) — logger accepts arbitrary input
- shared/markdown.tsx: typed the recursive ReactNode walker with a
  `hasChildrenProp` type guard around `isValidElement`; the components
  map now uses react-markdown's `Components` type so `code` / `pre`
  overrides infer their props (5)
- settings-prompt.tsx: `ControllerProps.control: Control<FieldValues>`,
  `field: ControllerRenderProps<…>`, validation result typed against
  `ValidatePromptMutation['validatePrompt']`, dropped a stray
  `as any` on `removeEventListener` options (4)
- settings-prompts.tsx: row sub-component params typed via
  `Row<T>` from @tanstack/react-table (2)
- settings-providers.tsx: provider icon map typed against
  `SVGProps<SVGSVGElement>`, row sub-component via `Row<Provider>`,
  recursive `getFields(obj)` narrowed from `any` to `unknown` with a
  single boundary cast for `Object.entries` (3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:54:41 +07:00
Sergey Kozyrenko c3bd1d30f2 test(frontend): de-flake DetailNavigationSheet typing test via fireEvent.change
Baseline 8/10 fail (80% flake) → 20/20 pass. Root cause was `user.type`
racing the Radix Sheet focus trap, not the `useDebounce` macrotask
theory from the prior investigation: with `defaultOpen=true` Radix sets
`body { pointer-events: none }`, swallowing user-event's internal click,
so keystrokes route to whatever element currently holds focus (the
SheetContent close button) instead of the input. `inputValue` stays
empty and the listbox never narrows.

`fireEvent.change` mirrors the sidestep already used in the
URL-filter AND test in the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:36:26 +07:00
Sergey Kozyrenko 4396aa774f style(frontend): trim war-story comments in InputSearch
Followed up on the "no war-story comments" rule (memory file added a
few commits ago, then promptly broken in the X-button addition).
Pass through every comment with the test "remove it — does a senior
reading the code for the first time understand less?".

Removed:
- "Same two-cell shape as DataTableFilter, see its docstring" — cross-
  file rationale belongs in commit history.
- "Hint shown over the collapsed trigger. Kbd self-themes…" — Kbd
  configuration detail, not part of this component's contract.
- "Mirror size='sm' button geometry used by HeaderButton" — micro
  implementation detail.
- "Collapsed trigger sits flush… has-[>button]:ml-[-0.45rem] handles
  the analogous adjustment" — CSS quirk story.
- "The trigger is a real <button> — never a <div> with onClick" —
  generic HTML/a11y factoid, not specific to this file.
- "The component is controlled — parent owns searchQuery" — visible
  from the props shape.
- "Belt-and-braces for programmatic clears" — defensive-style war
  language, replaced with one line stating the effect's purpose.
- "Initialise expanded if the parent already has a non-empty query" —
  duplicates `useState(() => searchQuery.trim().length > 0)`.

Kept (in single-line / sub-3-line form):
- `COMMIT_DEBOUNCE_MS` shares timing with DataTableFilter.
- `External → local sync` effect intent.
- `rAF` before focus so motion's transform has started.
- `handleInputBlur` reads `localValue`, not the prop (subtle invariant).
- Auto-collapse effect purpose.
- `inputRef.current?.focus()` in `handleClear` (otherwise auto-collapse
  fires and the field disappears under the user).
- Top-level JSDoc shrunk from 28 lines to 5 — what it does plus the
  two equivalent clear paths.

Behaviour unchanged. 15/15 tests still pass, lint clean. File: 340 → 260
lines (-24%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:10:01 +07:00
Sergey Kozyrenko 237179524b feat(frontend): add trailing X clear button to InputSearch (parity with DataTableFilter)
`DataTableFilter` already ships a conditional trailing `<X>` clear
button; `InputSearch` (the collapsible variant used for /knowledges
semantic search and the same shape sitting in flow detail searches)
only offered keyboard Esc to clear. Unify so every search field across
the app has the same shape.

Implementation:
- Extract a shared `handleClear` used by both the new X button and the
  first-Esc branch. The path cancels any pending debounce, emits `''`
  upstream, and explicitly returns focus to the input — this matches
  the Esc-clear path (where focus is already on the input) and
  side-steps the programmatic-clear collapse effect, so the field
  stays open and ready for fresh typing.
- Render the X only while `isExpanded && localValue` so it can never
  appear on a collapsed/empty field. `aria-label` is contextual:
  "Clear search knowledge documents" / "Clear semantic search" / etc.,
  derived from the host page's `ariaLabel`.
- Update the JSDoc to describe both clear entry points.

Tests (input-search.test.tsx, 15 total — 10 baseline + 5 new):
- absent on empty mount
- present on deep-linked non-empty mount
- shows/hides as the user types and clears
- click clears value, emits `''` upstream, keeps focus on input,
  prevents the trigger from re-appearing
- click mid-typing drops any pending debounce — no leaked emit

Browser verified on /knowledges semantic search:
beforeClear { value: 'jwt', url: '?qs=jwt', xPresent: true }
afterClear  { value: '',    url: '',       xPresent: false, focusOnInput: true }

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:05:20 +07:00
Sergey Kozyrenko b691e05879 fix(frontend): collapse duplicate GraphQL types from v6 codegen migration
After the `chore(frontend): upgrade graphql-codegen toolchain` upgrade
(8a3d4e9) the generated `src/graphql/types.ts` carried every base type
twice — once as `export enum Foo` (from `typescript`) and once as
`export type Foo = 'a' | 'b'` (from `typescript-operations`). Vite's
dependency scan failed on the duplicate declarations with 29
`Identifier 'X' has already been declared` parse errors, which made
the dev server occasionally die on cold start.

Root cause: this was a stale v5-style config running on v6 plugins.
The v6 migration guide is explicit — `preResolveTypes` has been
removed, and `typescript-operations@6` now emits base types itself,
so the canonical setup is `plugins: ['typescript-operations']` rather
than `['typescript', 'typescript-operations']`. Keeping both meant the
two plugins independently emitted the full type set in different
shapes.

Bisect confirmed the split: `typescript` alone → 1327 lines, no
duplicates; `typescript-operations` alone → 1311 lines, no
duplicates; the pair → 2639 lines with 29 duplicate type names.
With `typescript-react-apollo` also in the mix the regenerated file
ballooned to 8819 lines, half of which was the shadow set.

Fix:
- Drop the `typescript` plugin from the codegen config.
- Drop `preResolveTypes: true` (removed in v6).
- Add `enumType: 'native'` so the call sites that use enum-style
  access (`KnowledgeDocType.Answer`, `AgentConfigType.Adviser`, …)
  keep compiling — without it, v6 defaults to string-literal unions.

Result:
- `src/graphql/types.ts` shrinks 8819 → 7586 lines, zero duplicate
  declarations.
- `KnowledgeDocType`, `AgentConfigType`, etc. remain real TS `enum`s
  so `KnowledgeDocType.Answer`-style access keeps working.
- `pnpm run build` clean, lint clean, 531/531 tests passing.
- Vite dev start no longer prints `[PARSE_ERROR] Identifier X has
  already been declared`. `/knowledges` (the page that exercises
  enum access) loads with 10 rows and zero console errors.

Migration guide:
https://the-guild.dev/graphql/codegen/docs/migration/operations-and-client-preset-from-5-0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:21:36 +07:00
Sergey Kozyrenko a3d19f077a revert(frontend): drop unfounded resolve.dedupe + war-story comment
Reviewing 17451e7: the dedupe was added in reaction to a runtime
`Cannot read properties of null (reading 'useRef')` that I attributed
to dual React copies via pnpm hoist. But I applied two changes at
once — clearing `node_modules/.vite` and adding dedupe — and never
isolated which one actually fixed it.

Empirical re-check just now: production build, dev server, and
the affected page all work with dedupe removed. The real cause was
the stale Vite pre-bundle (`.vite/deps/`), which doesn't auto-rebuild
after `pnpm add` in a running dev server. Cache clear alone fixes it.

That makes the dedupe defensive hygiene at best, and the 7-line
war-story comment alongside it actively misleads — it pins a problem
on a mechanism that wasn't the cause. Removing both keeps the config
honest. QA report updated to record the misdiagnosis as a process
note instead of an architectural rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:03:49 +07:00
Sergey Kozyrenko 2bb2fd5d98 style(frontend): apply prettier formatting on filter test files
Run `pnpm prettier:fix` brought three files into compliance:
- data-table.test.tsx — new race-scenario suite added in the previous
  commit; prettier preferred a slightly different multi-line shape for a
  couple of `render(<FilterHost />)` calls.
- input-search.test.tsx — new file in the previous commit; same minor
  multi-line reshaping for `render(<SearchHost />)`.
- settings-api-tokens.tsx — pre-existing single-line nested ternary that
  predates this branch, now collapsed to one line per prettier.

No behavior change. Format-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:47:34 +07:00
Sergey Kozyrenko 17451e75dd refactor(frontend): migrate filter/search debounce to use-debounce npm
After landing the imperative-timer fix and extracting `useDebouncedCallback`
into our own hook, the use-site count climbed past three and the case for
a single, battle-tested contract beat the case for in-tree code. Drops
both our `useDebouncedValue` and `useDebouncedCallback` and routes every
caller through `use-debounce@10.1.1` (1M+ weekly, ~3KB gzip).

API mapping at each use-site:
- value debounce: `useDebouncedValue(v, ms)` → `const [v] = useDebounce(v, ms)`
- callback debounce: `useDebouncedCallback(fn, ms)` → identical signature,
  same `.cancel()`/`.isPending()` semantics we relied on, plus `.flush()`
  and leading/trailing/maxWait options we now get for free.

Files touched (7 use-sites): data-table.tsx, input-search.tsx,
use-table-state.ts, use-table-query-filter.ts, knowledges-provider.tsx,
use-flow-files-search.ts, use-detail-navigation.ts.

pnpm + Vite gotcha: pnpm hoists into `.pnpm/<name>@<ver>/`, so any
transitive `import React from 'react'` from a third-party package can
resolve to its own physical copy. With one path through our code and
another through use-debounce, the runtime crashed at `useRef` with
"Cannot read properties of null (reading 'useRef')". Added
`resolve.dedupe: ['react', 'react-dom']` in vite.config.ts so the
bundle ships exactly one React.

Tests:
- new input-search.test.tsx (10 tests) covers collapsed/expanded
  presentation, expand-on-click + rAF focus, burst-typing coalesce,
  Esc-clear mid-debounce, two-stage Esc semantics, external-source-wins
  race, Ctrl+F / Cmd+F global hotkey expansion.
- data-table.test.tsx +5 tests: deep-link initial filterValue,
  6-keystroke burst coalesce, unmount-mid-debounce cleanup, two
  independent DataTables on one page (settings/prompts shape),
  back-button-style external clear, three-round type/clear stress.
- full suite: 541/541 passing, lint 0 errors.

Manual smoke via Chrome DevTools MCP on every page that mounts a
filter input — /flows, /templates, /settings/api-tokens,
/settings/prompts (both tables independent), /knowledges (filter
+ semantic search hitting the server through ?qs=). All scenarios
collapse to a single `[{v="", u=""}]` transition; no URL re-emit
after X, no leftover timer after Esc, no cross-table leakage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:28:27 +07:00
Sergey Kozyrenko e451d23aab refactor(frontend): extract debounced filter emit into useDebouncedCallback
The previous iteration moved DataTableFilter / InputSearch off
`useDebouncedValue` and onto an inline `setTimeout` + `pendingTimerRef`
+ `cancelPendingEmit` dance. That fixed all four race scenarios but
left imperative scheduling living inside components — a code smell
flagged in review. After surveying the React 19.2 surface area
(`useDeferredValue` is for *render* priority, not side-effect throttling;
`useTransition` doesn't apply to controlled inputs; `useEffectEvent` is
restricted to handlers called from within Effects) and `use-debounce`'s
API, the right shape is a small, dependency-free hook.

`useDebouncedCallback(fn, delayMs)` returns a stable callable with
`.cancel()` / `.isPending()`. Timer state lives in a closure variable
inside a `useState` lazy initialiser, so:
- the returned identity never changes (safe in effect deps and memoised
  children);
- there are no refs to read during render (no React Compiler complaints);
- `fn` and `delayMs` go through `useLatestRef` so the dispatched call
  always sees the freshest closure — inline arrows in parents are free.

DataTableFilter / InputSearch shrink to one useState + one useRef
(`lastEmittedReference` distinguishes our own router round-trip from a
true external change) + one useEffect (external sync). All four
regression tests added in the previous commit still pass; five new unit
tests pin `useDebouncedCallback` semantics (burst coalescing, cancel,
isPending, latest-closure read, unmount cleanup). Browser repro on
/flows for both X-after-flush and X-before-flush scenarios: a single
transition `v="" u=""`, no resurrection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:48:10 +07:00
Sergey Kozyrenko ba354f3701 refactor(frontend): one outbound timer for filter input emit
Previously DataTableFilter (and InputSearch) layered four cells holding
the same logical value — `localValue` (sync useState), `debouncedValue`
(async useState inside `useDebouncedValue`), `lastEmittedReference`
(ref), and the parent's `query` prop (async round-trip) — and stitched
them together with two opposing useEffect's. The async `debouncedValue`
cell could be read out of phase from any other effect, which leaked
through as four distinct races: X clear after debounce flush (URL/state
snap back ~50–80 ms), X clear *before* debounce flush (pending timer
fires after clear), external `query` change mid-debounce (pending
typed value clobbers external), and tail-end character loss on fast
typing across the round-trip boundary.

Drop the extra storage cell. The debounce is now an imperative
`pendingTimerReference` mutated only by `handleChange`,
`cancelPendingEmit`, and the external-sync effect. All outbound
emission goes through one `emit(next)` function that synchronously
cancels any pending timer before sending. `handleClear` → `emit('')`.
External `query` change → cancel pending + accept. Unmount → cancel.
`onQueryChange` lives behind `useLatestRef` so effect deps stay
stable across parent re-renders.

Four regression tests in data-table.test.tsx pin all four scenarios.
Verified by rolling back the fix temporarily — the X-clear race test
fails with the exact `emitted = ['', 'alpha', '']` from the original
browser repro. With the fix in place all four pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:24:09 +07:00
Sergey Kozyrenko 4a83b29c3d fix(frontend): kill X-clear race that resurrected the filter query
DataTableFilter / InputSearch listed `onQueryChange` (an inline arrow from
the parent — fresh reference per render) in the emit effect's deps. After
the X-clear handler set `lastEmitted=''` and propagated upstream, the
parent re-rendered and handed back a new handler reference, re-running
the effect while `debouncedValue` was still the pre-clear value
(`useState` inside `useDebouncedValue` hadn't yet been updated by its
trailing `setTimeout`). The effect saw `debouncedValue='alpha' !==
lastEmitted=''` and re-emitted `'alpha'`, snapping URL/state back for
~50–80 ms. JSDOM regression captured `emitted = ['', 'alpha', '']`.

Stash the handler in `useLatestRef` and drop it from deps so the effect
fires only when the observed value actually changes. Same fix in
InputSearch's Esc-clear path. New test in data-table.test.tsx pins the
behavior — fails with the previous code (`['', 'alpha', '']`), passes
now (`['', '']`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:09:07 +07:00
Sergey Kozyrenko 606eb7bf1f style(frontend): trim JSDoc fluff on DataTable empty-state additions
The JSDoc blocks added in afb9b63 mostly paraphrased the 12 lines of
code below them. Reviewing line-by-line:

- "two axes" / "filter active vs no filter" / per-state copy — code
  reads identically and is right next to the comment.
- "kept inline because tightly coupled to effectiveQuery" — every
  helper in this file is inline, that's the existing convention.
- "colSpan={columns.length} parent" — the one call site is 10 lines
  below in the same file.
- "for callers that have not migrated yet" — `empty?:` being optional
  already says this.
- "so existing tests stay green" — the test itself documents the
  contract; deleting the fallback fails it loudly.

Kept the one piece TypeScript can't express: a single-line hint that
`entityName` wants the plural lowercase form so the generated copy
reads naturally mid-sentence. That shows up in IDE autocomplete and
saves a "Flows" → "No Flows match" first-try mistake.

No behaviour change. 28/28 DataTable tests pass, 0 ESLint errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:57:53 +07:00
Sergey Kozyrenko afb9b637ef feat(frontend): contextual DataTable empty state via shadcn <Empty>
Resolves QA report observation O9. Every list page used to render the
bare "No results." label when its DataTable had no rows — both for a
truly empty dataset and when a filter narrowed the result set to zero.
The Resources page already had a better pattern (shadcn <Empty> block
with a filter-aware copy that cited the query), so propagate it to the
shared DataTable.

API
- New optional `empty?: { entityName?: string }` prop on `DataTable`.
- When `entityName` is provided, the body cell renders an `<Empty>`
  block:
    filter active → Search icon + "No matches" + "No <entity> match
    <code>{query}</code>. Try a different query."
    no filter      → Inbox icon + "No <entity> yet"
  and the pagination footer mirrors the entity name ("No <entity>"
  instead of "No results").
- When `entityName` is omitted, the existing bare "No results." cell
  and "No results" footer are kept verbatim — preserves backward-compat
  for call sites that have not migrated and for the existing test that
  asserts the legacy copy.

Per-page wiring
Plural lowercase names match how a screen reader reads them mid-
sentence:
  flows                  → "flows"
  templates              → "templates"
  knowledges             → "knowledge documents"
  /settings/providers    → "providers"
  /settings/api-tokens   → "API tokens"
  /settings/prompts ×2   → "agent prompts" / "tool prompts"

Tests
- Existing `'No results.'` assertion stays valid: it renders a
  DataTable without the `empty` prop, exercising the legacy fallback
  path.
- Added 3 new cases under `DataTable — empty results`:
    1. legacy fallback when `empty.entityName` is omitted
    2. data-empty "No <entity> yet" when no filter is active
    3. filter-empty "No <entity> match <query>. Try a different
       query." with the query cited verbatim

Verified
- 0 ESLint errors, 44 pre-existing warnings.
- 511/511 vitest pass in sequential mode and when the affected files
  run isolated. A pre-existing concurrency flake in
  `detail-navigation-sheet.test.tsx` (`typing narrows the listbox
  immediately when searchDebounceMs=0`) occasionally surfaces under
  vitest's default `--file-parallelism` — unrelated to this change,
  passes deterministically with `--no-file-parallelism` and when the
  file is run in isolation.
- Browser sweep across 7 tables (`/flows`, `/templates`,
  `/knowledges`, `/settings/{providers,api-tokens,prompts(×2)}`) all
  render the new copy with `?q=ZZZZZZ`.

References
- shadcn `Empty` component (already vendored in
  `components/ui/empty.tsx`)
- shadcn.io `tables-empty` block FAQ (best-practice: distinguish
  filter-empty vs data-empty via `hasActiveFilters` boolean, ground
  the empty cell in a Lucide icon, surface a "next-step" hint).
- Existing precedent in `pages/resources/resources.tsx` —
  `No resources match <code>{q}</code>. Try a different query.`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:31:30 +07:00
Sergey Kozyrenko 25e19e5be8 revert(frontend): keep provider clone URL as ?id= (revert part of 04702c3)
Reverts the O10 part of commit 04702c3. The original change renamed
the provider clone URL parameter from `?id=` to `?clone=`. On second
look the rename solved no real problem:

- No security issue: PentAGI is a single-tenant local admin tool, so
  there is no cross-tenant info leak from a shared URL.
- No bug: the form behaviour was identical before and after.
- No UX impact: the URL is generated by the "Clone" action, not typed
  by users; the bare `?id=` is also explicit enough in context
  (settings-provider.tsx gates it on `isNew`, so the meaning is
  "source provider to clone from").

What the rename did cost: it broke backward compatibility with any
bookmarks/links saved in `?id=` form. Restore `?id=` to remove the
needless churn.

The rest of commit 04702c3 (O7 dialog case, O8 "API Tokens" unify,
O11 button label + JSDoc, O12 italic placeholder) addressed real
inconsistencies and stays in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:02:27 +07:00
Sergey Kozyrenko 04702c3648 fix(frontend): UX consistency sweep — dialog case, page titles, clone URL
Resolves QA report observations O7, O8, O10, O11, O12.

O7 — Confirm dialog title case
The Resources page rendered "Delete Directory" / "Delete Resource" in
Title Case while every other delete confirm (template, knowledge,
token) used Sentence case ("Delete template", "Delete token"). Aligned
the resources copy to "Delete directory" / "Delete resource".

O8 — /settings/api-tokens identity mismatch
Three names referred to the same page:
  - sidebar link / h1: "PentAGI API"
  - tab title (route registry): "API tokens"
  - menuItems entry: "PentAGI API"
Unified everything to "API Tokens". Also removed the now-redundant
api-tokens special case in settings-layout's SettingsHeader (it always
returned the same value the menuItems lookup would have produced).

O10 — Provider Clone URL parameter
`/settings/providers/new?id=5` collided semantically with the rest of
the codebase, where `?id=` usually means "this is the record being
edited". Renamed to `?clone=5` so the intent is explicit when the URL
is shared or shows up in logs. Updated the reader in
settings-provider.tsx (`formQueryParams.id` → `formQueryParams.clone`)
to match.

O11 — "Create Provider" button accessibility
The button is a DropdownMenuTrigger that opens a provider-type chooser
before navigating to /new — not a submit button. Sighted users get
this from `<ChevronDown />` and Radix's `aria-haspopup="menu"`, but
the bare visible text "Create Provider" reads as a confirmation
action to screen readers. Added `aria-label="Create provider — choose
type"` and a block comment explaining the pattern so future
maintainers don't try to "fix" the dropdown into a direct submit.

O12 — `(unnamed)` placeholder rendering
Tokens with empty name rendered as literal `(unnamed)` with
text-muted-foreground only. Added `font-normal italic` so the
placeholder reads as missing data rather than a real name (the column
otherwise uses font-medium).

Verified: 0 ESLint errors, 508/508 tests pass.
Browser regression sweep across 9 pages (/dashboard, /flows,
/templates, /knowledges, /resources, /flows/:id, /settings/{providers,
api-tokens,prompts}) still returns 0 icon-only buttons without an
accessible name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:52:16 +07:00
Sergey Kozyrenko 3346387cee fix(frontend): valid prompt-id guard + form-field a11y attrs
Resolves QA report BUG #16 and BUG #17.

BUG #16 — /settings/prompts/<invalid> tab title fallback
formatPromptId() blindly capitalized whatever the URL passed in, so a
nonexistent id like /settings/prompts/99999 produced the tab title
"99999 — PentAGI" (and an "Edit Prompt" header with a literal "99999"
in the error message). Valid prompt ids are camelCase identifiers
(e.g. "agentSelector"). Reject anything that does not match
/^[a-z][a-zA-Z]*$/ and fall back to a generic "Prompt", so the title
becomes "Prompt — PentAGI" for invalid ids; valid ids keep their
existing "Agent Selector — PentAGI" rendering.

BUG #17 — Chrome DevTools "form field needs id/name" + label-for
Two separate issues on /flows and /flows/🆔

1) Four hidden <input type="file"> elements driven programmatically
   by use-resources-upload / use-flow-files-upload / flow-form lacked
   `name`. Added descriptive names (`resource-upload`, `flow-file-upload`,
   `flow-form-attachment`) plus `aria-hidden="true"` and `tabIndex={-1}`
   — these inputs are never user-focused (the button triggers .click()
   via a ref), so excluding them from the accessibility tree is correct.

2) <FormControl><Switch/></FormControl> + <FormLabel> for the "Use Agents"
   field on the assistant flow form was rendered without a wrapping
   <FormItem>, so useFormField() read the default empty context and
   formItemId became "undefined-form-item". Chrome then flagged "label
   for=undefined-form-item" as an orphan. Wrapped the cluster in
   <FormItem className="flex flex-row items-center gap-0"> so useId()
   generates a real id and the label binds to the Switch trigger
   button correctly.

Verified: 0 ESLint errors, 508/508 tests pass.
On /flows the page now has 0 form fields without id/name.
On /flows/:id the only remaining unlabeled fields are third-party
internals we do not own (xterm.js helper textarea, Radix Switch
primitive checkbox, react-textarea-autosize ghost) — Chrome's count
dropped accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:37:59 +07:00
Sergey Kozyrenko f41f012e67 refactor(frontend): unify row-action a11y pattern to aria-label
Page-level row-action triggers were split between two conventions:
- 4 pages used <span className="sr-only">Open menu</span> inside <Button>
- 3 pages (touched in commit 9eee2b4) followed the same pattern for consistency

Project has no i18n pipeline that benefits from sr-only text-node
translation, and the rest of the codebase already uses aria-label for
icon-only buttons (pagination, forms, theme tabs, toggles). Unify all
6 page-level row-action triggers to aria-label="Open menu":

- pages/flows/flows.tsx
- pages/knowledges/knowledges.tsx
- pages/templates/templates.tsx
- pages/settings/settings-providers.tsx
- pages/settings/settings-prompts.tsx (×2 — agent + tool tables)
- pages/settings/settings-api-tokens.tsx

shadcn UI library components (sheet, dialog, sidebar, breadcrumb) keep
their sr-only spans untouched — that is upstream shadcn convention and
not in scope here.

Verified: 0 ESLint errors, 508/508 tests pass, browser sweep across
6 list pages confirms aria-label="Open menu" rendered correctly and
no icon-only buttons remain without an accessible name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:24:42 +07:00
Sergey Kozyrenko 9eee2b48a1 fix(frontend): add aria-labels to icon-only buttons
Resolves QA report BUG #12, #13, #14, #15, #18 — icon-only buttons
across forms, sidebar, DataTable, and detail pages lacked accessible
names. Applied handler-based naming convention so the label matches
the prop / handler / state attribute, not the visual icon:

- type="submit" + isSubmitting → "Submit" / "Submitting…"
- onCancel + isCanceling → "Cancel" / "Cancelling…"
- onToggleFavorite + isFavorite → "Toggle favorite" + aria-pressed
- setTheme(value) + value → "<Value> theme"
- Pagination via table.firstPage()/etc → "First/Previous/Next/Last page"
- Row actions DropdownMenuTrigger → "Open menu" via existing sr-only convention

Verified: 0 ESLint errors, 508/508 tests pass, regression sweep across
9 pages (/dashboard, /flows, /templates, /knowledges, /resources,
/flows/:id, /settings/{providers,api-tokens,prompts}) returned 0
icon-only buttons without accessible name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:15:03 +07:00
Sergey Kozyrenko de14bacb59 style(frontend): widen page indicator slot so "Page 79 of 79" fits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:19:29 +07:00
Sergey Kozyrenko 385704dee4 fix(frontend): stop pagination URL loop on back-nav and clamp
The clamping effect compared the URL to a `safePageIndex` derived from the
internal mirror. After popstate the URL refreshes one render before the
mirror catches up; after a page-size change the mirror refreshes one
render before the URL — so each side echoed the stale source back over
the fresh one, oscillating `/flows` ↔ `/flows?page=2` indefinitely.

Now clamping only fires when both URL and mirror agree out-of-range, and
goes through a new `setPage(idx, { replace: true })` opt-in so a typed
`?page=999` no longer leaves a history entry the next back-press would
re-trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:16:41 +07:00
Sergey Kozyrenko 6704116936 fix(frontend): prevent detail-navigation sheet items from overflowing
Radix ScrollArea wraps children in a display:table viewport that grows
to intrinsic content, so renderItem rows with white-space:nowrap text
pushed each option past the sheet's right edge. Swap to plain
overflow-y-auto and add min-w-0 to the flex chain so truncate finally
takes effect on long flow / template / knowledge titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:07:37 +07:00
Sergey Kozyrenko e97b29b31f refactor(frontend): purge WHAT-only comments across the codebase
Strip section banners, "Helper function to X" labels above self-describing
identifiers, "GraphQL query/mutation/subscription" markers, and the
copy-pasted `// Create debounced function` / `// Filter by search` blocks
that lined every flow feature. Where a comment hid a non-obvious reason
(history blocker pattern, watch() vs getValues(), CJK font fallbacks),
rewrite it as a WHY comment instead of dropping it.

Net diff: -571/+78 across 57 files. Lint clean (pre-existing `any`
warnings unchanged), `tsc` errors unchanged, 506/506 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:42:45 +07:00
Sergey Kozyrenko 1903e8fc0f fix(frontend): tag apollo titles with a Symbol marker so prod detects them
Terser strips the inner function-expression name in `apolloTitle()` during
the production build, leaving an anonymous `function({params})` whose
`fn.name === ''`. The old PascalCase heuristic in `DocumentTitle` then
misclassified the component as a `(params) => string` resolver, called it
with raw `match.params`, and crashed `variables({flowId})` with
"Cannot destructure property 'flowId' of undefined" on every /flows/:id
view in production.

Attach an explicit `Symbol.for('pentagi.apolloTitle')` marker via
`Object.assign` in the factory and expose `isApolloTitle()` as the only
public guard. Symbol-keyed properties survive Terser regardless of
`mangle.properties` settings, and `Symbol.for` keeps the marker stable
across accidental double-bundles (workers, SSR boundaries). The
`document-title` consumer now imports the guard, not the symbol, so the
marker stays an implementation detail of the apollo-title module.

Cover the regression with a dedicated `apollo-title.test.tsx` that
simulates name stripping via `Object.defineProperty(fn, 'name', { value: '' })`
and asserts the guard still recognizes the component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:31:46 +07:00
Sergey Kozyrenko 95c4fb5970 fix(frontend): default shadcn Button to type="button"
HTML's default `type` for a `<button>` inside a `<form>` is `submit`,
so every Button without an explicit `type` silently posted the parent
form on click — repro on knowledge detail: clicking the
`DetailNavigationButtons` Prev/Position/Next cluster (or the Anonymize
button) fired the form's submit handler and kicked off an
`updateKnowledge` mutation that 502'd. Mirrors the existing convention
in `InputGroupButton`. `asChild` still defers to the consumer's
element; the one genuine submit caller (`FormSubmitButton`) is
explicit. Audit confirms no other Button uses default-submit
intentionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:41:42 +07:00
Sergey Kozyrenko 5a26d800ee feat(frontend): in-sheet search + focus-steal fix in detail navigation
`useDetailNavigation` now owns a controllable local search (debounced,
ANDed with the URL `?q=` filter) and `DetailNavigationSheet` surfaces it
as a header input by default (`hasSearch={true}`). Sheet auto-focus no
longer steals focus from the search input when `filteredItems` shrinks
on a debounce flush — gate the option-focus effect on `document.activeElement`
so typing past the boundary keeps the caret in place. ArrowDown from the
input synchronously promotes focus into the listbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:38:49 +07:00
Sergey Kozyrenko 4888568a7e refactor(frontend): drop dead storage plumbing from filter readers
Now that `useTableState` no longer touches storage for filter values, the
adjacent read-only API has dead optionality:

- `useTableQueryFilterReader` accepted a `storageKey` that was already
  documented as "ignored here — only for API symmetry with the mutate
  variant." With the symmetry gone (the mutate variant no longer takes
  one), keep the reader pure-URL and drop the prop entirely.
- `useDetailNavigation` used to compute that storage key via
  `usePageStorageKeys` just to pass it into the reader. The whole
  `parentPath` override existed solely to feed that computation. Drop
  `parentPath`, the `ParentPath` template-literal type, and the
  `usePageStorageKeys` import — no caller passes `parentPath`, so the
  cleanup is API-compatible.
- `useResourcesSearch` JSDoc still claimed a "localStorage fallback";
  reword to describe the current URL-only behavior so the comment stops
  contradicting the code.

Manually verified `/knowledges?q=Jinja` → click a row → the detail page
shows `1/11` navigation, Next steps through siblings, and `?q=Jinja`
stays in the URL — the headless controller still reads the live filter
through the reader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:15:03 +07:00
Sergey Kozyrenko f8c2876cdf fix(frontend): stop restoring table filter from storage
The previous `useTableState` mirrored `?q=` into per-page localStorage
and replayed it back into the URL on mount when the URL came in clean.
The intent was "fresh tab resumes the prior search," but in practice
the storage→URL replay leaked the user's most recent filter into
unrelated visits: navigating away from a filtered `/flows?q=` and
returning via the sidebar would silently land on the same filter, often
with an empty result set the user had no reason to expect.

Filter is an ad-hoc query, not a preference. Real preferences (sorting,
column visibility, page size, search columns) still persist via
`lib/table-state` — only `filter` is dropped from the storage roundtrip:

- Remove the storage→URL restore effect.
- Remove the URL→storage write effect for `filter`.
- Drop the `skipRestore` option (no restore left to suppress) and the
  `storageKey` override that only existed to scope the restore.
- Drop the conditional `skipRestore` plumbing on `/knowledges` — the
  page no longer needs to defend against a stale `?q=` being replayed
  on top of a fresh `?qs=` link.
- Rewrite the "storage roundtrip" test block as
  "filter is URL-only": assert the storage entry is ignored on mount,
  the URL still wins when both are set, and typed filter never lands
  in storage.

Manually verified in the browser: `/flows?q=IDOR` → navigate away →
return to `/flows` shows the unfiltered table; sorting a column still
persists across reloads (`table_4_/flows` keeps the `sorting` field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:51:24 +07:00
Sergey Kozyrenko 84fcdc2b28 refactor(frontend): use shared formatDate for table cells, drop dup tooltips
The Flows, Providers, and API tokens pages each defined a local
`formatDateTime`/`formatFullDateTime` pair (~16 lines × 3 files) doing
essentially the same trimming-by-recency job as `formatDate` from
`@/lib/utils/format`, and each wrapped the cell in a Tooltip that
repeated the same text more verbosely.

After the previous date-format unification commit (1806956), the
trimmed cell already conveys enough context for every recency band:
- today → HH:mm:ss
- this year → HH:mm, d MMM
- older → HH:mm, d MMM yyyy

So the Tooltip+full-timestamp pair no longer adds information for the
date columns. Replace the three local copies with a direct
`formatDate(new Date(dateString))` call and drop the Tooltip wrapper
where it only mirrors the cell.

The Tooltip on the Terminals column in flows.tsx is kept — it expands
the list of terminals with per-row connection status and is not a
duplicate.

Net delta: -115 / +10 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:41:45 +07:00
Sergey Kozyrenko 6af1ee4dc1 style(frontend): drop Search adornment, swap Columns toggle for icon button
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:20:21 +07:00
Sergey Kozyrenko 18069565d9 fix(frontend): address QA findings (P0–P3, excluding password policy)
- providers/flow-provider: map "no rows in result set" to a friendly
  "Flow not found" toast with a stable id so repeated sibling-query
  errors no longer stack raw SQL into the UI
- components/ui/sidebar: skip the global Ctrl+B shortcut when focus is
  in an input, textarea, select, or contenteditable — was hijacking the
  Tiptap Bold shortcut in the markdown editor
- components/ui/sidebar: mark the mobile drawer with group + data-state
  "expanded" so `group-data-[state=...]` selectors collapse correctly
  and flow id badges stop rendering twice in the drawer
- pages/templates/template: add aria-label + title to the icon-only Save
  button and mark the icon aria-hidden
- pages/settings/settings-api-tokens: render the date-picker label with
  date-fns format "d MMM yyyy" to match the table's date format
- pages/settings/settings-providers: drop the fixed Name column width so
  the Type column stays visible on narrow viewports; Badge truncates
  cleanly with whitespace-nowrap and shrink-0 on the provider icon
- components/shared/confirmation-dialog: derive the title from
  `\${confirmText} \${itemType}` so dialogs surface "Delete template"
  instead of a generic "Confirm Action", and use the verb in the
  description for consistency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:41:20 +07:00
Sergey Kozyrenko 1debdd9fdf fix 2026-05-20 01:25:18 +07:00
Sergey Kozyrenko ed066603c2 style(frontend): drop redundant Search icon, stretch Flags column header
- DataTableFilter no longer renders a leading `<Search>` addon. With
  `InputSearch` now living in the same page header as the trigger for
  semantic search, two magnifier icons next to each other read as
  visual clutter — the placeholder text alone already cues the input's
  purpose. Drops the unused `Search` import too.
- The "Flags" column header span gets `w-full` so its
  `justify-end` actually pushes the label flush against the right edge
  of the cell. Without the explicit width it collapsed to content
  size and the alignment had no axis to act on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:45:10 +07:00
Sergey Kozyrenko 5f7b683974 fix(frontend): keep InputSearch typing responsive via local-state mirror
Typing fast into the knowledges header search dropped characters: each
keystroke went straight into the parent's `onSearchChange`, which on
this page funnels through `setSearchParams` → router re-render → the
controlled `searchQuery` prop snapped back through React. When the
round-trip outran the user's fingers, the input's visible value rolled
back and chars between commits were lost ("find" → "fn").

Mirrored the `DataTableFilter` pattern: hold a `localValue` state that
keystrokes update synchronously, debounce the commit upstream
(`useDebouncedValue`, 150 ms — same constant as the table filter so
both inputs on this page feel uniform), and reconcile external resets
(Esc, programmatic URL wipe, back button) via a `lastEmittedReference`
guard that ignores our own round-trips.

Blur and Escape now also read `localValue` instead of `searchQuery` so
keystrokes pending in the debounce window aren't treated as "nothing
typed yet". Escape additionally fast-paths an empty commit upstream so
the clear hits the parent without waiting for the debounce.

Measurements (chrome-devtools, 19-keystroke burst): pre-fix typing
dropped characters, post-fix avg keystroke 2.07 ms, p95 6 ms, 0
keystrokes over the 16 ms 60-fps budget. Server `searchKnowledge`
still fires exactly once per typing pause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:43:09 +07:00
Sergey Kozyrenko 747c988556 style(frontend): tweak Kbd tooltip palette, drop redundant Plus sizing
- Kbd inside tooltip-content now uses the `accent` token family
  (`bg-accent/20`, `text-accent-foreground`, dark `bg-accent/50`) for
  better legibility against the popover background. The previous
  `bg-background/20` chips read as thin film on dark tooltips.
- The empty-state "New Knowledge" button on the knowledges page passes
  `<Plus />` without an explicit `size-4` — the Button variant already
  sizes its icon slot, the override was duplicating defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:27:43 +07:00
Sergey Kozyrenko 8ed62a24e8 fix(frontend): emit shadcn v4 data-slot attrs on Tooltip primitives
Children like Kbd target tooltip surfaces via
\`[[data-slot=tooltip-content]_&]:bg-background/20\` selectors so the
chips can re-tone themselves for the dark popover background. Our
TooltipContent was missing that attribute (the file shipped from a
pre-v4 cut of shadcn), so the override never matched and Kbd kept its
muted page-surface styling — invisible on the tooltip.

Set \`data-slot\` on every Tooltip primitive (provider / root /
trigger / content) to match the v4 contract. Verified in DevTools:
Kbd inside tooltip-content now computes the inverted background and
foreground colors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:18:25 +07:00
Sergey Kozyrenko 992eb8b198 refactor(frontend): swap InputSearch tooltip text for shadcn Kbd chips
shadcn ships the canonical Kbd-inside-Tooltip pattern (per the
upstream kbd-tooltip demo and our existing components/ui/kbd.tsx).
The Kbd component already self-themes for tooltip-content surfaces
via `[[data-slot=tooltip-content]_&]` overrides, so swapping the
plain "(⌘ K)" suffix for `<KbdGroup><Kbd>⌘</Kbd><Kbd>K</Kbd></KbdGroup>`
matches the rest of the app and renders proper keycap chips against
the tooltip background.

Verified the accessibility-description path: Radix still surfaces the
flattened "Semantic search... ⌘ K" string to screen readers (we saw
it in the a11y snapshot), so the keyboard hint stays announced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:11:13 +07:00
Sergey Kozyrenko e2cd75720e feat(frontend): show placeholder + hotkey tooltip on InputSearch trigger
The collapsed trigger is just a magnifier icon — there's no visible
hint about what it does or which key reveals it. Added a tooltip that
combines the `placeholder` with the platform-appropriate hotkey glyph
(⌘ on Apple, Ctrl elsewhere) using the existing `isMac()` helper. When
`hotkey` is `null` the suffix drops and the tooltip shows just the
placeholder.

Tooltip wraps only the trigger button: once the input is expanded the
caret is in it and a hovering tooltip would obscure the very thing the
user is typing into.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:04:32 +07:00
Sergey Kozyrenko dd786d4c9d fix(frontend): restore InputSearch blur-collapse for empty value
The original sofy-front version collapsed the control whenever focus
left an empty input. When the duplicate blur+effect pair was folded
into a single effect during the port, that behaviour silently went
with it — opening the trigger without typing and then clicking
elsewhere left the box expanded forever, because `searchQuery` never
changed and the effect never re-ran.

Re-added a dedicated `onBlur` handler that collapses on focus loss
when the value is empty. The effect stays as the safety net for the
programmatic-clear case (parent wipes `?qs=` from the URL while focus
is elsewhere) — different trigger, different path, neither path can
cover the other.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:00:40 +07:00
Sergey Kozyrenko 5a7b2e882e style(frontend): align InputSearch sizing with size='sm' button geometry
`HeaderButton` defaults to `size='sm'` (h-8 px-3 rounded-md text-xs)
and pages mount InputSearch right next to it. The component previously
shipped with h-7 / icon-xs (28 px) so the icon-only trigger sat a
notch shorter than its sibling action — visible in the knowledges
header.

Bumped the group to h-8, the trigger to size='icon-sm', and pinned
the inner input to h-8 (it inherits `Input`'s default h-9 otherwise,
which pokes 4 px past the bordered group). Dropped the duplicate
negative margins on the button — the addon's
`has-[>button]:ml-[-0.45rem]` already handles flush-left alignment
when expanded; only the collapsed state needs the explicit `-mx-1.5`
to claw back the addon's default padding around the lone icon.

Verified: trigger 32×32, group 32 px, input 32 px, all matching the
neighbouring `HeaderButton` baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:55:54 +07:00
Sergey Kozyrenko 31a7f34c0f feat(frontend): add collapsible InputSearch, wire it to ?qs= on knowledges
New `components/ui/input-search.tsx`: a controlled, collapsible search
control that idles as just a `<Search>` icon button and expands into a
text input with a motion-driven width animation. Adapted from the
sofy-front version with:

- Trigger is a real `<button>` (via `InputGroupButton`) instead of a
  `<div onClick>` — keyboard reachable, screen-reader announced, and
  avoids the invalid-HTML nesting that putting an `<input>` inside a
  `<button>` would create.
- Configurable Mod+`hotkey` (default `'f'`, `null` to disable). The
  knowledges page uses `'k'` so it doesn't fight the browser's own
  Ctrl+F find-in-page.
- Escape clears the value on the first press, blurs + collapses on the
  second — matches native search-input muscle memory.
- Single collapse effect on `searchQuery` change instead of the
  duplicate blur+effect pair.
- `aria-label` prop wired to both the trigger button and the input.

The knowledges header now mounts this control bound to `?qs=` via
`useSearchParams`. The provider already debounces the URL value before
hitting `searchKnowledge`, so the input commits keystrokes immediately
(instant UI feedback) while the server roundtrip waits for the 400 ms
debounce. URL writes use `replace` to avoid history-stack churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:40:11 +07:00
Sergey Kozyrenko 4430cfc329 fix(frontend): suppress ?q= storage restore when ?qs= is present
When `/knowledges` is opened via a `?qs=…` link, `useTableState`'s
mount-time restore was injecting the previous session's `?q=` from
storage on top of it — so a clean semantic-search URL silently became
`?qs=foo&q=python` and the user got a narrowed result set they didn't
ask for.

Added `skipRestore` to `useTableState` (defaults to `false`, captured
via a ref at mount so a later toggle doesn't replay a restore the user
has worked past). `knowledges.tsx` passes `searchParams.has('qs')` so
the restore is skipped exactly when the parameter is part of the
landing URL.

Mirror-to-storage on subsequent filter changes is unaffected — a
client filter the user actually types still persists to storage for
the next session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:16:25 +07:00
Sergey Kozyrenko 2a4417505a feat(frontend): wire knowledges provider to searchKnowledge via ?qs=
Knowledges list previously sourced everything from `knowledgeDocuments`.
When `?qs=` is present the provider now switches to `searchKnowledge`
(debounced 400ms, capped at 100 results) and feeds the resulting subset
through the same public `knowledges` array — so DataTable, the existing
`?q=` client filter, and DetailNavigation continue to walk it without
any consumer-side change.

Subscription cache merge in `lib/apollo.ts` only covers the
`knowledgeDocuments` root field; in search mode CRUD events trigger a
`refetchQueries(['searchKnowledge'])` via `useLatestRef` so the relevance
ordering stays consistent without freezing the boolean at mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:48:55 +07:00
Sergey Kozyrenko a55f7bd6eb feat(frontend): add Copy Token ID action to api-tokens row dropdown
Mirrors the entry already present in the right-click context menu so
both menus expose the same set of actions (Edit / Copy Token ID /
Delete) — the dropdown was previously missing the copy shortcut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:35:48 +07:00
Sergey Kozyrenko d08295610a fix(frontend): suppress touch text-selection on rows with a context menu
Long-press on iOS/Android starts native text selection before Radix
ContextMenu reacts, so the row appears highlighted from the top of the
table down to the finger. Apply `user-select: none` and disable the iOS
callout on those rows only, and only on coarse pointers — desktop users
keep the ability to select cell text with the mouse.

Pattern matches the workaround discussed in radix-ui/primitives#930.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:35:39 +07:00
Sergey Kozyrenko 85a7ee281f fix(frontend): keep table row highlighted while context menu or dropdown is open
Hover relies on the cursor staying over the <tr>, but Radix portals the
overlay so the row dims as soon as the menu opens. Match the same
background via `data-[state=open]` (ContextMenuTrigger forwards
`data-state="open"` onto the row through `asChild`) and
`has-[[data-state=open]]` (covers any Dropdown/Popover triggered from a
cell). Switch the selection prop to a conditional spread so the prior
`data-state={false}` no longer wins over Radix Slot's merged props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:44:03 +07:00
Sergey Kozyrenko f369b411ed refactor(frontend): centralize document <title> in a single registry
Single source of truth for every route's tab title. All title knowledge —
static strings, params-derived resolvers, and Apollo-cache-driven reactive
components — now lives in src/lib/route-titles/, and app.tsx imports only
the `routeTitles` object. The router file no longer pulls Apollo hooks
or formatters; it just spreads handles onto routes.

Handle API collapsed from two keys to one. Following the canonical
`useMatches` example from React Router docs, `handle.title` is now a
single field that accepts three forms:

  • `string` — static, known at build time
  • `(params) => string` — synchronous resolver from URL params
  • `ComponentType<{ params }>` — reactive (e.g. Apollo cache subscription)

DocumentTitle distinguishes resolver vs component by the PascalCase
convention (component names start with an uppercase letter), so the
choice is implicit at the call site — no `titleComponent` field, no
discriminated union, no marker property.

Boilerplate consolidation:
- 4 near-clone resource-title components (Flow/Knowledge/Provider/
  Template) collapse into 4 declarative `apolloTitle({ useQuery,
  variables, select })` calls in the registry. The factory owns the
  `fetchPolicy: 'cache-only'` plumbing, skip semantics, and component
  naming.
- renderTitle/APP_NAME/RouteParams were duplicated between
  document-title.tsx and resource-titles.tsx — now imported from one
  render-title.tsx.
- formatPromptId moves into the route-titles module since the prompt
  detail page and the route handle are its only consumers.

References to React docs added to document-title.tsx: only one <title>
should be rendered from React at a time (multiple → undefined browser/
SEO behavior). The convention is now enforceable from one place.

All 482 unit tests pass; lint, tsc, prettier clean. Verified in the
browser across every route — static (Dashboard, Flows, Resources, …),
/new variants (New flow, New provider, …), params resolver
(/settings/prompts/agentSelector → "Agent Selector"), and Apollo
reactive (/flows/491 → "Flow #491 — Login Beta Program"), including
sibling navigation between detail routes which previously motivated
moving DocumentTitle into the shell.

Net diff: -171 / +66 across the touched files plus the new registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:25 +07:00
Sergey Kozyrenko 73bb0e6446 fix(frontend): toast + redirect when knowledge document not found
Match the redirect pattern used on the flow page: instead of rendering
a dedicated "not found" card with a manual back button, show a toast
and replace the route with /knowledges so the user lands on the list
the same way other resources behave.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:25 +07:00
Sergey Kozyrenko f2fb222807 refactor(frontend): collapse PageTitle into DocumentTitle via route handles
Single mechanism for the document <title>. Every route now declares its
title in the react-router handle:

  • handle: { title: 'Dashboard' } — static listing pages
  • handle: { title: (params) => formatPromptId(params.promptId) } —
    params-derived (e.g. /settings/prompts/:promptId)
  • handle: { titleComponent: TemplateTitle } — Apollo cache-driven,
    reactive to cache updates after the page mounts

A single <DocumentTitle/> in RootLayout walks useMatches() and picks
the deepest match. PageTitle is gone — 13 call sites across 13 page
components migrated to handle, the component deleted.

Other improvements:
- Split document-title.tsx so the shell-only <DocumentTitle/> has zero
  graphql imports — the resource title components (FlowTitle,
  KnowledgeTitle, ProviderTitle, TemplateTitle) live in
  resource-titles.tsx. This lets the unit tests render DocumentTitle
  without dragging the codegenerated graphql/types.ts through Vite.
- String(p.id) === providerId replaces sloppy == in ProviderTitle.
- matches.findLast(...) replaces the imperative for-loop.
- Extracted formatPromptId into lib/utils/format-prompt-id.ts so the
  prompt detail page and the route handle resolver share one formatter.

7 unit tests added for DocumentTitle covering: static title, derived
title from params, deepest-match-wins, titleComponent rendering,
titleComponent precedence over static title, empty-string fallback,
and no-handle fallback.

19 test files / 482 tests pass; lint + tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:25 +07:00
Sergey Kozyrenko 3a67daaad8 refactor(frontend): drive document <title> from route handles
Move <title> ownership out of detail-page components into the app
shell. The four detail routes — templates/:templateId,
knowledges/:knowledgeId, settings/providers/:providerId,
flows/:flowId — now expose a titleComponent via react-router
handle. A new <DocumentTitle/> in RootLayout walks useMatches()
deepest-first and renders the matched title component. Each one
subscribes to its resource via Apollo with fetchPolicy:
'cache-only', so it reacts to the destination page's own fetch
without issuing a duplicate request.

Fixes the root cause behind bbf943e's sticky workaround: navigation
between sibling documents (DetailNavigation prev/next) tore down
the page-level state that PageTitle held, flashing a generic
fallback during data fetch. The shell-level <DocumentTitle/>
survives the remount, so the title resolves from cache before the
new page has finished mounting.

- Add src/components/shared/document-title.tsx with DocumentTitle
  plus FlowTitle / KnowledgeTitle / ProviderTitle / TemplateTitle.
- Wire <DocumentTitle/> into RootLayout and attach
  handle={{ titleComponent: ... }} on the four detail routes.
- Remove <PageTitle> calls and now-redundant <> wrappers from the
  four page components.
- Drop the sticky setState-during-render hack from PageTitle —
  listing pages that still use it never flickered, so the simple
  passthrough is enough.

Lint + tsc clean, all 475 tests pass. Listing routes (Dashboard,
Flows, Templates, Knowledges, etc.) keep using PageTitle and can
migrate route-by-route without coordination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:25 +07:00
Sergey Kozyrenko 765d743b21 refactor(frontend): inline API token forms on RHF + zod
Replace the two ad-hoc useState form objects (createFormData /
editFormData) in settings-api-tokens.tsx with parent-level useForm
hooks per inline row, each backed by a zod schema. Controller in
each DataTable cell re-subscribes to the parent form state on
remount, so subscription-driven row remounts no longer drop user
input — same root cause that 04c6825 fixed, but solved at the
architectural level instead of controlling each Input manually.

- tokenNameSchema (trim + max 255), createTokenFormSchema with
  required expiresAt via refine, editTokenFormSchema with status.
- <Input>, <Select> and <Calendar> cells rendered through
  <Controller>; RHF owns the values, not React state in the parent.
- CreateRowActions / EditRowActions subcomponents own the
  useFormState({ control }).isValid subscription so the Save button
  re-renders only the small action subtree on keystrokes instead of
  the whole table.
- Ad-hoc required/trim checks dropped from handlers; the schema +
  isValid gate handle them. Submit still applies trim() || null
  because the GraphQL mutation treats null as "no name."

All 18 test files / 475 tests pass; lint + tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:24 +07:00