feat(dashboard): unlink PR/issue/Jira source links from a session - #8072
feat(dashboard): unlink PR/issue/Jira source links from a session#8072RohanK6 wants to merge 1 commit into
Conversation
a72eec1 to
8b9d526
Compare
|
CI fix — head The Backend Test shards (Linux 3.12 #2/#4 and the same-numbered Windows shards) failed on one root cause: this PR adds an Fixed at root cause:
Re-verified locally: |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Right model (tombstone over derived data), but a grow-only set is wrapped in a replace-and-compensate saga it never needed, sprawled across seven modules. Watch
[DESIGN-REVIEWED] a031fd9 |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of I have everything I need. Let me compile the reconcile table mentally and finalize. Reconciliation of user-visible changes:
The reveal pattern, UX-Verdict: CONCERNS One hover-away click permanently destroys a chip with no undo, no confirm, and — by design — no way back, ever. Watch
Evidence gaps
[UX-REVIEWED] a031fd9 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All checks are done. Base evidence gathered: the autocompact sibling's transactional shape, the spec that does document source-link derivation, consumer counts for the new registry hook, and the third duplicate lock factory. Composing the review. First-Principles-Verdict: CONCERNS The workflow-registry pre-notify hook ships for one consumer to fix a transient-display nit the carry-forward flag already contains; the "no spec" claim is false. Not justified as shipped
What this change shipsInventory (10 items) — 7 justifiedIntent: let a user remove a stale PR/issue/Jira chip from a session card so it stays removed across re-scans and restarts (issue #3724) — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] a031fd9 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsBoth candidates fail falsification: Candidate 1 (double Candidate 2 ( No additional grounded defects surfaced. No findings. [OPUS-REVIEWED] a031fd9 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
8b9d526 to
2fabb4f
Compare
|
Disposition — GPT 5.6 BLOCKING finding (head Finding (holds — real bug): Fix (code): pass Regression guard: upgraded Re-verified: |
2fabb4f to
ce621cc
Compare
2e96357 to
c02e5b3
Compare
|
Disposition — GPT 5.6 (head BLOCKING — unlink persistence not transactional / concurrent retry can acknowledge a rolled-back unlink — FIXED. Wrapped the whole dismissal mutate → mirror → save → rollback in a per-transcript transaction lock ( FINDING — function-local UI two-buttons-per-row — REBUTTED (standing). No such enforced rule exists in the repo; Design Review and UX Review — the layout authorities — pass this head. Unchanged. Re-verified: |
c02e5b3 to
b0c19ba
Compare
|
Disposition — GPT 5.6 (head BLOCKING — concurrent retry can acknowledge a dismissal that is rolled back — FIXED (real bug in the prior lock, correctly caught). GPT engaged with the transaction lock added last round and found a genuine ordering defect: the dismissal mutation Fix (exactly as GPT prescribed): acquire the per-transcript transaction lock FIRST, then do the reauth + locked-key guard + the newly-dismissed check + mutation + mirror + save/rollback all INSIDE the lock. A second concurrent DELETE now blocks on the lock and only runs its check-and-mutate after the first has fully committed or rolled back, re-deriving its own newly-dismissed decision from the settled state — so it can never acknowledge a rolled-back dismissal. This is the completion of the transactional fix the prior round started. Re-verified: |
b0c19ba to
13b41fc
Compare
585b575 to
edfa512
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Disposition — GPT 5.6 BLOCKING + Opus 4.8 advisory (head BLOCKING (GPT 5.6) — Fix (exactly as GPT prescribed — the ADVISORY (Opus 4.8) — Re-verified: backend unlink+plugin 57/57 (incl. the new ordering regression); tsc clean; eslint 0 new; ChatSidebar unlink vitest 6/6. Rebased onto current main and pushed as one commit |
|
Disposition — GPT 5.6 BLOCKING ×2 (head 1. BLOCKING — 2. BLOCKING — Re-verified: backend unlink+plugin 58/58 (incl. both new regressions); flake8 + black clean; tsc clean. Rebased onto current main and pushed as one commit |
|
Disposition — GPT 5.6 BLOCKING ×4 (head 1. BLOCKING — 2. BLOCKING — 3. BLOCKING — 4. BLOCKING — Re-verified: (Advisory reviewers: Opus 4.8 PASS; Design/First-Principles/UX CONCERNS — the UX "reveal ✕ on hover + permanence tooltip" and First-Principles "collapse the second |
|
Disposition — GPT 5.6 BLOCKING (head BLOCKING — Fix (exactly as GPT prescribed, and matching the Added a regression test ( Re-verified: |
|
Disposition — GPT 5.6 BLOCKING (head BLOCKING — Fix (exactly as GPT prescribed): on the rebind branch, after dropping the dismissal from the rebound requesting slot, still mirror the dismissal onto every OTHER slot still bound to Reworked the rebind test ( Re-verified: |
|
Thanks @bolichen97 - kept open per recommendation and confirming this is ready for review |
bolichen97
left a comment
There was a problem hiding this comment.
The backend is in good shape after the review rounds: ownership check before identity validation, check-and-mutate under the per-transcript lock, durable primary save before any sibling mirror, rollback + 409 on a refused save, and all four hydrators restore the dismissed set. No objection to any of that.
What I cannot approve as-is is the interaction itself:
- An always-visible red danger
Xon every 10px chip. Every source-link chip on every session card now carries a destructive control in its resting state. That is a lot of red for a rarely used action, and a 10px target next to the link makes a mis-click easy. - One click is permanent, with no confirm, no undo, and no way back. The body says re-pasting the same URL will not restore the chip. A mis-click therefore loses the link for the life of the session, and the user has no recovery path at all. Please pick at least one of: reveal the
Xon hover/focus only, add an undo toast after the optimistic hide, or let a re-pasted URL re-link. - Medium, must fix:
ChatSidebar.tsx~L810 renderserr.messagefor anyError, so the user sees raw server strings ("not found", "session was deleted or rebound") or browser text ("Failed to fetch") instead of theunlink_source_link_failedcatalog string; the 12-locale key is effectively dead on the common failure paths. Map the responsecodeto a catalog key, or always use the catalog string.
Low: test_a_refused_save_leaves_no_sibling_mirrored has a second scenario that lost its def header; the body's test counts are off (25 functions / 37 parametrized items). Happy to re-review quickly once the hover/undo question is settled.
|
@bolichen97 thanks for the review — all of it is addressed, and the follow-up review rounds since are green. Current head Your four items
Follow-up review rounds (all fixed at root cause)
Demo (re-recorded full-card on an isolated dev gateway — earlier one was cropped): Verified: backend Update (head
Update (head Update (head
Update (head Update (head Update (head Update (head Update (head
Update (head
Update (head Update (head
Update (head
Update (head
Update (head Update (head
Update (head Update (head Update (head Update (head Update (head STRICT GREEN reached (head Rebased onto main (head Update (head Update (head Update (head Update (head Update (head |
|
This is ready for review after resolving merge conflicts |
|
This is ready for review – all merge conflicts are resolved |
Source-link chips are derived by scanning the transcript, so removing a link is undone by the next re-scan. Add a per-slot dismissed-identity suppression set that the derivation filters against, a DELETE endpoint that records into it, and an unlink affordance on each sidebar chip. The set persists in durable slot metadata so a restart does not resurrect a dismissed chip. No remote provider is touched.

Fixes #3724
Problem / Motivation
A chat session's sidebar card shows "source link" chips for every PR, issue, or Jira ticket the transcript mentions. There is no way to remove one: a PR that was pasted once but is no longer relevant stays pinned to the session forever, and the strip only grows. Issue #3724 asks for a way to unlink a source link from a session.
The catch is that these chips are not stored. They are DERIVED on demand by scanning the transcript for provider URLs in
Projection.source_links(...). So the naive fix — "delete the link" — does nothing: the very next revision bump re-scans the transcript and re-adds it.Why it matters
Without a stable removal, the session card accumulates stale links the user cannot clear, and the one obvious implementation (delete the derived row) is a bug that silently undoes itself. The correct model is a per-slot dismissed-identity suppression set that the derivation filters against — the same tombstone pattern the tab-dismissal path already uses — so a removed chip stays removed across every re-scan and across a gateway restart.
What changed
A per-slot set of dismissed
SourceRef.identitykeys, a DELETE endpoint that records into it, and an unlink affordance on each chip. Nothing touches a remote provider: this hides a chip, it does not close a PR or delete an issue.Derive → filter → dismiss:
state.py): a new_dismissed_source_links: set[str]per slot, holding serialized identity keys.dismiss_source_link(key)adds the key, bumps the source-links revision, and returns whether it was newly added (idempotent repeat = no-op).handlers/source_providers.py):source_ref_identity_key(identity)renders the identity tuple to canonical JSON (fixed member order, no incidental whitespace,ensure_ascii), andis_valid_source_identity_key(key)validates an untrusted key against that exact grammar. Keyed on the identity, not the URL — so a dismiss suppresses the object, not one spelling of it (a trailing-slash re-mention stays gone).slot_projection.py): after computingidentity, the derivation drops any dismissed key — but the parse budget is still charged first, so cost accounting is unchanged. The dismissed-set contents are folded into the existingcache_key, so a dismiss invalidates the cache. Each derived chip now also carries its serializedidentityso the client can name it back to the endpoint.chat_handlers.py+routes/chat.py):DELETE /api/chat/slots/{slot}/source-links/{identity}. Validates the slot (404 +slot_not_found, indistinguishable from the sibling GET so it cannot probe which slots exist), denies cross-app access, and validates the identity path param (400 +invalid_source_identity). On a newly-recorded dismissal it broadcasts a slots update and persists off-loop; a repeat is a no-op that skips both.chat_persistence.py): the dismissed set is written into the slot's durable metadata (dismissed_source_links, sorted for a deterministic line) on both save paths, and rehydrated on both restore paths through_restore_dismissed_source_links, which re-validates each key and drops any tampered entry (a malformed key can only ever fail to match a real identity).ChatSidebar.tsx,api/client.ts): each PR/issue chip carries an unlink ✕ that is revealed only on that chip's hover / focus-within (matching the sidebar's existingIconButtonGroup revealpattern) rather than sitting always-visible — so a source-link row shows no resting-state destructive control and a stray pointer cannot hit a delete target the user never summoned; it stays keyboard-reachable viafocus-within. The ✕ is also hidden when an older gateway sends no identity. Clicking it calls the newapi.unlinkSourceLink(slot, identity), hides the chip optimistically, and — on failure — re-shows it and surfaces the failure through the sharedErrorNotice(inline) with the localizedunlink_source_link_failedcatalog string (never a raw server/browser message). The authoritative removal arrives via the slots push the endpoint already broadcasts. Two new i18n keys added across all 12 locales + the pseudolocale.Dismissal is permanent (chosen and stated): unlinking records the object's identity in the suppression set, and the derivation filters that identity out unconditionally on every re-scan. Because a dismiss is keyed on the identity (not one URL spelling), the chip stays gone no matter how the same object is mentioned again later — a re-paste of the same PR/issue/Jira URL does not bring it back. This is the smallest honest model: "unlink" means the chip is gone for the life of the session. There is deliberately no re-link/undo path — re-adding a specific chip after unlinking is out of scope for #3724, and a point-in-time "resurface on a newer mention" variant would require the transcript scan to distinguish "old" from "new" mentions (a timestamp comparison it has no cheap way to do) for marginal value. The suppression set only ever grows within a session and is bounded by the number of distinct source links the transcript can carry.
No module spec documents the source-link derivation feature, so there is no spec to update in the same commit.
Tests
Backend (
test/test_dashboard_source_link_unlink.py, 38 tests): dismiss suppresses a derived link; a non-dismissed sibling is unaffected; dismiss matches the object across URL shapes; repeat dismiss is idempotent; dismiss invalidates the cache; the set survives a simulated reload; a tampered identity key is dropped on restore; identity-key validation accepts a real key and rejects malformed/oversized/nested/wrong-arity/wrong-type ones; the endpoint records + broadcasts + persists (withforce=Trueso a dismissal on a restored session reaches disk), invalidates the cache, returns 400 on a malformed identity, 404 on an unknown slot, denies an app token, and is idempotent on a repeat (no extra broadcast/write).Frontend (
website/src/test/ChatSidebar.sourceLinkUnlink.test.tsx, 6 tests): the ✕ renders (hover/focus-revealed) on both a change and an issue chip; clicking it calls DELETE with the opaque identity; the chip hides optimistically without switching sessions; a failed unlink re-shows the chip and renders theErrorNoticealert with the catalog string; no ✕ when the gateway sent no identity; the ✕ is disabled offline. The existing 48 source-link chip tests still pass (the chip DOM was restructured to wrap the anchor + ✕ without changing titles or testids).Sequential local verification (all green):
Screenshots / video
The unlink ✕ is revealed on chip hover/focus (no resting-state red ✕). Hover a chip → the ✕ appears → click it → the chip disappears optimistically and stays gone after a refresh (persisted), while the sibling chip remains:
Before (chips at rest — no ✕ until hover/focus):
After unlinking a chip and refreshing (the unlinked chip is gone, the sibling remains):