Conflict state shows -1, sits in the wrong place, and does not block #98

Closed
opened 2026-07-28 06:03:06 +00:00 by claude-bot · 2 comments

Severity: HIGH

The problems

The 409 conflict state is wrong in three ways
(frontend/src/pages/ReviewWorkspacePage.tsx:478-492, :150-154):

  1. It shows a nonsense number. The banner reads "Current version is {conflictVersion}", but
    because the error envelope never survives FastAPI's wrapping, err.details is undefined and it
    displays "Current version is -1" every time. Fixed by the error-shape issue in v0.2.0.
  2. It appears in the wrong place. The banner is absolutely positioned over the top of the
    image canvas, while the reviewer's eyes are on the decision button they just clicked in the
    right panel.
  3. It does not block. docs/circa-ui-spec.md §7.3 and §11.1 require conflict to be a blocking
    state that shows what changed. The decision buttons stay enabled, nothing shows what the other
    reviewer did, and the user can immediately re-submit into a second 409.

One thing that does work, by accident: the draft inputs survive reload because useState does not
re-initialize — which happens to satisfy "preserve the draft."

Why it matters

This is the one moment where two family members' work collides. A banner reporting a nonsense
version, in the wrong place, that does not stop a re-submit, reads as a bug rather than as a
safety feature — and undermines trust in the mechanism that protects their work.

Scope

  • Move the banner into the right panel, directly above the Decision card.
  • Disable the decision buttons while the conflict is unresolved.
  • Show a minimal diff of what changed: "Anna approved 1983 (year) 2 minutes ago" — available from
    GET /photos/{id} plus the latest decision after reload.
  • Keep the draft preserved, but make that behaviour deliberate rather than incidental.
  • Offer a clear resolution path: reload and re-apply, or discard.

Done when

  • The conflict shows the real current version
  • It appears where the reviewer is looking
  • Submission is blocked until resolved
  • The reviewer can see what the other person changed
  • The draft survives, by design

References

  • frontend/src/pages/ReviewWorkspacePage.tsx:478-492,150-154
  • docs/circa-ui-spec.md §7.3, §11.1

Depends on: the native error shape issue in v0.2.0.

## Severity: HIGH ## The problems The 409 conflict state is wrong in three ways (`frontend/src/pages/ReviewWorkspacePage.tsx:478-492`, `:150-154`): 1. **It shows a nonsense number.** The banner reads "Current version is {conflictVersion}", but because the error envelope never survives FastAPI's wrapping, `err.details` is undefined and it displays **"Current version is -1"** every time. Fixed by the error-shape issue in v0.2.0. 2. **It appears in the wrong place.** The banner is absolutely positioned over the top of the *image canvas*, while the reviewer's eyes are on the decision button they just clicked in the right panel. 3. **It does not block.** `docs/circa-ui-spec.md` §7.3 and §11.1 require conflict to be a blocking state that shows what changed. The decision buttons stay enabled, nothing shows what the other reviewer did, and the user can immediately re-submit into a second 409. One thing that does work, by accident: the draft inputs survive reload because `useState` does not re-initialize — which happens to satisfy "preserve the draft." ## Why it matters This is the one moment where two family members' work collides. A banner reporting a nonsense version, in the wrong place, that does not stop a re-submit, reads as a bug rather than as a safety feature — and undermines trust in the mechanism that protects their work. ## Scope - Move the banner into the right panel, directly above the Decision card. - Disable the decision buttons while the conflict is unresolved. - Show a minimal diff of what changed: "Anna approved 1983 (year) 2 minutes ago" — available from `GET /photos/{id}` plus the latest decision after reload. - Keep the draft preserved, but make that behaviour deliberate rather than incidental. - Offer a clear resolution path: reload and re-apply, or discard. ## Done when - [ ] The conflict shows the real current version - [ ] It appears where the reviewer is looking - [ ] Submission is blocked until resolved - [ ] The reviewer can see what the other person changed - [ ] The draft survives, by design ## References - `frontend/src/pages/ReviewWorkspacePage.tsx:478-492,150-154` - `docs/circa-ui-spec.md` §7.3, §11.1 Depends on: the native error shape issue in v0.2.0.
claude-bot added this to the v0.3.0 milestone 2026-07-28 06:03:06 +00:00
Author

Picking this up. Probed first, as the issue's own dependency note invites — two of the five done-when items are already satisfied, and by the work this issue said it was waiting on.

Already fixed, and asserted:

  • "The conflict shows the real current version." #75 landed the native error shape, and conflictVersion now reads err.details.current_version with an explicit three-valued result — a number, null for a conflict the server could not name, or undefined for "not a conflict". The "Current version is -1" this issue was filed about is gone, and the conflict journey asserts Current version is \d+ on a real browser against a real backend.
  • "Submission is blocked until resolved." #11 added blocked: conflict !== null across every versioned write, and the same journey asserts that Approve, Dispute, Needs Review, Skip and Flag Rescan are all disabled after a refusal — while deliberately leaving the AI and OCR reruns enabled, since they carry no review_version and cannot conflict.

Still wrong, and one of them is now worse than when this was filed.

"It appears in the wrong place." The banner is still absolutely positioned over the image canvas. #97 has since docked the decision controls to the bottom of the right panel, so the banner is now about as far from the reviewer's eye as the layout allows: they click Approve at the bottom right and the explanation appears top left, over the photograph. Moving into the dock, directly above the decision form.

"The reviewer can see what the other person changed." Not built. Everything needed is already on the wire — DecisionOut carries created_by_display_name, decision_type, the date fields and created_at — so this is a fetch and a sentence, rendering something like Anna approved 1983 (year), 2 minutes ago. If that fetch fails it will say nothing rather than render half a sentence, since a conflict banner is the worst possible place to guess.

"The draft survives, by design." The issue is right that this works by accident — useState simply does not re-initialise. It will get a comment saying it is load-bearing and a test that fails if someone later "tidies" it into a useEffect that resets on prop change.

Being built alongside #99 and #100, which touch the same region of the same file.

Picking this up. Probed first, as the issue's own dependency note invites — **two of the five done-when items are already satisfied**, and by the work this issue said it was waiting on. **Already fixed, and asserted:** - *"The conflict shows the real current version."* #75 landed the native error shape, and `conflictVersion` now reads `err.details.current_version` with an explicit three-valued result — a number, `null` for a conflict the server could not name, or `undefined` for "not a conflict". The **"Current version is -1"** this issue was filed about is gone, and the conflict journey asserts `Current version is \d+` on a real browser against a real backend. - *"Submission is blocked until resolved."* #11 added `blocked: conflict !== null` across every versioned write, and the same journey asserts that Approve, Dispute, Needs Review, Skip and Flag Rescan are all disabled after a refusal — while deliberately leaving the AI and OCR reruns enabled, since they carry no `review_version` and cannot conflict. **Still wrong, and one of them is now worse than when this was filed.** *"It appears in the wrong place."* The banner is still absolutely positioned over the **image canvas**. #97 has since docked the decision controls to the bottom of the right panel, so the banner is now about as far from the reviewer's eye as the layout allows: they click Approve at the bottom right and the explanation appears top left, over the photograph. Moving into the dock, directly above the decision form. *"The reviewer can see what the other person changed."* Not built. Everything needed is already on the wire — `DecisionOut` carries `created_by_display_name`, `decision_type`, the date fields and `created_at` — so this is a fetch and a sentence, rendering something like `Anna approved 1983 (year), 2 minutes ago`. If that fetch fails it will say nothing rather than render half a sentence, since a conflict banner is the worst possible place to guess. *"The draft survives, by design."* The issue is right that this works by accident — `useState` simply does not re-initialise. It will get a comment saying it is load-bearing and a test that fails if someone later "tidies" it into a `useEffect` that resets on prop change. Being built alongside #99 and #100, which touch the same region of the same file.
Author

Done in 97d5cdc, with #99 and #100. CI run #60 green on all three jobs.

Done when:

  • The conflict shows the real current version — already true via #75, as noted above
  • It appears where the reviewer is looking — now in flow above the decision form, in the dock
  • Submission is blocked until resolved — already true via #11
  • The reviewer can see what the other person changed
  • The draft survives, by design

On the sentence naming the other reviewer, two decisions worth recording.

It reads Anna Whitcombe chose Approve — 1983 (year), 2 minutes ago. "chose", not "approved": labels.ts keeps decision names imperative on purpose — the history is a list of what reviewers pressed — so a past-tense verb here would mint the second vocabulary that file exists to prevent. Its own docstring supplied the wording: the label is "the same word they chose".

More importantly, the line is suppressed when the newest decision predates the conflict. The write that beat the reviewer may have been a note edit or a rescan flag, not a decision at all — in which case the latest decision could be months old, and naming it would tell the reviewer that Anna's approval from March is why their save just failed. That is a confident false statement about the archive's reasoning, which is #78's failure in a place a reviewer is already unsettled. The guard compares review_version_at_submission against the photo's current version. A skip is handled too: it carries no date, and rendering "Undated" for it would put a judgement in the sentence its author did not make.

Draft preservation is now deliberate. It worked because useState does not re-initialise — true, load-bearing, and entirely undocumented. There is now a comment saying so and a test asserting the typed dates survive a 409, so a later tidy-up into a prop-syncing effect fails rather than silently losing a reviewer's work at the one moment they are most likely to have typed something they care about.

One cost of the move, since #97 docked the decision form. .right-panel-dock is capped at 60% height and now shares that space with the banner, so on a short viewport a reviewer may scroll within the dock to reach Approve after a conflict. Every versioned button is disabled at that moment anyway, so it is acceptable — but it is a consequence of putting the banner where this issue asked for it, and worth knowing rather than discovering.

Done in 97d5cdc, with #99 and #100. CI run [#60](https://git.rhoving.com/rbrooks/Circa/actions/runs/60) green on all three jobs. **Done when:** - [x] The conflict shows the real current version — already true via #75, as noted above - [x] It appears where the reviewer is looking — now in flow above the decision form, in the dock - [x] Submission is blocked until resolved — already true via #11 - [x] The reviewer can see what the other person changed - [x] The draft survives, by design **On the sentence naming the other reviewer**, two decisions worth recording. It reads `Anna Whitcombe chose Approve — 1983 (year), 2 minutes ago`. **"chose", not "approved"**: `labels.ts` keeps decision names imperative on purpose — the history is a list of what reviewers pressed — so a past-tense verb here would mint the second vocabulary that file exists to prevent. Its own docstring supplied the wording: the label is "the same word they chose". More importantly, **the line is suppressed when the newest decision predates the conflict**. The write that beat the reviewer may have been a note edit or a rescan flag, not a decision at all — in which case the latest decision could be months old, and naming it would tell the reviewer that Anna's approval from March is why their save just failed. That is a confident false statement about the archive's reasoning, which is #78's failure in a place a reviewer is already unsettled. The guard compares `review_version_at_submission` against the photo's current version. A skip is handled too: it carries no date, and rendering "Undated" for it would put a judgement in the sentence its author did not make. **Draft preservation is now deliberate.** It worked because `useState` does not re-initialise — true, load-bearing, and entirely undocumented. There is now a comment saying so and a test asserting the typed dates survive a 409, so a later tidy-up into a prop-syncing effect fails rather than silently losing a reviewer's work at the one moment they are most likely to have typed something they care about. **One cost of the move, since #97 docked the decision form.** `.right-panel-dock` is capped at 60% height and now shares that space with the banner, so on a short viewport a reviewer may scroll within the dock to reach Approve after a conflict. Every versioned button is disabled at that moment anyway, so it is acceptable — but it is a consequence of putting the banner where this issue asked for it, and worth knowing rather than discovering.
Sign in to join this conversation.
No description provided.