refactor(sidebar): extract the move-undo primitive behind one hook - #6942
Conversation
Stage 1 of #4626, and a pure refactor: no observable behaviour change and no new surface armed. The drag-move undo that landed in #4617 works only for session drags, and its whole substance is a lifecycle that is invisible on a happy-path read -- an offer stays PENDING until the server acknowledges, latches `superseded` when a third party's placement lands inside that window, and once LIVE is dropped rather than re-validated. All three guard races that a second hand-rolled copy would get subtly wrong, so the mechanism is extracted before another surface arms it. - `useMoveUndo` (new) owns the offer state machine, the `MOVE_UNDO_MS` deadline, and the hover hold that suspends it. Callers inject only the three surface-specific parts: `locate` (where an item sits, with `undefined` meaning gone), `apply` (the optimistic move, acknowledging via `onCommitted`), and `folderExists` (so undo degrades a deleted origin to unfiled instead of posting a 400). - `SessionMoveUndoBar` -> `MoveUndoBar`, with `MovedSession` -> `MovedItem` and its `slotKey` / `sessionTitle` fields renamed to `itemKey` / `itemTitle`. Those names would be lies the moment an artifact or a folder subtree arms the same offer. - `ChatSidebar` is the SOLE caller. Its ~120 lines of offer machinery collapse to the three memoised deps plus a descriptor built at the drag site. `MOVE_UNDO_MS` and the one-way `live` / `superseded` semantics are preserved exactly, including the deps array pinned to `[offer?.id, paused]` (flipping `live`, or writing the frozen remainder, must not restart the clock) and the hold callback capturing the id current when it was handed to a bar (so a retiring bar's hover cannot freeze a newer offer's clock). The `components.sessionMoveUndoBar.*` i18n keys and `session-move-undo*` test ids are deliberately NOT renamed: the strings are translated in every locale catalogue and the ids are what `capture-session-move-undo.mjs` selects on, so renaming them would be churn and breakage for no behavioural gain. Proof of no behaviour change: `MoveUndoBar.test.tsx` (renamed from `SessionMoveUndoBar.test.tsx`) and the `ChatSidebar.moveUndo*` family pass with their assertions untouched -- only the module path, the component identifier and the two renamed field names change. 32 tests green, full frontend suite 26,340 passed, typecheck clean, eslint 0 errors. Refs #4626
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A genuinely subtle lifecycle extracted behind a minimal three-function seam, with a concrete named second caller — proportionate, faithful, and reversible. Suggestions
[DESIGN-REVIEWED] ba45913 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Pure refactor: the bar's markup, strings, i18n keys, timing, and undo lifecycle are byte-identical; nothing a user sees or does changes. Verified against the diff: the only user-facing string touchpoints are [UX-REVIEWED] ba45913 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts are run. Composing the final review. First-Principles-Verdict: CONCERNS The extraction is faithful and well-argued, but the generalized hook ships with exactly one consumer — its entire justification is a stage 2 that hasn't landed. What this change shipsIntent: consolidate the drag-move undo lifecycle into one reusable primitive so artifacts and folder moves can arm it next — an ADDITION of internal surface with zero claimed behavior change; description and diff agree.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] ba45913 |
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (6 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: code-move refactor, extract move-undo primitive behind one hook, no behaviour change.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (6 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: extracts the sidebar move-undo lifecycle behind a single reusable useMoveUndo hook (code-move, no behaviour change).
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (6 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: code-move only -- the drag-move undo lifecycle is lifted verbatim out of ChatSidebar into website/src/hooks/useMoveUndo.ts and the bar is renamed session-agnostic (MovedSession -> MovedItem); i18n keys and test ids deliberately unchanged, existing suites updated for the rename only.
Dispositions — First Principles Review 🟡 CONCERNSAnswering both subtractions for the record, since this landed before the verdict was dispositioned. Both are advisory; neither blocked readiness. Subtraction 1 — "defer
|
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (6 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: code-move only -- extracts the sidebar drag-move undo state into useMoveUndo.ts with markup, Tailwind classes, i18n keys and data-testids byte-for-byte unchanged per the PR's no-visual-delta declaration; tests updated to the new module boundary. [Note: the auto-merge arm is denied by this cron agent's permission posture; merge deferred to a human maintainer.]
Why no screenshot: pure refactor with no rendered delta -- the bar's markup, Tailwind classes, i18n keys and
data-testids are byte-for-byte unchanged; only the module it lives in and which hook owns its state moved.Stage 1 of #4626. A pure refactor: zero observable behaviour change, zero new surface armed.
Why extract before arming anything
The drag-move undo that landed in #4617 works for session drags only. Its substance is not the bar — it is a lifecycle that is invisible on a happy-path read:
supersededwhen a third party's placement arrives inside that window, because by ack time live state may match the destination again (a move away and back) and nothing later could tell;Stage 2 arms artifacts and folder-into-folder moves.
moveFolderTomoves a whole subtree, so it needs exactly these staleness guards — which is why the mechanism is extracted rather than copied. A second hand-rolled copy would get one of the three subtly wrong.What changed
website/src/hooks/useMoveUndo.ts(new) owns the offer state machine, theMOVE_UNDO_MSdeadline, and the hover hold that suspends it. Callers inject only the surface-specific parts:locate(itemKey)— where the item sits now;undefined= gone (retire),null= unfiled rootapply(itemKey, folderId, { onCommitted })— the optimistic move, acknowledging on server successfolderExists(id)— so undo degrades a deleted origin to unfiled instead of posting a 400SessionMoveUndoBar→MoveUndoBar,MovedSession→MovedItem, and itsslotKey/sessionTitlefields →itemKey/itemTitle. Those two names would be lies the moment an artifact or a folder subtree arms the same offer.ChatSidebaris the SOLE caller. ~120 lines of offer machinery collapse to three memoised deps plus a descriptor built at the drag site.MOVE_UNDO_MSand the one-waylive/supersededsemantics are preserved exactly, including two details a re-implementation would drop:[offer?.id, paused]— flippinglive, or writing the frozen remainder, must not restart the clock;onHoldChangecaptures the id that was current when it was handed to a bar, so a bar retiring through its 150msAnimatePresenceexit cannot freeze a newer offer's clock.Deliberately NOT renamed
The
components.sessionMoveUndoBar.*i18n keys and thesession-move-undo*test ids keep their names. The strings are translated in every locale catalogue, and the ids are whatwebsite/scripts/capture-session-move-undo.mjsselects on — renaming them would be churn plus breakage for no behavioural gain.Proof of no behaviour change
The existing tests pass with their assertions untouched — only the module path, the component identifier, and the two renamed field names change:
website/src/test/MoveUndoBar.test.tsx(renamed fromSessionMoveUndoBar.test.tsx) — 15 testswebsite/src/test/ChatSidebar.moveUndo.test.tsx— 15 testswebsite/src/test/ChatSidebar.moveUndoStale.test.tsx— 2 testsTesting
1665 passed / 1666 files,26,340 tests passedsrc/i18n/productName.test.ts, is main-inherited — it flags product-name strings inpullRequestPanel.owner_not_configured_guidanceandsttSettings.the_bundled_audio_decoder_is_missing_or_damaged, fails identically on pristined7b7d65c3, and this diff touches no locale filenpm run typecheckclean,npm run lint0 errorsOut of scope (stage 2, separate PR against the same issue)
ArtifactsPage.tsxhandleDragEnd—moveArtifactand folder nesting viaupdateFolderMut, both currently silentmoveFolderToin the sidebar and its drag routes + menu picksno linked issue: intentional. This is stage 1 of two, and the tracked issue
(#4626) must stay open until stage 2 arms artifacts drags and
folder-into-folder moves, which is what its report actually asks for. The closing
trailer therefore belongs on the stage 2 PR, not this one.