feat(sidebar): confirm and undo a session dragged into a folder - #4617
feat(sidebar): confirm and undo a session dragged into a folder#4617dwu96 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real, well-evidenced UX harm, fixed at the right layer with a proportionate compare-and-set; alternatives and failure modes are argued, not assumed. Suggestions
[DESIGN-REVIEWED] 2f5e600 |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/dashboard/chat_folders.py:632 -- Value-only CAS permits an ABA overwrite |
20ccdda to
fc8a7ee
Compare
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS A silent, mis-aimable gesture gains a named destination and an in-flow undo; strings, states, keyboard path, and screenshots all hold up on cold read. Suggestions
[UX-REVIEWED] 2f5e600 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered — patch read, intent read, repository cross-checked (existing undo/toast mechanisms, the First-Principles-Verdict: CONCERNS Session drags get named feedback and undo — earned; but folder drags share the same silent-disappearance cause and stay unfixed, and the server CAS field is permanent surface for a broadcast-lag window. What this change shipsIntent: after a mis-aimed drag files a session into a folder, the user should see where it went and be able to take it back. ADDITION (titled
Watch
[FIRST-PRINCIPLES-REVIEWED] 2f5e600 |
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: |
fc8a7ee to
0884888
Compare
|
Round 1 dispositions — head is now GPT 5.6, BLOCKING ( The 8s deadline moved out of the bar and into the sidebar as part of the same fix — a subtler leg of the same defect: an offer whose optimistic move never became visible (request failed and rolled back) had no bar mounted to run a timer, so it could sit around indefinitely waiting for a later unrelated move to make it match. It now dies on the same clock whether or not it was ever visible. New regression test ( First Principles, CONCERNS — both items addressed.
Design ✅, UX ✅, Opus ✅ — no action. Unrelated red, for whoever merges: |
0884888 to
a55fdd5
Compare
|
Round 2 dispositions — head is now GPT 5.6, BLOCKING (
Tests: Design Review watch item — FIXED. Good catch: undo replayed First Principles CONCERNS — subtraction taken. The 14 intermediate GIF frames are gone from UX Review suggestion (silent expiry) — no change, per its own "no change needed now"; the countdown line is the warning and the row menu is the recovery path. Unrelated red, unchanged: |
|
First Principles round 2 ( Every other lane is green on this head: GPT 5.6 ✅ (its round-2 blocking finding is closed by the compare-and-set), Design ✅, UX ✅, Opus ✅. |
a55fdd5 to
365994c
Compare
365994c to
a540b23
Compare
|
Round 3 dispositions — head is now GPT B1 ( GPT B2 (
If a maintainer disagrees, the honest options are (a) accept the residue with the CAS as-is, (b) land the revision counter as a follow-up covering all folder writes rather than just undo, or (c) Everything else on the previous head was green: Design ✅, UX ✅, Opus ✅, First Principles 🟡 with only the already-deferred #4626 item, and the Backend Lint red is gone (it was |
a540b23 to
c767508
Compare
|
Round 4 dispositions — head is now BLOCKING ( Evidence and guards, because "it fits now" is exactly the claim a still frame can fake:
FINDING ( Backend suite for this endpoint is now 7 cases, all mutation-verified. Local gates green: First Principles was |
c767508 to
88d0f67
Compare
Dragging a session onto a folder was the one folder move with no feedback:
the row left the list, nothing said where it landed, and a drop one row off
the intended target could only be found by opening folders one at a time.
Every DRAG-initiated move now parks its inverse and the sidebar offers it
back for 8s: a bar naming the destination folder, an Undo button, and the
platform undo chord (⌘Z / Ctrl+Z — not ⌘C, which is copy). Menu moves
("Move to folder…") name their destination already and do not arm it.
Placement is the design decision, not the bar itself. It renders as a
sibling AFTER the session lanes and BEFORE the "Older Sessions" footer, and
outside every scroll container, so it covers neither that persistent control
nor the row that just moved — which is the row the user needs to see to judge
the drop. The cost is that the footer shifts down by ~30px while the bar is
up; a 150ms height transition pays for it, and the alternative (a floating
toast) buys the stable layout by hiding the evidence.
Details worth knowing:
- The offer retires itself when the recorded move stops being the session's
last one (closed, or moved again from another surface), so Undo can never
drag a session out of somewhere it was later put on purpose.
- A drop onto the folder a session already lives in arms nothing.
- The countdown is a framer-motion scaleX, not a CSS animation: the global
prefers-reduced-motion rule clamps CSS animations to 0.01ms, which would
empty the bar instantly and read as "already expired".
- The chord stands down while focus is in a text field (ChatInput owns its
own undo history) and ignores ⇧⌘Z.
Tests: 12 component cases (labels, both undo paths, the Mac vs non-Mac
chord, the text-field and redo guards, expiry, unmount cleanup) and 6
sidebar cases (drop arms it, undo posts the ORIGINAL folder, no-op drop,
retirement on close, and the two placement contracts). All mutation-verified.
`website/scripts/capture-session-move-undo.mjs` drives the real built SPA
with real pointer events and asserts the placement geometrically, so the
committed frames cannot go stale silently.
Review round 1: the offer's lifecycle is one-way (a `live` flag set once the
store shows the destination, then dropped and never re-validated) because a
derived-visibility version could revive: drag A→B, move B→C→B from a menu, and
the old A inverse matched again and would have overwritten the newer move. The
8s deadline moved to the sidebar for the same reason — an offer whose optimistic
move never became visible has no bar to run a timer and must still die. The
unused `durationMs` prop is gone; the bar reads MOVE_UNDO_MS directly.
Review round 2: undo is now a COMPARE-AND-SET. Another client can move the
session on inside the 8s window, and its broadcast need not have arrived, so an
unconditional undo could overwrite that newer placement. PATCH
/api/chat/slots/{slot}/folder takes an optional `expected_folder_id`; when it no
longer matches, the server refuses with 409 `folder_conflict` and reports the
authoritative folder, which the move hook applies to the store so the sidebar
shows where the session actually is. The compare and the write are separated by
no `await`, so they are atomic on the loop. Live "Move to folder…" omits the
field and stays unconditional.
Also: undo degrades a `fromFolderId` whose folder was deleted inside the window
to unfiled rather than posting a dead id (Design Review's watch item), and the
GIF's 14 intermediate frames no longer ride along in the screenshot dir — the
harness writes them to the OS temp dir (First Principles' subtraction).
Review round 3: the compare-and-set is checked BEFORE the destination folder is
validated. With the old order, an undo whose origin folder had meanwhile been
deleted was refused as 400 "folder not found" — a refusal that says nothing about
where the session is, so the client fell back to its stale idea of the placement
while the server held a newer one. Answering the staleness question first turns
that case into a 409 carrying the authoritative folder.
Review round 4: two more from GPT.
The bar had a narrow-width defect — at SIDEBAR_MIN (180px) the "Moved to" prefix
plus the Undo control consumed the row and truncated the destination, i.e. the
one thing the bar exists to say was the first thing to go. It now drops the
prefix and the ⌘Z label below 220px, keeping the folder glyph, the name and the
button; the chord still fires and the full sentence moves to the row's tooltip.
Same width ladder as the header's own compactHeader/tinyHeader steps. The
harness proves it at 180px and measures the name's rendered width.
A CONDITIONAL write whose destination folder no longer exists now lands as
unfiled instead of 400. Undo restores a placement rather than choosing one, and
the client's folder list can be a broadcast behind, so it cannot know the origin
was deleted — a refusal left the button doing nothing. An unconditional write is
a live choice and still gets the 400.
Per review of the rendered bar: the button face reads "Undo" and nothing else.
The chord is a power shortcut, not part of the label, so it moved to the button's
tooltip and to aria-keyshortcuts — still discoverable, no longer decoration. The
harness now drives the PRIMARY path by CLICKING the button in a real browser and
asserts both halves of what a click must do (the session leaves the folder AND
the bar goes away), then drags again and undoes from the keyboard to prove the
unlabelled chord still fires.
Review round 5: the offer goes live only once the SERVER has acknowledged the
drag move, not when the optimistic store write lands. Arming on the optimistic
write let the user undo while the original PATCH was still in flight: undo's
compare-and-set would be refused (the server still held the old folder) and the
original write would then land, silently reversing the undo. `useMoveSlotToFolder`
now reports the acknowledgement through an `onCommitted` callback.
Also dropped the client-side deleted-origin check: the server already degrades a
conditional write to a missing folder to unfiled, so the client copy was a second
mechanism doing one job — and the server's is the only one that can see a
deletion this client has not heard about yet.
88d0f67 to
2f5e600
Compare
|
Round 5 dispositions — head is now GPT BLOCKING ( The offer now goes live only on the server's acknowledgement: While doing this I dropped my own First Principles subtraction (drop the client-side deleted-origin check) — TAKEN. Agreed, and your reasoning is the stronger half: the server already degrades a conditional write to a deleted folder to unfiled, and it is the only one of the two that can see a deletion this client has not heard about yet. The First Principles Watch items — both stand, deliberately.
Design Review was |
|
Round 6 — handing this back rather than iterating again, because GPT has returned to a finding it already made and I already answered. GPT's only remaining blocking item on This is the same finding as round 3's B2, verbatim in mechanism and remedy, and the two demands do not compose: round 2 blocked the merge until undo became conditional; round 6 blocks it until the conditional undo is reverted. Following both literally leaves no undo at all. So the decision is a maintainer's, not mine — I will not self-authorize an override. The two honest ways forward:
Everything else on this head is green: 57 checks pass, 0 fail apart from GPT 5.6 Review and the PR Readiness roll-up that aggregates it. Design ✅ PASS, UX ✅ PASS, Opus 4.8 ✅ no findings, First Principles 🟡 CONCERNS with every item dispositioned (one subtraction taken, two Watch items answered, the |
Problem / Motivation
Dragging a session onto a folder in the sessions sidebar is the only folder move that gives no feedback. The row leaves the list, nothing on screen says where it went, and the folder it landed in may well be collapsed. Drop it one row off the folder you aimed at and the session is simply gone from view — the only way back is opening folders one at a time until it turns up.
Every other route into a folder names its destination: the row menu's "Move to folder…", the session-header dropdown. Only the coarse, mis-aimable gesture is silent.
Why it matters
A drag is easy to get wrong (small targets, a moving list, auto-expanding folders mid-drag) and the failure is invisible rather than noisy: the user does not learn they made a mistake, they learn a session disappeared. That turns a 200ms slip into a hunt through the folder tree, and there is no undo — the move is already persisted.
What changed (motivation → approach → change)
Goal: after a drag, the user should know where the session went and be able to take it back without hunting.
Approach — where it goes, decided against two alternatives. A floating toast keeps the layout stable but has to cover something, and at the bottom of the sidebar the two things it can cover are the persistent "Older Sessions" footer control and the last rows of the list — including the row that just moved, which is exactly the evidence needed to judge the drop. A minimal one-line note avoids that but is too quiet for the mistake it exists to catch. So the bar renders in the flow: a sibling AFTER the session lanes and BEFORE the footer separator, outside every scroll container. It occludes nothing; it pushes the footer down ~30px while it is up, softened by a 150ms height transition.
Change:
website/src/components/SessionMoveUndoBar.tsx:↳ Moved to 🗀 <Folder>+ anUndo ⌘Zbutton + a 2px countdown for the 8s window.ChatSidebarroutes both drag paths (list-view dnd-kit and board-view native drop) through amoveByDragwrapper that performs the move via the existinguseMoveSlotToFolderhook and records its inverse. Menu moves are untouched — they name their destination already.aria-keyshortcuts.ChatInputowns its own undo history, and ignores ⇧⌘Z (redo).en-XA.Two behaviours worth calling out because they are deliberate, not incidental:
scaleX, not a CSS animation. The globalprefers-reduced-motionrule inindex.cssclamps every CSS animation to0.01ms, which would drain the bar instantly and read as "already expired" for exactly the users least able to re-aim a drag.A drop onto the folder a session already lives in arms nothing — there would be nothing to undo.
Undo is a compare-and-set, not a blind write. Another client can move the session on inside the 8s window and its broadcast need not have arrived here, so undo sends the folder it expects the session to still be in:
PATCH /api/chat/slots/{slot}/foldertakes an optionalexpected_folder_id, refuses a stale one with 409folder_conflict, and reports the authoritative folder — which the move hook applies to the store, so the sidebar shows where the session actually is rather than a third invented placement. The compare and the write are separated by noawait, so they are atomic on the event loop. Live "Move to folder…" omits the field and stays unconditional, exactly as before. AfromFolderIdwhose folder was deleted inside the window degrades to unfiled rather than replaying a dead id.Tests
website/src/test/SessionMoveUndoBar.test.tsx(13 cases) — destination naming, the unfiled label, the live-region announcement, undo by click, undo by chord, the Mac (⌘Z) vs non-Mac (Ctrl+Z) binding, the text-field guard, the redo/bare-key guard, expiry firing once at the deadline, and both unmount cleanups.website/src/test/ChatSidebar.moveUndo.test.tsx(8 cases) — a drop performs the move AND arms the bar naming the destination; undo posts the original folder back and retires the offer; a same-folder drop arms nothing; the offer retires when the session is closed; and the two placement contracts (earlier in document order than the footer, and no scrolling ancestor).test/test_chat_slot_folder_expected.py(7 cases) covers the compare-and-set on the endpoint: the write lands when the expectation holds, a stale expectation is refused and mutates nothing, unfiled is a real expectation rather than an omission, and omitting the field stays unconditional.ChatSidebar.moveToFolder.test.tsxgains 3: the expectation is passed through, afolder_conflictlands on the server's folder rather than the caller's, and a non-conflict 409 still rolls back.Every case was mutation-verified — including one that had to be rewritten because it was vacuous: the placement assertion originally used
compareDocumentPosition, which this DOM implementation answers with theDISCONNECTEDbit set, so it reported "the footer follows the bar" even after the bar was moved below the footer. It now compares indices in document order, and fails on that mutation.Manual verification
node website/scripts/capture-session-move-undo.mjsdrives the real built SPA (website/dist,/api/**stubbed) with real pointer events — dnd-kit's sensors are pointer-based, so there is no synthetic shortcut — and asserts, exiting non-zero otherwise:Archive;bar bottom 896 vs footer top 897) — the placement claim as a measurement, not an eyeball;Archiveand the bar goes away — and the button's rendered text is exactlyUndo; a second drag then proves the unlabelled chord still fires;Full local gates:
tsc -bclean,eslint0 errors,i18n:checkexit 0 (all 18 checks, incl. the diff-scoped untranslated-passthrough gate),check-theme-colorsclean for the new file,jscpdno new clones, and the fullvitestsuite at 21948 passed / 1412 files. Two failures insrc/test/ThemeSelfRepair.test.tsxunder full-suite parallelism pass in isolation and touch no code in this diff.Screenshots / video
The value here is a sequence, so the GIF is the primary evidence — drag out of the list, land in
Archive(note the folder count going 0 → 1), the bar naming the destination, the countdown draining, then Ctrl+Z putting the session back:The bar in place, directly above the untouched "Older Sessions" footer:
At
SIDEBAR_MIN(180px), where the prefix and the shortcut label are dropped so the destination survives rather than being the first thing truncated:Dark theme — every surface in the bar is a theme token, so this is the proof none of it is a light-mode literal:
Before the drag, and after undo (the bar retired, session back in the list)
Related Issues
N/A — reported directly by a user hitting it in the dashboard.
Checklist