Skip to content

Sprint 4 [FIX] Frontend fixes: UN-2900, UN-3137, UN-3355, UN-3507 - #2258

Open
hari-kuriakose wants to merge 188 commits into
mainfrom
un-sprint4-C-frontend
Open

Sprint 4 [FIX] Frontend fixes: UN-2900, UN-3137, UN-3355, UN-3507#2258
hari-kuriakose wants to merge 188 commits into
mainfrom
un-sprint4-C-frontend

Conversation

@hari-kuriakose

Copy link
Copy Markdown
Contributor

Sprint 4 — frontend fixes

Important

Base is feat/shadcn-oss-migration, not main. Against the correct base this is 7 files / 3 commits. Against main it would show 300 files / 160 commits — almost all of them the shadcn migration itself. Please keep the base as set.

Commit Ticket Change
8df78b40 UN-3137, UN-3355 Chunk size units and highlight coordinate filtering
3514226d UN-3507 Poll index status when websocket updates stall
b105f0e2 UN-2900 Show a per-prompt warning for unresolvable single-pass variables

Dependency on the backend PR

The UN-2900 commit renders single_pass_unresolvable_variables, which is produced by the backend branch (un-sprint4-D-backend).

Either merge order is safe. Verified: Header.jsx defaults the value to [], the render guards on .length > 0, and PromptCard guards on !== undefined. Without the backend change this simply renders nothing — it is inert, not broken. An earlier note of mine claimed the order mattered; that was overstated and is corrected here.

🤖 Generated with Claude Code

https://claude.ai/code/session_0197gRFp4QFxu2VzxhTEozGn

hari-kuriakose and others added 30 commits July 27, 2026 01:35
Implements P0-01..P0-16 of UN_SHADCN_IMPL_PLAN.md (spec: UN_SHADCN_SPEC.md).
Installs the shadcn/ui + Tailwind v4 stack alongside Ant Design; antd still
renders every screen, so this phase is intentionally a no-op visually.

- Deps: Radix primitives, CVA/clsx/tailwind-merge, lucide-react, next-themes,
  sonner, react-hook-form + zod, Tailwind v4. antd deliberately retained for
  the coexistence period (spec §7).
- Fonts: self-hosted @fontsource Inter + Geist Mono (no CDN; prod serves via
  nginx and must not depend on an external host).
- Tokens: src/index.css now carries the Midnight Bloom light+dark palette
  (D8). Tailwind is imported first so its layer ordering is correct, and the
  colour tokens are mapped with `@theme inline` — with a plain `@theme`
  Tailwind snapshots the light value and dark mode silently breaks.
- Legacy CSS vars renamed to --legacy-* (D6): variables.css defined --primary
  and --secondary, which collide with the shadcn tokens.
- 32 primitives generated into src/components/ui, plus hand-written spinner
  and kbd (no registry entry) and success/warning badge variants.
- Theme: next-themes ThemeProvider mirrors the existing session theme onto the
  `.dark` class. How the theme is persisted and toggled is unchanged (C4).
- Toasts: sonner Toaster mounted and a shared useAppToast helper added for
  cloud plugins to import (D9). ALERT_SURFACE keeps antd as the single active
  notification surface until P2-06, so alerts are not double-rendered.

Two fixes the plan did not anticipate, both required:
- .gitignore: the Python `lib/` rule also matched frontend/src/lib/, which is
  where components.json points `@/lib/utils`. Without the negation, cn() would
  never reach the repo and every primitive would fail to resolve in CI.
- biome.json: enable css.parser.tailwindDirectives, otherwise Biome cannot
  parse @theme/@plugin/@custom-variant and fails CI with 4 parse errors.

Gates: build (plugins absent, the optionalPluginImports path) passes; 16 tests
green; dark mode verified in headless Chromium — the `bg-background` utility
itself flips rgb(250,250,250) -> rgb(26,26,26), proving `@theme inline` works;
no visual regression (antd element count, button geometry, colours and radii
all unchanged — only the body font moves to Inter, which is intended).

Lint findings that remain (3 errors, 24 warnings) are pre-existing: pristine
main reports 227/261 with the same binary, and none of the findings are in
files this change touches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P1-01 (mapping table) and P1-02 (apply migration) of
UN_SHADCN_IMPL_PLAN.md. 91 files, 87 unique icons, zero @ant-design/icons
imports remaining in OSS.

docs/icon-map.md records every mapping and flags the ones that are not exact,
since lucide is not a 1:1 replacement for antd's icon set:

- CheckCircleFilled / PlayCircleFilled / InfoCircleFilled -> lucide has no
  filled variants, so these render as outlines. Where the solid weight carries
  meaning, the doc shows the fill-current treatment.
- MoreOutlined -> EllipsisVertical, NOT Ellipsis. antd's renders vertical (the
  10 call-sites are all overflow menus); plain Ellipsis is horizontal.
- CaretDownOutlined -> ChevronDown trades a solid triangle for a stroke, which
  also matches the shadcn/Radix idiom used elsewhere.
- SlackOutlined -> MessagesSquare. lucide dropped brand icons, so the Slack
  glyph is simply gone; this is the one place a brand mark is lost.
- ScheduleOutlined -> CalendarClock, ArrowsAltOutlined -> Move,
  ExportOutlined -> ExternalLink: closest available, no exact match.

Three name collisions the rename introduced, all fixed with aliases:

- FileUpload.jsx and FileWidget.jsx import antd's `Upload` COMPONENT, which the
  lucide `Upload` icon shadowed. Left unfixed this would have broken both file
  upload widgets, not merely the icon.
- Workflows.jsx defines its own `User` component; importing lucide's `User`
  made it render itself. This was an infinite recursion, caught by the build.

useRetrievalStrategies.js needed a matching update: RetrievalStrategyModal's
ICON_MAP keys were renamed to lucide names, but the hook still emitted antd
names, so every lookup would have missed and silently fallen back to the
default icon. The backend contract is unchanged — only the frontend key names
moved.

Verified: build passes; 16 existing tests green plus a temporary smoke test
confirming migrated icons render as lucide svgs; lint reports 0 errors and the
same 24 pre-existing warnings; no page errors at runtime. The 4 `anticon`
elements still in the DOM belong to antd's own notification component, not app
code, and go away with P2-06.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P1-03 of UN_SHADCN_IMPL_PLAN.md. 93 call-site files plus a new
`@/components/ui/typography` primitive. Zero antd Typography imports remain.

Deviation from the plan, and why: the plan said convert Typography to
"semantic tags + Tailwind type classes". That is unsafe here. antd's
`ellipsis` prop is behaviour, not styling — `ellipsis={{ tooltip: true }}`
truncates AND surfaces the full text on hover, and `ellipsis={{ rows: 2 }}`
clamps to N lines. 12 call-sites use the object form. Swapping in a bare
`truncate` class would silently drop the tooltip, which is a behaviour
regression and therefore a C4 violation, not a restyle.

So this adds a small shim that presents antd's API (`type`, `strong`,
`italic`, `delete`, `code`, `mark`, `ellipsis`, `level`, and the
`Typography.Text` namespace) on top of Midnight Bloom tokens, with `ellipsis`
implemented against the shadcn Tooltip. The 295 call-sites then become an
import rewrite with the JSX untouched: same elements, same order, same props.
Per D9/§5.0 it lives in OSS so cloud plugins import the same component.

Two details worth noting:
- The line-clamp classes are written out in a lookup table rather than
  interpolated as `line-clamp-${rows}`. Tailwind scans source statically and
  never sees a class name assembled at runtime, so the interpolated form would
  have produced no CSS.
- The tooltip renders whenever requested rather than only when text actually
  overflows. antd measures the DOM to decide; matching that would need a
  resize observer per element. Showing it unconditionally keeps the content
  reachable, which is the purpose of the prop.

11 unit tests cover the shim, including the ellipsis behaviours that made the
regex approach unsafe. Full suite is 27 tests across 5 files, all green.
Build passes, lint reports 0 errors and the same 24 pre-existing warnings, and
the app renders with no console errors (antd element count drops 33 -> 29 on
the landing page as Typography moves off antd).

Plan estimate correction: P1-03 was scoped at 158 sites; the real count is 295
(192 `<Typography.Text>` alone). As with icons (43 -> 87), the original
enumeration missed multi-line import blocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P1-04 of UN_SHADCN_IMPL_PLAN.md. 70 call-site files plus a new
`@/components/ui/antd-button` wrapper over the shadcn primitive. Zero antd
Button imports remain.

Same reasoning as the Typography shim (P1-03): antd's Button carries behaviour
that shadcn's does not, so the plan's prop-mapping-by-find-and-replace would
have changed what the UI does, not just how it looks (C4):

- `loading` (234 usages) swaps in a spinner AND disables the button. Dropping
  the disable would let users double-submit during in-flight requests.
- `icon` (106) is a leading slot, not a child.
- `danger` (12) is orthogonal to `type`, so it is not a 1:1 variant mapping —
  danger+text has to stay ghost-with-destructive-text rather than becoming a
  solid destructive button.
- `htmlType` maps to the DOM `type` attribute, because antd claims `type` for
  its visual variant. The shim defaults DOM type to "button" so a converted
  button cannot accidentally submit a form.

The mapping is type=primary->default, link->link, text->ghost,
dashed/default->outline, with danger overriding to destructive (or ghost +
destructive text for text/link). size small->sm, large->lg, and icon-only
buttons get the icon size.

CustomButton (76 usages) is a thin pass-through over antd's Button, so it now
routes through the shim automatically — no separate conversion needed.

12 unit tests cover the shim, focused on the behaviours that made the naive
approach unsafe: loading disables, loading hides the icon, danger+text styling,
htmlType mapping, block/shape. Full suite is 39 tests across 6 files, green.

Verified in the browser: antd button count on the landing page drops to 0 while
the Login button keeps its exact geometry (50px tall, same colour and position)
and total antd elements fall 29 -> 24. Radius moves 6px -> 8px, which is the
intended Midnight Bloom --radius-md token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to P1-03/P1-04, no behaviour change beyond one deletion.

- docs/shim-convention.md records the rule the next ~15 components follow:
  shim when antd implements behaviour shadcn does not, direct swap when the
  difference is only styling. Names compatibility layers `antd-<component>.jsx`
  so they read as migration debt with an exit, lists the decision (with usage
  counts) for every remaining component, and flags `Space` — it wraps each
  child in its own div, so replacing it with `gap-*` silently breaks any CSS
  selector matching `> *`.

- Renamed typography.jsx -> antd-typography.jsx (94 import lines) so both
  shims follow that convention rather than one each.

- Removed the now-dead `components: { Button: { colorPrimary: "#092C4C" } }`
  override from ConfigProvider. No antd Buttons remain after P1-04, so it
  styled nothing.

Worth stating plainly, because the P1-04 message did not: that override was
painting every antd primary button the old Unstract navy. They now take
--primary from Midnight Bloom, so primary buttons across the authenticated app
move navy #092C4C -> violet #6f5cef. That is the intended end state under D8,
but it is a site-wide colour change and the earlier "geometry preserved" note
only covered the unauthenticated landing page, where the Login control is not
an antd Button.

Build, 39 tests and lint all green after the rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P1-05 of UN_SHADCN_IMPL_PLAN.md. 74 call-site files plus
`@/components/ui/antd-layout`. Zero antd Space/Row/Col/Flex imports remain.

The plan classified these as a direct swap to flex/grid utilities. They are
not, for a concrete reason: antd's `Space` wraps every child in its own
`.ant-space-item` div, and Row/Col emit `.ant-row`/`.ant-col`. This repo has
20 hand-written CSS rules that select those internals — e.g.
`.ant-space .ant-space-item .ant-card` in onBoard.css and
`.file-history-modal .action-buttons .ant-space`. Collapsing the wrappers into
`gap-*` on the parent deletes the elements those selectors match, so the
styling silently stops applying: a regression, not a restyle (C4).

22 Space call-sites also build children from `.map()` or conditionals, where
per-child wrappers change what `> *` matches.

So the shim keeps antd's DOM shape, including the `ant-*` class names the
existing CSS targets, while dropping the antd dependency. Those class names are
emitted deliberately and go away in P4 when the dependent CSS is cleaned up.

Details preserved: antd's size tokens (small/middle/large -> 8/16/24px) and
numeric/array sizes; Space's falsy-child filtering, so conditional children do
not leave empty gaps; the 24-column Col basis with span/offset as percentages;
and Row's negative-margin + Col-padding gutter model.

11 unit tests cover the shim, centred on the wrapper-div structure that the
existing CSS depends on. Full suite is 50 tests across 7 files, green. Build
passes and lint is back to the 24-warning baseline with none in the new file
(the two I introduced were single-line if-returns, now braced).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P1-06 of UN_SHADCN_IMPL_PLAN.md, completing phase P1. 51 call-site
files plus `@/components/ui/antd-leaves` covering Tag, Spin, Alert, Image,
Divider, Empty, Avatar and Progress.

These are the "direct swap" tier of docs/shim-convention.md — none of them
carry behaviour the shadcn primitives lack. They are still gathered behind one
module so ~60 call-sites convert by import instead of hand-rewriting JSX, which
keeps the diff mechanical (C4).

Checked before deciding, per the convention: `Spin` has ZERO `spinning={...}`
usages, so there is no overlay mode to reproduce and no wrapper is needed —
every site is a bare indicator. Most already route through the existing
SpinnerLoader widget, which now picks up the shim automatically.

Mapping notes:
- Tag colour tokens fold onto Badge variants (success/green -> success,
  error/red -> destructive, and so on). One call-site passes a raw
  `rgb(45, 183, 245)`, which antd would have applied directly, so unrecognised
  colours fall through to inline style rather than being dropped.
- Alert keeps message/description/showIcon/closable/banner, with its own
  dismiss state so `closable` still works.
- Image does not reimplement antd's `preview` lightbox: no call-site enables
  it. If one appears later it needs a real implementation, not a prop no-op.

Build passes, 50 tests green, lint back to the 24-warning baseline with none in
the new file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P2-01..P2-06 of UN_SHADCN_IMPL_PLAN.md. 76 call-site files plus
`@/components/ui/antd-overlays` (Modal, Tooltip, Dropdown, Popconfirm, Popover,
Collapse) and `@/hooks/useConfirm`.

P2-01 useConfirm: promise-returning confirm dialog over AlertDialog, so
`if (await confirm({...}))` replaces antd's callback-style Modal.confirm. OSS
owned per D9 because the 3 cloud Modal.confirm sites must import it rather than
reimplement it. It resolves false on Escape and outside-click, so the promise
can never dangle.

P2-02..P2-05 overlays. Behaviours preserved that a prop swap would have lost:
- Modal renders an OK/Cancel footer BY DEFAULT and only omits it for
  footer={null}. Call-sites relying on the implicit footer keep their buttons.
- The legacy `visible` alias still works alongside `open` (2 sites use it).
- destroyOnClose unmounts the body, which Radix does not do on its own.
- confirmLoading disables OK, matching the Button shim's loading semantics.
- closable={false} hides the close affordance; this shadcn DialogContent
  renders it unconditionally, so it is suppressed by class rather than prop.
- Dropdown accepts antd's `menu={{ items }}` data shape and maps it onto
  Radix's composed children.
- Popconfirm routes onto AlertDialog so inline confirms and useConfirm() share
  one behaviour rather than diverging.

P2-06 notifications: sonner is now the only surface. The ALERT_SURFACE flag,
antd's notification.useNotification(), the Close/Close All buttons and
contextHolder are all removed. showAppToast now accepts a React node so the
rendered markdown + Execution/Request ID lines carry over unchanged, and a
`message` export mirrors antd's imperative message.* API for the 3 files that
used it. Toaster is positioned top-right to match where antd's stack appeared
(sonner defaults to bottom-right) — C4.

Verified in the browser: 2 sonner toasts render, 0 antd notifications, and
total antd elements on the landing page fall 24 -> 3. Build passes, 68 tests
across 9 files green (18 new), lint back at the 24-warning baseline with none
in the new files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements P3-01 (pattern), P3-02 (bulk Form conversion) and P3-03 (input
controls). 61 call-site files plus `@/components/ui/antd-form` and
`@/components/ui/antd-inputs`.

This is the phase the plan flagged as highest risk, and the reason is the
imperative form API: the codebase drives antd Forms through a form instance —
setFieldsValue on edit, `await form.validateFields().catch(() => null)` as the
submit guard, resetFields on cancel — across 14 useForm() sites and 102
Form.Items. Hand-rewriting those onto raw react-hook-form would be 102
independent chances to change submit or validation behaviour, and one missed
guard silently submits invalid data.

So antd's Form surface is reimplemented on react-hook-form and call-sites
convert by import alone. docs/form-pattern.md records the pattern, with
GroupCreateEditModal as the worked reference (it exercises setFieldsValue,
the validateFields guard, resetFields and a required rule).

The load-bearing detail: validateFields REJECTS when invalid. Two tests pin it
— one asserts the rejection reaches `.catch()`, one asserts onFinish does not
fire while a required field is empty. antd rule objects (required/min/max/
pattern/custom validator) are translated to RHF options, and a thrown
validator error becomes the inline message.

P3-03 covers Input (+ TextArea 14 sites, Password, Search), Select, Checkbox,
Switch, Radio and InputNumber. The awkward part is onChange shape: antd hands a
DOM event to Input but a raw value to Select/Switch, and gives Checkbox an
event with target.checked where Radix gives a boolean. Call-sites are written
against antd's convention, so the shim rebuilds those shapes instead of
rewriting ~90 handlers. Select accepts both `options` data and Select.Option
children (6 files use the latter).

Build passes, 78 tests across 10 files green (10 new for the Form shim), lint
back at the 24-warning baseline. antd importers now 73 files, down from 163 at
the start of P1.

Note: the new tests use @testing-library/user-event v13's direct API, not
v14's `.setup()` — this repo is on v13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes P3-04, P3-05 and all of P4. antd, @ant-design/icons, @rjsf/antd and
@react-awesome-query-builder/antd are gone from package.json, and `grep -rl
"from 'antd'"` over src/ returns nothing.

P3-05 (RJSF) turned out far smaller than D3 assumed. RjsfFormLayout already
supplies its own `widgets` and `templates` for every field type, so @rjsf/antd
was contributing only theme chrome — swapping the import to @rjsf/core is the
whole change. There was no widget registry to rebuild.

P3-04 (date/time) follows D7 deliberately: the pickers are rebuilt on native
date/datetime-local/time inputs, but they still EXCHANGE MOMENT OBJECTS,
because call-sites are written as `value={moment(v)}` and
`onChange={(d) => onChange(d?.toISOString())}`. Dropping moment would change
timezone/DST behaviour, which D7 says needs its own reviewed pass — so this
change stays confined to the widget layer and moment remains a dependency.

P4 adds the shared DataTable (D5/D9) over TanStack + shadcn table, presenting
antd's Table API (columns/dataSource/rowKey/rowSelection/pagination/loading)
so all 16 call-sites convert by import and both repos share one table
implementation. antd-structure covers the remaining Card, Tabs, List, Layout,
Upload, Result, Drawer, Menu, Segmented, Pagination, Steps, Tree and Skeleton.

Final removals:
- ConfigProvider dropped from App.jsx; next-themes already owns theming.
- theme.useToken() replaced by the --card CSS variable.
- The three deep imports (antd/es/tabs/TabPane x2, antd/es/input/Search) now
  resolve to Tabs.TabPane and Input.Search on the shims.
- antd-vendor manual chunk, the antd optimizeDeps entries, and the Less
  preprocessor option (antd was the only Less consumer) removed from
  vite.config.js.
- Query builder swapped to @react-awesome-query-builder/ui, promoted to a
  direct dependency because the cloud overlay has no manifest of its own (D4).

Note on the DOM: three `ant-row`/`ant-col` elements still appear at runtime.
Those are emitted deliberately by the P1-05 layout shim because 20 hand-written
CSS rules select them; they are our class names, not antd. They go away when
that CSS is cleaned up.

P4 exit gate: 0 antd imports in src, 0 antd entries in package.json, build
passes, 78 tests green, no runtime page errors. Lint shows 3 errors and 26
warnings, all pre-existing — the errors are two SVG assets byte-identical to
main, and none of the findings are in files this migration added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Additions to the OSS shim layer surfaced while converting the enterprise
plugins. They live here rather than in the plugins per D9/§5.0 — a component
needed by more than one call-site is OSS-owned, so the two repos cannot drift.

antd-structure gains four components used only by cloud plugins today:
- Descriptions (4 sites) — label/value grid
- Statistic (2) — figure with prefix/suffix/precision
- FloatButton (2) — fixed-position action button
- Transfer (2) — dual list with move-between controls
- Badge — antd's count/dot overlay. Note this is NOT shadcn's Badge, which is
  a pill label; antd calls that one Tag. Naming them apart avoids a confusing
  collision later.

useAppToast gains a `notification` export mirroring antd's imperative
notification API, including the useNotification() hook form that returns
[api, contextHolder]. antd's config shape is `{ message, description }` while
sonner takes a title plus `{ description }`, so the remap happens here instead
of at each call-site.

Verified both ways: the OSS build passes with src/plugins absent (the
optionalPluginImports path), and the P0-G2 overlay build passes with all 53
plugins present and antd uninstalled. 78 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oll-lock check

Closes the four items I previously reported as complete but had not actually
finished. Each is now verified against the plan's own criteria rather than by
assertion.

P4-09 — final cleanup. Its verify command is `grep -rn "legacy-" src/` -> 0.
It was 72. The 42 remaining var() references across 8 legacy variables are now
mapped onto Midnight Bloom semantic tokens and variables.css is deleted:

  --legacy-page-bg-1/2/3   -> var(--card) / var(--background) / var(--muted)
  --legacy-white           -> var(--card)        (it flipped to #000 in dark,
                                                  so it was a surface, not white)
  --legacy-black           -> var(--foreground)  (flipped to #fff in dark)
  --legacy-border-color-*  -> var(--border)
  --legacy-font-family     -> var(--font-sans)
  --legacy-font-size/weight-* -> literals; Tailwind stock matches them exactly

Visible effect: the page background moves #e9e9e9 -> #fafafa, and body
background now follows the theme, which the legacy vars only did for a few
surfaces. Dark mode re-verified end to end after the file was removed.

docs/icon-map.md was stale — it documented 43 icons from the first enumeration
pass, but the real set is 116 (87 OSS, 87 cloud, overlapping). Regenerated from
the verified map with true pre-migration usage counts pulled from git, and 27
inexact pairs called out with the reason each differs: lucide has NO filled
variants (8 icons render lighter), it dropped brand icons (Slack is simply
gone), and several are approximations (FilePdf -> FileText loses the format
hint). This is the artifact a reviewer needs to sanity-check those calls.

Four shims had no tests, which contradicts the rule in shim-convention.md that
every shim must cover the behaviours justifying it. Added 67 tests:
- antd-inputs (14) — the onChange CONVENTIONS, which differ per component and
  which Radix inverts: Input gets an event, InputNumber a number, Checkbox an
  event with target.checked, Switch a boolean.
- antd-datetime (14) — the D7 contract, i.e. onChange hands back a MOMENT so
  `date?.toISOString()` at the call-sites keeps working.
- antd-leaves (17) — including the raw rgb() Tag colour that must not be
  dropped just because it is not a known token.
- antd-structure (22) — DataTable's antd column/render contract, and Badge's
  count/overflow/showZero rules.

P2-02's deferred `body { overflow: hidden }` check is done. Radix's dialog
scroll-lock also sets body overflow and restores the prior value on close; the
risk was it restoring the wrong one and leaving the fixed app shell scrollable.
Three tests pin it: overflow stays hidden before/during/after, survives
repeated cycles, and is NOT left hidden on pages that never pinned it. The
index.css comment now records the outcome instead of reading as a TODO.

One real bug surfaced while writing these tests: the Tabs shim passed both
`value` and `defaultValue` to Radix, and a present-but-undefined `value` makes
Radix treat the component as controlled — which would have frozen every
uncontrolled tab set. Now it passes exactly one.

Test suite: 148 tests across 15 files, up from 78. Build passes, lint at the
24-warning baseline with zero findings in migration files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dev-deploy frontend image build failed:

    error: lockfile had changes, but lockfile is frozen
    process "/bin/sh -c bun install --frozen-lockfile --ignore-scripts"
    did not complete successfully: exit code: 1

`bun remove antd @ant-design/icons @rjsf/antd
@react-awesome-query-builder/antd` and the `@tanstack/react-table` /
`@react-awesome-query-builder/ui` additions updated bun.lock in the working
tree, but that file was never staged — every earlier commit staged explicit
paths and bun.lock was not among them. So the committed lockfile still listed
antd as a root dependency and was missing @tanstack/react-table, which is
exactly the desync --frozen-lockfile exists to catch.

Nothing about the migration changes; this is the manifest edits reaching git.

Why local checks did not catch it: `bun install --frozen-lockfile` in the
worktree passes, because it validates the WORKING lockfile, which was already
correct. Only a clean checkout — i.e. Docker — sees the committed one. Verified
the fix by copying package.json + bun.lock into an empty directory and running
the container's exact command there: exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs found by comparing the dev deployment against production. Both are in
the P4 structure shim, and both produce a page that is "correct" in the DOM but
broken on screen — so no test, build or lint caught them.

1. Layout had no flex-grow. antd's Layout is `flex: auto`; mine computed
   `flex: 0 1 auto` and resolved to height 0. Every descendant using `flex: 1`
   then collapsed: on the dashboard, `.metrics-dashboard-container` was 0px
   tall while its child was 212px, so the whole page rendered at y=858, below
   a clipped viewport. The content was in the DOM the entire time, which is
   why it looked like a data problem rather than a CSS one.

   Layout.Content had the same issue (`flex-1` vs antd's `flex: auto`).

2. Layout.Sider ignored `collapsed` / `collapsedWidth`. It always applied
   `width`, so with a stored `collapsed: true` preference the rail sat at the
   full 240px while SideNavBar hid every label behind `!collapsed` — an
   icons-only sidebar in an expanded gutter. `collapsible` and `collapsedWidth`
   were also leaking onto the DOM as invalid attributes.

Layout now also switches to a row when it contains a Sider, matching antd's
hasSider auto-detection. That is done via an explicit `__isSider` marker rather
than `c.type === Layout.Sider`: the identity check is fragile because Sider is
assigned after Layout and does not survive HMR or wrapping.

7 regression tests cover both: flex-auto on Layout and Content, row/column
switching, collapsed vs expanded width, and no antd-only props reaching the
DOM. Full suite 155 tests, build and lint green.

Worth noting for the remaining review: this is the class of defect the shim
unit tests structurally cannot catch. They assert rendered output in jsdom,
which has no layout engine — height 0 and height 212 look identical there.
Only a real browser shows it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the previous Layout fix, which was only half right.

`flex-auto` landed and the Sider collapse fix worked (the rail correctly
renders at 65px now), but the dashboard was still empty:
`.metrics-dashboard-container` remained 0px against production's 607px.

The reason is the OTHER half of antd's Layout behaviour. A Layout containing a
Sider lays out as a ROW; mine stayed a column, so the content area got no
height. My first attempt inferred this from `React.Children`, which cannot
work here: PageLayout renders `<SideNavBar>`, and the Sider lives *inside*
that component. Compile-time child inspection can never see it.

antd solves this with runtime context, so this does too — a Sider registers
itself with the nearest ancestor Layout on mount, however deeply nested. The
`__isSider` marker from the previous commit is gone; it was unreachable.

Confirmed against production, whose outer Layout is
`ant-layout ant-layout-has-sider` with `flex-direction: row` at 713px, versus
mine at `flex-col` and 0px.

The new test renders a Sider inside another component, matching how the real
app does it — the earlier test passed a Sider as a direct child, which is
exactly the case that already worked and why the bug survived.

156 tests, build and lint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third layout defect found by comparing the dev deployment against production.

On the workflows page the "Create Prompt Studio" dialog rendered at y=-109 with
`transform: none` — pinned to the top of the viewport with its header clipped
off-screen.

Cause: shadcn's DialogContent is ALREADY centred, via
`top-[50%] translate-y-[-50%]`. My Modal shim treated antd's `centered` prop as
something it had to implement and appended `top-1/2 -translate-y-1/2` — the
same geometry spelled differently. tailwind-merge sees two competing
translate/top utilities, keeps one, and the dialog ends up with no transform at
all.

antd's `centered` is therefore a no-op here: the base component already does
it. The prop is still destructured so it cannot land on the DOM as an invalid
attribute, with a comment explaining why it is deliberately unused — otherwise
this looks like an oversight and gets "fixed" back.

Two regression tests: the base translate utilities must survive alongside
`centered`, and the conflicting spelling must be absent.

Audited the other shims for the same pattern (a wrapper adding positioning
utilities on top of a shadcn primitive's own). The remaining `absolute`/`fixed`
classes in antd-leaves and antd-structure are on elements those shims create
themselves, so there is nothing to conflict with.

158 tests, build and lint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth defect found against the live deployment.

The Add LLM / Add Connector pickers render
`<List grid={{ gutter: 16, column: 4 }}>`. antd switches to an n-column grid
for that; my shim always rendered a divided vertical list, so every adapter
appeared one-per-row in a 600px scroller instead of 4-up. Measured in the
browser: `.list-of-srcs` children all sat at the same x with display:block.

The shim now honours `grid.column` (grid + grid-cols-n) and `grid.gutter`
(gap), and keeps the stacked divide-y list when no grid prop is passed. Column
classes are written out in a lookup rather than interpolated, since Tailwind
scans statically — same reasoning as the line-clamp table in antd-typography.

Two tests: grid mode applies grid-cols-4 and the gutter and drops divide-y;
non-grid mode still stacks.

160 tests, build and lint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth defect found against the live deployment.

The Add LLM adapter settings form (RJSF) rendered 1109px tall in an 800px
viewport. The dialog was pushed to y=-194 and the Submit button sat off-screen,
so the form could be filled in but never saved.

antd wraps modal content in `.ant-modal-body`, and this app's CSS caps that
element — `.add-source-modal .ant-modal-body { height: 695px; overflow: hidden
auto }`, `.retrieval-strategy-modal .ant-modal-body { max-height: 70vh }` and
several more. My Modal shim rendered children directly into DialogContent, so
none of those rules matched anything and nothing constrained the height.

Content is now wrapped in a `.ant-modal-body` element. The class name is what
makes the existing per-modal CSS work again; the `max-h-[70vh] overflow-y-auto`
on it is the fallback for modals that never had a bespoke rule.

Found while verifying P3-05: the RJSF form itself is correct on @rjsf/core —
9 inputs, 3 required markers, descriptions, prefilled defaults, password reveal,
and Test Connection / Submit / Close all render. It was only unreachable.

161 tests, build and lint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the systemic cause behind most of the layout defects found against the
live deployment, rather than another one-off.

The app has ~200 hand-written CSS rules that target antd's internal class
names — `.ant-card-body`, `.ant-modal-content`, `.ant-tabs-nav`,
`.ant-table-body`, `.ant-btn`, `.ant-typography` and ~65 more. antd emitted
those elements; my shims did not, so every one of those rules silently matched
nothing. 109 of them set layout properties (height, overflow, display, flex,
padding), which is exactly why screens looked structurally right in the DOM and
wrong on screen.

Measured before and after: **109 dead layout rules across 53 classes → 8
across 8**. The 8 that remain are leaf styling on features this app does not
currently render (card meta, textarea counters, tab overflow controls).

The shims now emit the class names alongside their Tailwind classes. This is
deliberate coupling to the legacy CSS, not an accident, and it is temporary:
when that CSS is eventually rewritten against the design tokens, the hooks come
out. The P1-05 layout shim already did this for `.ant-space-item`/`.ant-row`;
this extends the same approach to the rest.

Also fixed while here: Divider, Radio.Group/Radio, Segmented items, Popover
inner, Result subtitle, Dropdown menu items and Collapse header/content were
missing their hooks.

Found by static audit rather than by opening screens — the previous five bugs
were each discovered one page at a time, which does not scale and would have
missed the ones on screens nobody happened to visit.

161 tests, build and lint green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…udio

Most severe defect so far: opening any Prompt Studio project showed "Couldn't
load this page" and rendered nothing.

Cause: PromptCardItems.jsx and NotesCard.jsx render `<Collapse.Panel>`, and
SetOrg.jsx renders `<Card.Meta>`. Neither sub-component existed on the shims,
so React received `undefined` as an element type and threw error #130. That
does not degrade one component — it takes down the entire route.

Collapse now supports both antd forms: the `items` data prop and the legacy
`<Collapse><Collapse.Panel header=…>` children, including `showArrow={false}`
which PromptCardItems relies on. Card.Meta renders avatar/title/description.

Added a completeness guard (shim-completeness.test.jsx) instead of only fixing
the two. It scans the app source for every `<Foo.Bar>` usage and asserts the
shims actually expose it. The per-component tests could not have caught this:
nothing in them rendered Collapse.Panel, so its absence was invisible until a
real page tried. The guard covers 14 sub-components today and fails loudly for
any future gap.

It earned its place immediately — it caught that my first Collapse.Panel
assignment had not landed (biome had reordered the export block my patch
anchored to, so the edit silently no-opped).

176 tests across 16 files, build and lint at the 24-warning baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by static audit rather than by clicking: scanning for `Foo.bar(...)`
calls on shim components turned up two undefined statics.

ConfirmModal calls `Modal.useModal()` and then `modal.confirm({...})`. Neither
existed, so every consumer threw a TypeError the moment its button was clicked.
That is 12 components — delete actions across prompt studio, workflows, manage
docs, LLM profiles, custom synonyms and the top nav.

useModal now returns `[api, contextHolder]` and implements confirm/info/
success/error/warning/destroyAll on AlertDialog, so it shares behaviour with
useConfirm() instead of becoming a second confirm pattern. Escape and
outside-click resolve as Cancel.

Modal.confirm is implemented too — the fully-imperative form callable outside
React, which mounts its own root. No OSS call-site uses it today, but the cloud
plugins have three.

Extended the completeness guard to cover static calls, not just `<Foo.Bar>`
JSX. It now strips comments before scanning: a doc comment mentioning
`Modal.confirm` is not a call-site, and flagging it would teach people to
ignore the test.

180 tests across 16 files, build and lint at the 24-warning baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleventh defect. The workflows "Create Prompt Studio" dialog rendered at
y=-127 with its header clipped off-screen, even after the earlier centring fix.

That earlier fix was correct — the classes were right this time. The override
came from the app's own stylesheet:

    .prompt-studio-modal { padding: 10px; top: 20px; }

antd's modal wrapper is statically positioned, so `top: 20px` read as "20px
from the top of the viewport" and worked. The shadcn Dialog is
`position: fixed` and centres itself with `top: 50%` + `translateY(-50%)`, so
the same rule overrode the centring while the transform still applied — pulling
the dialog 127px above the viewport.

Removed the rule and left a comment explaining why, since it looks arbitrary
otherwise. Centring is the component's job now.

Added css-collisions.test.js rather than only fixing the one rule: it scans
every stylesheet for a modal/dialog ROOT selector setting top/bottom/transform
and fails with the offending file and rule. It deliberately ignores inner
elements (`__body`, descendant selectors, `.ant-*`), which cannot fight the
root's positioning. The remaining `.retrieval-strategy-modal__*` rules are
inner elements and are correctly not flagged.

This is the third distinct failure mode that jsdom cannot see (height 0, dead
CSS hooks, and now positional overrides), so it is worth having a static guard
rather than relying on someone opening the right screen.

182 tests across 17 files, build and lint at the 24-warning baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelfth defect, and the most silent one yet: Prompt Studio's Export button did
nothing. No menu, no error, no network request — I instrumented fetch and XHR
to confirm zero calls were made.

Export is the child of a `<Dropdown>`, and Radix renders its trigger with
`asChild`, attaching handlers through a ref. Neither CustomButton nor the base
shadcn Button forwarded refs, so the ref went nowhere and the trigger was never
wired up. A dropped ref throws nothing and logs nothing, which is why this
survived 182 passing tests and a full route sweep — the page rendered fine,
the button just wasn't connected to anything.

Both now forward refs. That covers the 24 Dropdown call-sites, plus Popover
and Tooltip triggers that use the same asChild mechanism.

Audited the other primitives: Badge, Kbd, Label, Skeleton and Spinner are also
plain functions, but none is used with asChild anywhere, so they are not
causing breakage. Left alone rather than changed speculatively.

Four regression tests: the base Button and CustomButton each forward to a real
DOM node, a Dropdown wrapping CustomButton gets aria-haspopup/data-state
(proving Radix wired the trigger), and the menu actually opens on click.

186 tests across 18 files, build and lint at the 24-warning baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by the shim-completeness guard once the enterprise plugins were
overlaid: ReviewHeader.jsx:910 renders <Dropdown.Button>Download File</...>,
and Dropdown.Button was undefined. That is React error #130, which takes
down the whole manual-review route rather than just the button — the same
failure mode as the Collapse.Panel bug.

Dropdown.Button is NOT Dropdown. In <Dropdown> the child IS the trigger, so
naively aliasing the two would make "Download File" open a menu instead of
downloading. antd's split button keeps the halves separate: children is a
real action button wired to onClick, and only the chevron opens the menu.
The three new tests pin exactly that separation, since it is the one thing
an alias would silently get wrong.

The chevron half carries aria-label="More actions" so both halves stay
distinguishable by accessible name.
The shim accepted `presets`, `disabledDate`, `allowClear`, `onOk` and
`format` and did nothing with them. Nothing crashed, so this survived the
migration invisibly — but three of the five are behaviour, not decoration:

  - `presets`      MetricsDashboard's "Last 7/30/90 Days" buttons never
                   rendered. Those are the primary way the range gets set,
                   so the control looked finished while its main affordance
                   was missing.
  - `disabledDate` MetricsDashboard uses it to block future dates. Ignored,
                   users could query tomorrow. Now probed outward from today
                   and mapped onto the inputs' min/max, which is the bound a
                   native input can actually enforce.
  - `allowClear`   antd defaults to true; MetricsDashboard passes false
                   because its handler drops anything that is not a complete
                   pair. Emitting null there strands it on a stale range.

`onOk` now fires when a range becomes complete (there is no popup confirm
button to hang it off). `format` and `size` are destructured to keep them
off the DOM.

Also stops forcing moment on the way out. ExecutionLogs holds moment,
MetricsDashboard holds dayjs; the shim rebuilt every emitted date as moment,
handing MetricsDashboard a type it never opted into. It happens not to break
because that code only calls .toISOString(), which both implement — but it
quietly reverses D7's promise that this layer does not change what flows
through it. Emitted dates are now cloned from the caller's own instance.

Each of the five behaviours has a test, and each was mutation-checked: the
prop was re-broken one at a time and the matching test failed every time, so
these assert the fix rather than restating it.
… broken

Live check on the dashboard caught this: the preset buttons rendered, but
`disabledDate` produced no `max` bound, so future dates were still pickable
— the very thing the previous commit claimed to fix.

Cause: `new sample.constructor(isoish)` looks like a reasonable way to
rebuild a date in the caller's library. It is wrong for both libraries in
use. dayjs's internal constructor takes a config OBJECT, so handed a string
it ignores it and returns TODAY. moment's returns an object that throws on
.format(). So the disabledDate probe compared today against today on every
iteration, never crossed the boundary, and yielded no bound.

Now clones the caller's instance and re-points it field by field, which both
libraries support (dayjs setters return a new instance, moment's mutate and
return this; assigning the result covers both). The result is asserted to
land on the exact requested instant before it is returned.

The reason this got through: the test used a hand-written dayjs-shaped stub
whose constructor DID accept a date string, so it validated the stub rather
than the shim. Replaced with the real dayjs and moment, plus a case pinning
the actual predicate MetricsDashboard passes. Re-broken deliberately to
confirm the new test fails against the old approach.
Caught by driving the deployed dashboard: its range read 28 Jul → 28 Jul
when the default is "last 30 days", and clicking a preset appeared to do
nothing because the fields already showed today either way.

`moment(dayjsInstance)` is the culprit. It does not throw and does not
report invalid — it silently returns a moment for TODAY. `toInputValue`
called it for anything that was not already a moment, so every dayjs value
rendered as today with nothing to indicate it. MetricsDashboard holds dayjs,
so its whole range was wrong on screen while its state was correct.

Values exposing valueOf() (dayjs, moment, Date) are now normalised through
the epoch instant before parsing. Strings are unaffected: String.valueOf()
returns the string, so ISO parsing is unchanged.

This one predates the previous two commits — the presets and disabledDate
work was correct, but sat on top of a display path that had been broken for
every dayjs caller since the shim was written. Verified across dayjs,
moment, ISO string, Date, unparseable and null; the two new tests fail when
the old moment(value) call is put back.
Two native date inputs were parity with nothing — antd's RangePicker has
always been a two-month calendar with a preset sidebar, so the inputs were a
downgrade users would notice. New component for us, not new capability.

Adds a shadcn Calendar over react-day-picker v10 (which ships no stylesheet,
so every colour is a Midnight Bloom token and it tracks light/dark), and
rebuilds RangePicker as a single `ant-picker-range` trigger opening a
popover: preset sidebar on the left, two months on the right.

The external contract is unchanged and still mutation-tested: moment/dayjs
tuples in and out, presets, allowClear, onOk, disabledDate. Two things the
calendar does BETTER than the inputs it replaces:

  - `disabledDate` is per-date in antd, and a calendar greys out individual
    days. The native inputs could only approximate it by probing outward for
    a min/max bound.
  - the whole range is one control, so there is no half-updated state
    between two separate fields.

Two library behaviours worth recording, both found by probing rather than
assuming:

  - react-day-picker reports {from, to} with BOTH set to the clicked day on
    EVERY click; it does not distinguish opening a range from closing one.
    Taken at face value, onOk fires on click one and click two restarts
    instead of completing. An explicit anchor restores antd's semantics and
    orders the ends so a backwards selection still yields start <= end.
  - two months plus outside-day overflow means one date can appear twice in
    the DOM, so the test helper takes the non-outside cell.

All six behaviours were re-verified by mutation. The first pass missed one:
swapping likeSample for moment inside the disabledDate path went undetected,
because the library-preservation tests only covered onChange. Added a test
asserting the type the predicate itself receives.

bun.lock is updated (not package-lock.json, which is gitignored here) —
`bun install --frozen-lockfile` is what the Docker build runs, and an
npm-only install would have failed it the way a missing bun.lock did before.
The unit tests each drive one prop in isolation, which is how the earlier
dayjs display bug slipped through: every individual assertion passed while
the combination users actually see was broken.

This renders the exact props MetricsDashboard passes — dayjs values, its
disabledDate predicate, allowClear={false}, size, and all three presets —
then drives the whole interaction: open the trigger, confirm two months and
the preset sidebar, click a preset, and assert the emitted pair is dayjs and
spans exactly 7 days.

Cheap to run and it fails on any of the regressions this branch has already
hit once.
Live check on the deployed dashboard: the popover opened 250px wide and
~700px tall, bottom edge at 1070px in a ~780px window — the two months were
stacked in a column instead of sitting side by side, and the bottom of the
calendar was unreachable.

Cause: `sm:flex-row` on the months and `sm:flex-col` on the preset sidebar.
Tailwind's `sm:` measures the VIEWPORT, but this content lives inside a
popover whose own width is what decides the layout. On a wide screen the
breakpoint matched and still produced a stacked column, because the popover
never gets the viewport's width. Both are now unconditional rows.

Worth noting how close this came to shipping: the screenshot was clipped at
the viewport edge, so the popover looked plausible until its geometry was
measured. jsdom has no layout engine and could never have caught it.

The added guard asserts the class contract rather than the geometry — it
fails if a `sm:` variant reappears in the popover — and was confirmed by
reintroducing the bug.
jaseemjaskp and others added 19 commits August 31, 2026 11:18
The count span is painted over the child and `offset` routinely drags it
across the child's middle, but it was still hit-testable, so it swallowed
the clicks meant for the child underneath.

The width dependence made it look intermittent rather than broken. Measured
on Prompt Studio's audit button (32x24, 12x12 icon, offset [-2, 12]): a
one-digit count is 16.9px and masks 30% of the icon, leaving the centre
reachable; two digits is 24.2px and masks 85%, and the button goes dead.
So the icon worked on most prompts and stopped working on exactly the ones
reviewers had edited 10+ times.

The count is decoration, so mark it pointer-events-none -- this covers every
Badge-over-a-clickable call site, not just that one.
…page

The shim sized the pager off `dataSource.length` via TanStack's client-side
row model, but every resource list pages on the server: ToolSettings requests
`?page=1&page_size=10`, so it holds 10 rows while the response's `count` says
12. Page count came out as ceil(10/10) = 1, and the pager collapsed to a
single button over a list the API had already advertised a `next` link for.

antd's rule is the other way round -- it slices `dataSource` only when that
array holds MORE rows than fit on a page, and otherwise renders what it was
handed and lets `total` drive the pager. That distinction is the whole of
server-side paging. Derive the page count from `total`, slice only when the
call site really did hand over everything, and honour `current` as antd's
controlled-pager signal.

`onChange` was undeclared besides, so it fell into `...props` and onto the
wrapper <div>, where React ignores an unknown `onChange` attribute without a
word. ResourceTable's `handleChange` never ran, so even once the button
existed it did nothing. `showTotal` was dropped the same way, which is why
"Page 1 of 2 - 12 items" was missing from every one of these tables.

This was never specific to LLMs -- all nine lists built on usePaginatedList
were stranding rows 11+ (Vector DBs, Embeddings, Text Extractors, Connectors,
Prompt Studio, Workflows, Pipelines, API Deployments). It showed up on the LLM
settings screen first because that is the only one most orgs fill past ten.

Note what the tests could not see. Bridging TanStack's `onPaginationChange`
back to the parent passed all 17 unit tests and still ping-ponged in the
browser: TanStack calls `resetPageIndex()` itself whenever `data` changes, so
page 2's rows arriving immediately asked for page 1 and the pager snapped back
within a frame. Hence no bridge and `autoResetPageIndex: false` -- the
`currentPage` clamp already covers the shrinking-list case it exists for. The
regression test drives a real round trip, which is the only shape that catches
this; it fails with the bridge restored.
antd's `<List.Item>` has two trailing slots -- `actions` and `extra` -- and
call sites pick whichever reads better, expecting the same right-hand
placement from either. The shim declared only `actions`, so `extra` fell into
`...props` and landed on the wrapper <div> as an unknown DOM attribute, where
React drops it without a word.

Every control put there vanished. Share access listed who a resource was
shared with and offered no way to un-share them: the delete icon that revokes
a user's or a group's access is passed as `extra`, so once an adapter was
shared with someone there was no route back short of the API. Export Tool's
per-user remove, Group members' remove, and Co-owners' remove went the same
way, all silently.

Render both slots in the trailing group, in antd's order (children, actions,
extra). The regression tests assert `extra` alone and `actions` + `extra`
together, and both fail against the old shim.
antd's `.ant-avatar` is `display: inline-block`, so `<Avatar /> name` renders
on one line and call sites lean on it: Share access, Export settings and
Co-owners each pass `<><Avatar /><Typography.Text /></>` as a single
`List.Item.Meta` title and expect the avatar beside the email.

The shadcn primitive is `flex` -- a block-level box, which cannot share a line
with the text next to it -- so every one of those rows rendered the avatar
stacked ABOVE the address, at roughly double the row height the design calls
for.

`inline-flex align-middle`, passed through the shim's own `cn` so
tailwind-merge resolves it over the primitive's `flex`. Avatars inside a flex
parent are unaffected: a flex item is blockified regardless of its own
`display`, which covers the table and card call sites that lay out their own
children.
An icon rendered as a bare `<span>` or `<svg>` puts NOTHING in the
accessibility tree. Radix merges a Popconfirm's or Dropdown's trigger props
onto whatever child it is handed, so these all worked under a mouse and were
unreachable by keyboard and unnameable by a screen reader:

- Share access -- revoke a user's or group's access
- Export settings -- remove a user from a custom share
- Manage Groups -- the row kebab, i.e. Manage members / Edit / Delete
- Group members -- remove a member

Each becomes a `<Button type="text">` from the antd-button shim with an
`aria-label` naming its subject ("Revoke access for trt"), which is the idiom
CoOwnerManagement and the card kebab menus already use. The shim spreads the
label onto a real <button> and shadcn's variants size the icon, so no
component CSS is needed for any of them.

The Groups kebab also carried `rotate={90}`, an antd icon-font prop that does
nothing on a lucide SVG beyond emitting an invalid attribute -- it had been
rendering horizontal. `EllipsisVertical` is the glyph it was asking for.

Checked in a browser rather than from the diff, because the failure is
invisible in the DOM: driving each control by its accessibility-tree node is
the proof, and none of them had one before.
antd's Menu fires both `onClick` and `onSelect` when a selectable item is
picked, and a call site may listen on either. The shim only forwarded
`onClick`, so `onSelect` fell through into the rest props and landed on the
<nav> as React's DOM `select` handler — which never fires on a click.

The Output Analyzer's Document List wires its handler to `onSelect` alone,
so picking another document silently did nothing: no error, no warning,
just a dead menu.
The row was recorded by an onClick on the kebab icon — the Dropdown's
trigger. Radix opens the menu on pointerdown and pins `pointer-events:
none` on <body> while it is open, so the click that would have followed
never lands and that handler never runs. Edit therefore navigated to
/users/edit with no location.state, which InviteEditUser bounces to the
dashboard; Delete's confirmation named no user at all.

Build the menu entries per row instead, so each one closes over its own
record and nothing depends on the trigger's click.
The Card shim never declared antd's `hoverable`, so `...props` put it on
the `<div>` as an unknown attribute and the pointer cursor and hover lift
were simply lost. Ten call sites pass it -- the adapter cards in Add LLM
among them, which read as inert.

Consume the prop and style it with Tailwind rather than emitting
`ant-card-hoverable`: nothing in the app's CSS targets that class, and
picking up antd's own rule would depend on whether an antd Card happened
to render.
The LLM, Vector DB and Embedding settings pages list adapters, not
profiles, so "New LLM Profile" named the wrong thing. Text Extractor and
OCR were already phrased this way.
…oltips

Agentic Table Extraction Settings came back empty every time it was
reopened: the saved Lite LLM adapter showed its placeholder, and the
three page fields showed defaults rather than what had been saved.

The modal fetches before it renders -- a spinner stands in for the
`<Form>` while the request is in flight -- so `setFieldsValue(fetched)`
lands while the form is still unmounted. Mounting then ran
`methods.reset(initialValues)` and discarded that write. antd merges the
other way round (`setValues({}, initialValues, this.store)` -- the store
wins), which is why the call-site worked before the migration. Seed
underneath the current values instead, skipping `undefined` so a field
RHF has merely registered keeps its initial value.

`tooltip` was never declared either, so every use fell into `...props`
and landed on the wrapper div: the marker never rendered and the config
object reached the DOM as an attribute. Accept both antd spellings -- a
bare node and `{ title, icon }` -- which restores the hints on the two
prompt-card settings modals, the manual-review rule editors and the
Stripe product form. The trigger is a real button because Radix opens on
hover AND focus, so a bare icon would hide the hint from keyboard users.
Ticking "Enable Postprocessing Webhook" and typing the URL within the
300ms toggle debounce unticked the box and dropped the URL input: the
URL save carried the `handleChange` from a pre-tick render, so its
optimistic `{...promptDetailsState}` re-asserted every other field as it
stood then. Each PATCH only carries its own field, so the server kept
both values and a refresh looked correct.

Write the field functionally, and roll back only that field on failure —
the same hazard applied to `active`, `required` and `profile_manager`.

Header re-seeded all four local fields whenever the `promptDetails`
object changed, so the toggle's own save landing mid-keystroke blanked
the URL the user was still typing. Key one effect per field on that
field's value; `details` fed none of them.
The adapter lists on the Default LLM Profile page grow with every adapter
an org configures, so picking one meant scrolling a list of near-identical
generated names. `showSearch` filters as you type; the shim's default
filter reads the option's own text, which is the adapter name, so no
filterOption is needed.
The Table shim presents antd's column API but silently dropped every filter
prop on it — `filters`, `filterDropdown`, `filterIcon`, `onFilter`,
`filteredValue`, `defaultFilteredValue`, `filterMultiple`, `filterSearch`. The
call-sites still declared them; nothing read them, so the headers rendered as
bare titles. The visible casualties are the three Execution Logs surfaces,
whose whole purpose is finding one execution among thousands: the Execution ID
search, the file-name search and the Status filter all disappeared. Logs &
Notifications, the LLMWhisperer dashboard, Lookup Usage and Manual Review lost
theirs the same way.

Sorting was wrong in the same place and for the same reason. antd reads the
sorter's SHAPE: a function is a local comparator, `sorter: true` means the
server sorts and the table should only report the click. Both sorted locally
with TanStack's guessed comparator and never called `onChange`, so every
`sorter: true` column reordered the ten rows already on screen while no request
went out — the logs list looked sorted and wasn't, since the rows that belonged
at the top were still on page two.

`sortDirections` was swallowed onto the wrapper div too, where React warned
about an unrecognised DOM attribute on every render of all four logs tables.
All four pass `["ascend", "descend", "ascend"]`, antd's idiom for a cycle that
never returns to unsorted.

One subtlety is worth naming because it is invisible until it bites: on commit,
a CONTROLLED column must report the keys the user just picked, not its own
`filteredValue`. That prop is the parent's current value — precisely the stale
one — so echoing it back is how the parent learns nothing changed. LogModal's
level filter is controlled on `selectedLogLevel` and sets it from this
callback, so it sat permanently on "no level".

Verified against a real deployment, not just the suite: the ID search, the
file-name search, the status filter's server round trip, the log-level filter
inside the modal, and `ordering=created_at` / `-created_at` on the wire.
`handleClearFilter` set the parent's level to null and then called `confirm()`,
which publishes whatever `setSelectedKeys` last set. That re-render had not
happened yet, so `confirm()` re-sent the level being cleared and the log list
stayed filtered. Empty the draft first. This one is antd's semantics too, not
an artefact of the shim, so Clear was broken before the migration as well.

The radio group also read `selectedKeys[0] || null`, and Radix treats a nullish
value as uncontrolled — picking the first level flipped it to controlled and
React warned. An empty string is the controlled spelling of "nothing selected".
A sortable column drew nothing until it was sorted, so on Execution Logs
neither "Executed At" nor "Execution Time" advertised that they sort, and
the lone chevron that appeared after a click read as decoration rather
than as state. Render antd's caret pair whenever `sorter` is set, greyed,
with the applied direction in the primary colour.

antd pins a column's affordances to the right edge of its header cell;
laying them inline after the title left each one wherever its own text
happened to end. The header is now a flex row with the title growing
(antd's `.ant-table-column-title { flex: 1 }`, which also keeps a centred
column's title centred) and the icons pushed to the trailing edge. The
flex row is conditional so a column with no affordance keeps its
alignment.

Not done here: antd also darkens the sorted column's header. Ten
stylesheets set `.ant-table-thead > tr > th { background }` at higher
specificity than a Tailwind utility, so that highlight would land on
some tables and silently not others.

Every custom `filterIcon` in the app is a bare lucide icon with no size,
so all four came out at lucide's 24px default and towered over the 12px
carets now beside them. The trigger sizes its own icon instead, as antd
does with `.ant-table-filter-trigger .anticon`.
react-day-picker renders a dropdown caption as a <select> PLUS a visible
label span carrying the same text — the select is meant to lie invisibly
over the span and take the clicks. Styling the select as the visible
control drew both, so the range picker's header read
"August August › 2026 2026 ›" per month and the doubled width slid under
the nav arrows.

So: `dropdown_root` is the bordered control users see, the select is a
transparent overlay on top of it, and the span supplies the text. The
focus ring hangs off `has-[:focus-visible]` because focus lands on the
select inside, not on the border.

The caption also reserves room for the arrows (`px-8`) rather than
centring into them, and the arrows sit at the top of a caption row that
is now their own height instead of at a hand-tuned offset.

`Chevron` only handled "left" and fell through to ChevronRight for
everything else — including the "down" the dropdowns ask for, so neither
the month nor the year control read as a dropdown. Map all four
orientations.
The Started / Ran for / Processed files cards were pinned to a fixed 60px
height while their bodies measured 66px — 88px once a timestamp wrapped — so
the card clipped its own content and "IST" rendered below the border, under
the table. Use min-height instead and let the row stretch.

The shim's card body carries Tailwind `p-6 pt-0`, which pinned the content to
the top edge and left 24px of dead space beneath it; centre it instead. That
also settles the icons, which sat at three different heights because each was
centred against a text block of a different size.

`.logging-card-icons` set only a margin, so the lucide SVGs ignored it and
fell back to their own 24px default — the same trap already documented on
`.column-settings-icon`, missed on this rule. Give them an explicit box.

Finally, line up the horizontal gutters: the title sat at 24px, the card row
at 0 and the table at 12px. All three now share one left and right edge.

The card row's `pad-12` class was only ever defined in the llm-whisperer
plugin's Playground.css, which never loads on this page, so it was inert.
Replace it with a real rule of its own.
@hari-kuriakose hari-kuriakose self-assigned this Aug 31, 2026
…resolve-2258

# Conflicts:
#	frontend/src/components/custom-tools/prompt-card/Header.jsx
@sonarqubecloud

Copy link
Copy Markdown

@hari-kuriakose
hari-kuriakose marked this pull request as ready for review August 31, 2026 20:56
@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR applies several Prompt Studio frontend fixes.

  • Displays chunk sizes consistently as token counts.
  • Filters unusable PDF highlight geometry.
  • Adds HTTP polling to recover index status when WebSocket updates stall.
  • Shows per-prompt warnings for single-pass variables that cannot be resolved.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
frontend/src/components/custom-tools/manage-docs-modal/ManageDocsModal.jsx Adds periodic index-status polling and fingerprint-based retirement when socket updates are unavailable.
frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx Corrects chunk-size labeling and presentation to use token units directly.
frontend/src/components/custom-tools/pdf-viewer/PdfViewer.jsx Rejects incomplete and non-positive highlight geometry before rendering or navigation.
frontend/src/components/custom-tools/prompt-card/Header.jsx Displays warning tags for single-pass variables reported as unresolvable.
frontend/src/components/custom-tools/prompt-card/PromptCard.jsx Refreshes the per-prompt unresolvable-variable metadata from prompt-save responses.
frontend/src/components/custom-tools/tool-ide/ToolIde.jsx Simplifies tool updates while documenting why prompt warning metadata is handled locally.
frontend/src/components/custom-tools/profile-info-bar/ProfileInfoBar.jsx Labels profile chunk sizes explicitly as token counts.

Sequence Diagram

sequenceDiagram
    participant UI as Manage Docs UI
    participant API as Document Index API
    participant WS as WebSocket
    UI->>API: Start indexing
    API-->>UI: Request accepted
    alt WebSocket update arrives
        WS-->>UI: Index completion event
        UI->>UI: Clear indexing state
    else WebSocket update stalls
        loop While document remains in indexing state
            UI->>API: Poll index status
            API-->>UI: Current index rows
        end
    end
Loading

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'origin/fea..." | Re-trigger Greptile

@hari-kuriakose

Copy link
Copy Markdown
Contributor Author

@greptileai

Base automatically changed from feat/shadcn-oss-migration to main September 1, 2026 09:00

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Before the findings — the base branch this PR declares no longer exists

This is the thing blocking a merge decision, so it goes first.

  • The PR description says the base is feat/shadcn-oss-migration. That branch is gone from origingit ls-remote --heads origin lists only feat/shadcn-foundation.
  • GitHub therefore reports base: main, and computes this PR as 374 files, +27,683 / −2,103.
  • The head branch has also moved well past what the description covers: un-sprint4-C-frontend now carries roughly 25 additional shadcn commits and two merge commits, tip b9f676c1f"Merge remote-tracking branch 'origin/feat/shadcn-oss-migration' into resolve-2258".

What I reviewed. I scoped to the 6-commit sprint-4 chain, 8df78b40f^..9334948a87 files, +224 / −21 — which reproduces the "7 files" figure in the description:

Commit Ticket
8df78b40f UN-3137, UN-3355
3514226d2 UN-3507
b105f0e25 UN-2900
ad2635ebe review-fix follow-up
c5e8e0b71 UN-3507 follow-up
9334948a8 UN-2900 docs follow-up

Inline anchors below are at the PR head b9f676c1f, so a few line numbers differ from that chain (Header.jsx in particular drifted ~44 lines). Every finding was re-verified against head before posting.

If the intent is to merge this branch into main as-is, this review covers a small fraction of what would land. That is the single assumption most likely to change the verdict.


Verdict — REQUEST CHANGES

Mode: LITE — single-pass, 16/16 lenses, one reader, no subagents. Eligible: 7 files / 245 lines, no disqualifiers.

Summary — Critical: 0 · High: 3 · Medium: 5 · Low: 3 · Lenses run: 16/16

Gate detail: no migrations, no auth/permission surface, no dependency or lockfile change, no IaC/CI/Helm, no wire-format break (UN-2900 consumes an additive backend field). The 5s polling loop was weighed against the concurrency disqualifier and judged out of scope for it — it is a setInterval over an idempotent GET, not a lock/thread/async-ordering primitive. It is reviewed under lens 6 below.

Mode detection: INITIAL — no prior reviews or inline comments on this PR authored by me. (Commit ad2635ebe "Address lite-review findings #1-#6" refers to a review that was never posted to this thread, so there was no prior ledger to reconcile.)


Lens checklist

# Lens Result
1 Spec & intent See findings #4, #6
2 Architectural fit & precedent See finding #8
3 Correctness & edge cases See findings #1, #2, #4, #6, #11
4 Security Clean — no new endpoint or handler; the poll reuses axiosPrivate against the existing tenant-scoped document-index route (ManageDocsModal.jsx:372-375). Tooltip content is user-authored variable names, but the overlay shim uses no dangerouslySetInnerHTML (verified in antd-overlays.tsx).
5 Data integrity & migrations N/A — no migration, schema change, or persisted write in this diff
6 Concurrency Clean, with one noted narrow race: indexDocs is an effect dependency (ManageDocsModal.jsx:248-255), so retiring one document tears down and re-arms the interval, resetting the baseline Map for the documents still indexing. A sibling completing inside that re-render window has its completed state adopted as the new baseline and never retires. The window is milliseconds and both are usually retired in the same tick's loop, so it does not reach Medium — but it compounds finding #3. Store identity was checked and is stable (custom-tool-store.js:68-70), so the interval does not thrash.
7 API & contract compatibility See finding #1 (index-space contract between producer and viewer). The backend field contract is otherwise sound — single_pass_unresolvable_variables is additive, a SerializerMethodField returning list[str] at backend/prompt_studio/prompt_studio_v2/serializers.py:26,35 on origin/un-sprint4-D-backend, and the frontend defaults to []. The description's "either merge order is safe" claim is verified.
8 Reliability & resilience See finding #3
9 Performance & cost See findings #3, #5
10 Observability Not covered — no metric or structured-log surface exists in this frontend for a new polling path to hook into, so I could not assess this against any repo convention.
11 Operational safety Not covered — the new poll ships unflagged and there is no rollback lever short of a revert, but this repo has no frontend feature-flag convention I could measure it against.
12 LLM/agent-specific N/A — no prompt template, model call, tool config, or retry/fallback logic in this diff. UN-2900 renders a warning about prompt variables; it does not touch the model path.
13 Testing See finding #7 (in the body below — no single anchor line)
14 Dependencies & build Clean — no package.json, bun.lock, Dockerfile, or CI file in the review surface. TriangleAlert comes from lucide-react, already a dependency.
15 Code quality See finding #10
16 Doc & comment accuracy See findings #2, #9. Everything else checked out: the PromptCard.jsx:74-83 latch cross-reference in the ToolIde comment is accurate at head; the DocumentParser.jsx:273 stable-key claim is accurate; PromptCard.jsx:379 passing promptDetailsState is accurate; the CUSTOM_DATA exclusion claim matches find_unresolvable_single_pass_variables on the backend branch; the coordinate shape claim in PdfViewer.jsx matches RenderHighlights.jsx. Comment density here is unusually high, but the claims are — with the two exceptions above — verifiable and correct.

Finding that could not be anchored inline

[Medium] [Lens 13] — No tests for a new async state machine, in an area that already has them

  • Location: whole review surface — git diff --stat 8df78b40f^ 9334948a8 lists 7 files, 224 insertions, and zero test files. No single line to anchor to.
  • Failure mode: the UN-3507 poll introduces a baseline/fingerprint retirement protocol with at least four distinct branches (recordBaselineOnly, unseen-key adoption, fingerprint-moved, silent) and none is exercised. Findings #2 and #3 are both single-assertion unit tests on the retirement predicate.
  • Evidence: the base branch carries 33 frontend test files, including one in a directory this PR edits — frontend/src/components/custom-tools/prompt-card/DisplayPromptResult.test.jsx. The harness and the local convention both already exist; this diff simply does not use them.
  • Suggested fix: extract the retirement predicate into a pure function and table-test it: first index success, re-index mid-run, re-index complete, failed index.
  • Confidence: High.

Open questions for the author

  1. The base branch this PR declares no longer exists (see the top of this review). What is the intended merge path — is the shadcn migration landing first under a different branch name, and should this PR's base be repointed at it?
  2. The head branch has moved well past the 3 commits the description covers. Was the merge intended to be part of this PR, or is it integration noise that should be rebased away?
  3. Finding #1: does manual-review highlight navigation get exercised on documents containing empty pages? That determines whether the index shift is a common or a rare path.
  4. Finding #3: is there any signal on the document-index response that distinguishes "indexing failed" from "indexing still running"? If not, bounding the poll is the only available fix.

Assumptions made

  • The review surface is the 6-commit sprint-4 chain, not the PR as GitHub computes it. If the intent is to merge the branch tip into main as-is, the eligibility gate would have refused this for lite review outright.
  • The three commits added after the description was written (ad2635ebe, c5e8e0b71, 9334948a8) are in scope as part of the same sprint-4 work.
  • origin/un-sprint4-D-backend at eb9ff6ae8 is the backend counterpart; the single_pass_unresolvable_variables contract was verified against it.
  • Cloud-side impact for finding #1 was assessed against the working tree of the unstract-cloud repo, not a branch matched to this PR.

Comment on lines +99 to +115
// UN-3355: entries are [pageNumber, y, height, pageHeight]. Empty
// pages in the document make LLMWhisperer emit a page number with
// y/height/pageHeight all zero. `some(v => v !== 0)` kept those,
// because the page number alone is non-zero -- the viewer then
// scrolled to the page and highlighted nothing. Require the
// geometry itself to be usable instead.
if (coordsOnly.length < 4) {
return false;
}
const [, y, height, pageHeight] = coordsOnly;
return (
Number.isFinite(y) &&
Number.isFinite(height) &&
Number.isFinite(pageHeight) &&
height > 0 &&
pageHeight > 0
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 7] — This filter shifts the highlight index space, desyncing cloud highlight navigation

  • Failure mode: currentHighlightIndex is produced against the unfiltered highlightData, but PdfViewer indexes into the filtered array. Every entry this filter drops shifts all later indices by one. Once currentHighlightIndex >= processedHighlightData.length, the guard at PdfViewer.jsx:119-121 fails and the component falls through to rendering every highlight at once — so the last N navigation steps show all highlights instead of one. The jump-to-page effect at :150-151 is wrong by the same offset.
  • Evidence: the cloud plugin sets the index space from the raw array — frontend/src/plugins/manual-review/components/result-editor/ResultEditor.jsx:612 and :1009, both setTotalHighlights(highlightData.length); — and passes the raw array plus that index into PdfViewer at frontend/src/plugins/manual-review/page/ManualReviewPage.jsx:2246-2254. Navigation wraps against totalHighlights at ManualReviewPage.jsx:1064-1076. (Those three paths are in the unstract-cloud repo, not this one.)
    Coordinate semantics confirmed against the consumer frontend/src/plugins/pdf-highlight/RenderHighlights.jsx (area[0]=page, area[2]/area[3]=height/pageHeight) — so the shape comment added here is correct; it is the index space, not the geometry, that breaks.
    Concretely, [A(valid), B(empty page), C(valid)]: the counter reads "2 / 3" but shows C, and step 3 shows all highlights. Before this change B survived the filter (its page number was non-zero) and the indices lined up.
  • Suggested fix: keep the index space single. Either filter at the producer so totalHighlights, currentHighlightIndex and the viewer all derive from the same array, or filter to {original index, coords} pairs and map currentHighlightIndex through that.
  • Confidence: High on the mechanism; Medium on how often empty-page entries appear in manual-review documents.

Comment on lines +347 to +356
// Identifies one indexing run for a document. `modified_at` is bumped by
// BaseModel on every save, so it changes when a re-index completes even
// though the index id itself may be unchanged.
const indexFingerprint = (item, indexType) =>
[
indexType === indexTypes.raw
? item?.raw_index_id
: item?.summarize_index_id,
item?.modified_at,
].join("|");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 3, 16] — This fingerprint retires a document mid-run on re-index, the exact case the comment above claims to prevent

  • Failure mode: the fingerprint is index_id + "|" + modified_at. The backend saves the same IndexManager row partway through an indexing run, bumping modified_at while the previous run's index id is still in place. On a re-index of an already-indexed document both retire conditions at :419-425 become true after the extraction phase — the fingerprint has moved, and handleIsIndexed() is true on the stale id — so deleteIndexDoc() fires and the spinner clears while indexing is still running. Same on a failed re-index.

  • Evidence: backend/prompt_studio/prompt_studio_index_manager_v2/prompt_studio_index_helper.py:125

    index_manager.save(update_fields=["extraction_status"])

    called from backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py:2594 (success) and :2576 (failure), both before the index id is written at prompt_studio_index_helper.py:62-64. modified_at is auto-included on partial saves by backend/utils/models/base_model.py:99-113, and it reaches the client because IndexManagerSerializer is fields = "__all__".

    The comment being contradicted, verbatim: "Retiring on 'has an index id' would be wrong... Compare against a fingerprint of the index row captured when the poll armed, and retire only once it actually changes." The fingerprint does not distinguish a mid-run save from a completed run.

  • Suggested fix: do not infer completion from an audit timestamp. Require raw_index_id / summarize_index_id itself to differ from the baseline value, or add an explicit run/status field to the document-index response and key off that.

  • Confidence: High.

Comment on lines +219 to +255
useEffect(() => {
if (!open || indexDocs?.length === 0) {
return undefined;
}

// "<indexType>:<docId>" -> fingerprint of that row when the poll armed.
const baseline = new Map();

const poll = (recordBaselineOnly) => {
const opts = { silent: true, baseline, recordBaselineOnly };
handleGetIndexStatus(rawLlmProfile, indexTypes.raw, opts);
const summarizeProfileId =
summarizeLlmProfile || (summarizeLlmAdapter ? defaultLlmProfile : null);
handleGetIndexStatus(summarizeProfileId, indexTypes.summarize, opts);
};

// Record the starting state now, so the first tick can already detect a
// change rather than spending one learning it. This request races the
// interval only in the sense that a tick firing before it resolves finds
// an empty map and adopts what it sees as the baseline -- which is the
// same outcome, one tick later.
poll(true);

const intervalId = setInterval(
() => poll(false),
INDEX_STATUS_POLL_INTERVAL_MS,
);

return () => clearInterval(intervalId);
}, [
open,
indexDocs,
rawLlmProfile,
summarizeLlmProfile,
summarizeLlmAdapter,
defaultLlmProfile,
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] [Lens 8, 9] — This poll never terminates when indexing fails on the dead-socket path

  • Failure mode: retirement requires handleIsIndexed(indexType, item) at :421, which is !!raw_index_id (:334-345). A first-time index that fails never writes an index id, so the document is never retired, indexDocs never empties, and this interval runs for the entire lifetime of the open modal — 2 requests every 5s, indefinitely, with the spinner never clearing. That is the same "spins forever" symptom UN-3507 set out to fix, now with an unbounded request loop attached.
  • Evidence: the socket path does handle failure — frontend/src/hooks/usePromptStudioSocket.js:98-102 calls deleteIndexDoc(docId) from handleFailed("index_document"). But a dead socket is precisely the condition this poll exists to cover, so that handler is unavailable exactly when it is needed. The comment at :215-218 acknowledges the loop runs "for as long as the modal stays open" but frames that as an error-toast concern only.
  • Suggested fix: bound the poll — a max-attempts or wall-clock deadline after which it stops and surfaces a single terminal message — and retire on an observable failure signal rather than only on success.
  • Confidence: High.

* the literal {{...}} reaches the LLM. custom_data still resolves, so
* the backend excludes it. Warn per prompt rather than blocking.
*/}
{unresolvableVariables?.length > 0 && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 1, 3] — This warning can neither appear nor disappear when single pass is toggled

  • Failure mode: two directions, both wrong. Enabling single pass shows no warnings until a full reload, because promptDetailsState latches on first seed and is never re-seeded. Disabling single pass leaves stale warnings on screen for the same reason — the user sees a warning about a mode they are not in.
  • Evidence: the latch is real — PromptCard.jsx:74-83 returns early once isPromptDetailsStateUpdated is set, and PromptCard.jsx:379 passes promptDetails={promptDetailsState} down to Header. The ToolIde.jsx:333-350 comment documents the enable-direction gap honestly ("'toggle single pass -> every affected prompt warns at once' does NOT hold"), and its cross-references check out. But the disable direction is unhandled and unmentioned, and the fix for it is already in scope: singlePassExtractMode is destructured live from the store at Header.jsx:104 and simply not used in this guard.
  • Suggested fix: gate this render on singlePassExtractMode && unresolvableVariables.length > 0 — one token, live signal already present — which fixes the stale-warning direction outright. The enable direction needs PromptCard to accept prop updates after its first seed.
  • Confidence: High.

Comment on lines 394 to 396
});

handleIndexStatus(indexType, indexStatus);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 9] — Each poll tick writes two fresh arrays into the store, re-rendering every Prompt Studio consumer

  • Failure mode: this call reaches handleIndexStatus (:324-332), which does updateCustomTool({ rawIndexStatus: data }) with a freshly-mapped array every time, even when the status is byte-identical to the previous tick. useCustomToolStore() is consumed without selectors across the tree (Header.jsx:102-110, PromptCard.jsx:61-68, and others), so every subscriber re-renders every 5 seconds for the whole duration of indexing — and forever in the case described in finding connectors #3.
  • Evidence: :389-396 builds indexStatus with data.map(...) then always writes; there is no equality check. Store identity is otherwise stable (frontend/src/store/custom-tool-store.js:68-70 merges partials), so these two writes are the sole per-tick churn.
  • Suggested fix: skip the updateCustomTool call when the newly computed status is deep-equal to the current one.
  • Confidence: High.

}
help={getBackendErrorDetail("chunk_size", backendErrors)}
extra={`~= ${tokenSize}k tokens, Max: ${maxTokenSize}`}
extra={`${tokenSize} tokens, Max: ${maxTokenSize}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 1, 3] — This hint still reads "0 tokens" when editing an existing profile

  • Failure mode: setTokenSize and setMaxTokenSize are called only inside handleLlmChangeForTokens (:374-392), which is wired exclusively to the LLM dropdown's onSelect at :502. Open an existing profile for edit and touch nothing: this line renders "0 tokens, Max: 0" directly beneath a Chunk Size field showing 2048. UN-3137 is specifically about this hint being wrong, and it remains wrong on the edit path — now stated more assertively than before, since the old text hedged with ~= 0.0k.
  • Evidence: :154-155 initialises both to 0; :502 is the only onSelect={handleLlmChangeForTokens}; the edit path populates the form from editedProfile?.chunk_size at :134 without touching either piece of state.
  • Suggested fix: seed both from the edited profile on mount, or drop the mirrored state entirely and derive the hint from the form value (see the separate Low finding on :404-406).
  • Confidence: High.

Comment on lines +363 to +367
const handleGetIndexStatus = (
llmProfileId,
indexType,
{ silent, baseline, recordBaselineOnly } = {},
) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] [Lens 2] — Completion is inferred from an audit timestamp because the endpoint exposes no status

  • Failure mode: the root cause of the mid-run retirement bug is architectural. IndexManager has no status field (backend/prompt_studio/prompt_studio_index_manager_v2/models.py:22-72) and its serializer is a bare fields = "__all__", so the client reverse-engineers "did this run finish" from modified_at. The compensating machinery — a Map mutated across async boundaries, passed by reference into this options bag on a function shared with the user-initiated calls — is the pattern the next engineer copies for the next status the API does not expose.
  • Suggested fix: add an explicit index status/state to the document-index response and let the client read it. Failing that, at minimum keep the poll's retirement logic out of this shared fetch helper.
  • Confidence: Medium — this is a precedent judgment, not a defect independent of the mid-run retirement finding.

Comment on lines +408 to +415
// UN-3137: chunk_size is a TOKEN count -- it is handed straight to
// LlamaIndex's SentenceSplitter, whose chunk_size is documented as "the
// token chunk size for each chunk". The old maths divided by 4 (a
// characters-per-token estimate) and then by 1024, so the hint under the
// field under-reported the real size by ~4096x. No conversion is needed now;
// this only coerces the form's string value and rejects non-numeric input.
// The field has no `min`, so a typed negative is clamped rather than shown.
function toTokenSize(chunkSize) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 16] — This comment overstates the old bug by 1000x

  • Failure mode: the comment claims the old hint "under-reported the real size by ~4096x". The old template appended a k suffix — extra={`~= ${tokenSize}k tokens, Max: ${maxTokenSize}`} — so chunk_size = 2048 rendered as ~= 0.5k tokens, i.e. 500 against an actual 2048. That is ~4.1x, not 4096x. A maintainer sizing the blast radius of the old bug from this comment would be off by three orders of magnitude.
  • Suggested fix: ~4x.
  • Confidence: High.

The comment's substantive claim — that chunk_size is a token count — is correct, and I verified it: the value reaches SentenceSplitter.from_defaults(chunk_size=...) via unstract/sdk1/src/unstract/sdk1/index.py:443-448unstract/sdk1/src/unstract/sdk1/vector_db.py:137-144.

Comment on lines 404 to 406
const handleChunkSizeChange = async (event) => {
const value = event.target.value;
const tokenSize = calcTokenSize(value);
setTokenSize(tokenSize);
setTokenSize(toTokenSize(event.target.value));
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 15] — tokenSize is now a pure mirror of the form field

  • Failure mode: with the conversion removed, toTokenSize (:415) is identity-with-clamp. A useState (:154), this onChange handler, and that helper now exist solely to echo the field's own value back underneath it at :531. This is also what makes the "0 tokens on edit" finding possible — the mirror can fall out of sync with the thing it mirrors.
  • Suggested fix: Form.useWatch("chunk_size", form) and delete the state, the handler, and the helper. maxTokenSize still needs its own state.
  • Confidence: High.

extra={`~= ${tokenSize}k tokens, Max: ${maxTokenSize}`}
extra={`${tokenSize} tokens, Max: ${maxTokenSize}`}
>
<Input type="number" onChange={handleChunkSizeChange} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] [Lens 3] — A negative chunk size is clamped in the hint but still submitted

  • Failure mode: Math.max(0, ...) in toTokenSize (:415) only affects the display. This input carries no min, and chunk_size is a plain IntegerField with no validators (backend/prompt_studio/prompt_profile_manager_v2/models.py:92), so a negative value persists and surfaces later as an indexing-time failure.
  • Evidence: the comment at :414 documents this as known — "The field has no min, so a typed negative is clamped rather than shown."
  • Suggested fix: min={0} on the input.
  • Confidence: High.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants