feat(web): show and move a task's workflow step from the kanban preview (#3329)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-04 04:04:19 +00:00
committed by GitHub
parent 884418b4c9
commit b067fac875
23 changed files with 2016 additions and 149 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ capability.
The specification linter reads the default limits from `docs/specs/spec-lint.json`.
The default limits are:
- System index: 12 KiB.
- System index: 16 KiB.
- Product or guide document: 16 KiB.
- Requirement document: 20 KiB.
- System-design document: 32 KiB.
+1 -1
View File
@@ -6,7 +6,7 @@
"product": 16384,
"requirement": 20480,
"system-design": 32768,
"system-index": 12288,
"system-index": 16384,
"template": 8192
}
}
+2
View File
@@ -39,6 +39,7 @@ UI owns responsive behavior; other systems own behavior/state.
- [Command-panel task reveal](requirements/command-panel-sidebar-task-reveal.md)
- [Task activity icons](requirements/command-panel-task-activity-icons.md)
- [Workflow step navigation](requirements/compact-workflow-step-navigation.md)
- [Preview panel step navigation](requirements/kanban-preview-workflow-step-navigation.md)
- [Comment Markdown Rendering](requirements/comment-markdown.md)
- [Mention recency](requirements/composer-mention-recency.md)
- [Composer Suggestion Overlays](requirements/composer-suggestion-overlays.md)
@@ -149,6 +150,7 @@ UI owns responsive behavior; other systems own behavior/state.
- [Mention recency](system-design/composer-mention-recency.md)
- [Composer Suggestion Overlays](system-design/composer-suggestion-overlays.md)
- [Compact Workflow Step Navigation](system-design/compact-workflow-step-navigation.md)
- [Kanban Preview Workflow Step Navigation](system-design/kanban-preview-workflow-step-navigation.md)
- [Workflow column visibility (Part 1)](system-design/board-step-visibility-filter-01.md)
- [Workflow column visibility (Part 2)](system-design/board-step-visibility-filter-02.md)
- [Task PR Automation Controls 1](system-design/ci-pr-automation-01.md)
@@ -0,0 +1,281 @@
---
status: draft
system: ui
created: 2026-09-03
owners:
- nova28
---
# Kanban Preview Workflow Step Navigation Requirements
## Overview
The kanban preview panel opens beside the board and shows one task's sessions.
Its header shows the task title, an open-full-page control, and a close
control. It does not show which workflow step the task is on, and it offers no
way to move the task.
Today the only in-board way to change a task's step is dragging its card to
another column. That path is not always available. The per-workflow column
visibility filter
([REQ-UI-BOARD-STEP-VISIBILITY-FILTER-001](board-step-visibility-filter.md))
lets a user hide any step, including the one a task should move to, and the
preview panel narrows the board so remaining columns can sit off-screen. In
both cases the drop target does not exist on screen and the task cannot be
moved without leaving the preview.
The task top bar already solves the same problem in a narrow space
([REQ-UI-COMPACT-WORKFLOW-STEP-NAVIGATION-001](compact-workflow-step-navigation.md)):
a compact current-step indicator that discloses every step with a move control
for each eligible target. This requirement puts that same indicator in the
preview header and constrains its footprint so the header's existing title and
controls keep their space.
The UI system owns this presentation and its containment. The task system
continues to own workflow order, move eligibility, and task transitions.
## Terminology
- **Preview panel:** The task panel that opens beside the kanban board, in
either its inline or its floating layout.
- **Preview header:** The single row at the top of the preview panel holding
the task title and the panel controls.
- **Step indicator:** The compact current-step marker, step name, and position
count defined by
[REQ-UI-COMPACT-WORKFLOW-STEP-NAVIGATION-001](compact-workflow-step-navigation.md).
- **Step disclosure:** The temporary surface opened from the step indicator
that lists every step of one workflow.
- **Own workflow:** The workflow named by the previewed task's workflow id,
which is not always the workflow the board is filtered to.
- **Eligible step:** A step that the existing task-move policy permits as a
manual target: the step adjacent to the current step, or a step whose
`allow_manual_move` is set.
- **Panel controls:** The open-full-page control and the close control in the
preview header.
## Requirements
### REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001: Step indicator and step navigation in the preview header
**Intent:** A user reading a task in the preview panel needs to see which
workflow step it is on and to move it to any eligible step, including steps
whose board column is hidden or off-screen, without closing the preview.
**User story:** As a board user with the preview open, I want to see and change
the previewed task's workflow step from the preview header, so that I can move
the task even when its target column is not on the board.
#### Acceptance criteria
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.1:** When the preview panel is
open on a task and that task's own workflow resolves to at least one step,
the preview header shall show a step indicator between the task title and the
panel controls, carrying the current-step marker, the current step name, and
the position count in the form `<current>/<total>`. The marker is suppressed
in the one case AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14 names, and the
count is omitted in the one case AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.2
names.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.2:** When the task's own workflow
has exactly one step, the step indicator shall omit the position count. When
that single step is the task's current step, the step disclosure shall offer
no move control, because the current step is never an eligible target. When
the single step is not the task's current step (the
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14 fallback), the shared eligibility
policy governs unchanged: a move control shall appear if and only if that
step's `allow_manual_move` is set. This surface shall not override the shared
eligibility policy for the single-step case.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.3:** The steps shown shall be the
steps of the previewed task's own workflow, resolved by that task's workflow
id, including when the board is filtered to a different workflow and when the
board shows several workflows at once.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.4:** Steps shall be ordered by
ascending `position`; when two steps of one workflow share a `position`, the
system shall order them by ascending step `id` compared as a string, so a
given step set always renders in one order. This ordering rule shall apply to
every surface that uses the shared stepper, including the task top bar.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.5:** The step disclosure shall
list every step of the task's own workflow, including a step the user has
hidden through the per-workflow column visibility filter and a step whose
column is scrolled out of view on the board.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.6:** The step disclosure shall
mark the current step and shall offer a move control for each eligible step
and for no other step, using the same eligibility policy as the task top bar.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.7:** When the user selects a move
control, the system shall issue the same task-move request the task top bar
issues, targeting the previewed task's own workflow id and the selected step
id, and shall place the task at the first position of the target step. It
shall not change the task's workflow.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.8:** While a move request is in
flight, every move control in the step disclosure shall be disabled and the
disclosure shall stay open, so one user cannot start a second move from this
surface before the first resolves.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.9:** When a move request succeeds,
the step disclosure shall close, the preview panel shall stay open on the same
task with the same selected session, and no route navigation shall occur. When
the resulting task state arrives, the step indicator shall show the new
current step and position count.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.10:** When a move request fails,
the step disclosure shall stay open and usable, and the preview panel shall
show a move-failure message below the preview header, inside the panel. The
message shall not appear in the preview header row.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.11:** The move-failure message
shall clear when the next move request from this surface starts and when the
preview switches to a different task. Selecting the same target again after a
failure shall issue the same request again; the system shall not deduplicate,
queue, or retry move requests on its own. A move request whose failure
arrives after the preview has stopped showing the task that issued it shall
be discarded rather than rendered. The message shall appear only when the
preview has stayed open on that same task continuously since the request was
issued, so neither switching to another task, nor closing the preview, nor
closing and reopening it on the same task can surface an error from an
earlier request.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.12:** When the previewed task's
step changes from any other source while the step disclosure is open, the
disclosure shall stay open and shall re-derive its current step, its completed
steps, and its eligible targets from the newest task state. When a step is
removed from the workflow while the disclosure is open, its row shall
disappear without leaving a selection behind.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.13:** When the preview panel has no
selected task, or the previewed task has no resolvable workflow id, or its own
workflow resolves to an empty step list, or those steps have not loaded yet,
the preview header shall show no step indicator and shall otherwise render
unchanged. The indicator shall appear once the steps resolve, with no
placeholder or skeleton in between.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14:** When the previewed task's
current step id matches no step in the resolved list, the step indicator shall
show the first step in order without marking it as the current step, and the
step disclosure shall mark no row as the current step. The disclosure already
behaves this way. The shared step indicator does not: it marks the step it
shows as current unconditionally, and shall be corrected to condition that
marker on a resolved current step. The correction lands in shared code, so it
applies to every surface using that indicator, including the task top bar.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.15:** When the step disclosure is
open and the user presses Escape, the system shall dismiss the step disclosure
only, return focus to the step indicator, and leave the preview panel open. A
further Escape with the disclosure closed shall close the preview panel, which
is its behavior today.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.16:** When the preview panel is in
its floating layout, the step disclosure shall render above the preview panel
and its backdrop, and pointer interaction inside the disclosure shall not
close the preview panel.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.17:** On a coarse-pointer device
that shows the preview panel, the step indicator shall open the same step list
in the touch surface used by the task top bar, with a minimum 44px hit area
for the indicator and for each move control, and a visible disclosure cue.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.18:** The step indicator shall
expose an accessible name carrying the current step name, its step number, and
the total step count, and the disclosure surface shall expose the same dialog
semantics and keyboard path as the task top bar's disclosure. In the
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14 fallback there is no current step,
so the accessible name shall instead carry the name and list position of the
step the indicator is showing, which is the first step in order, together with
the total. It shall use the same wording as the resolved case, because that is
exactly what the indicator renders visually in that case: the marker is
suppressed and the visible position count follows the single-step rule, so it
is omitted. The absence of a current step shall be conveyed by withholding the
current-step semantics from the indicator, not by different text, so the
accessible name and the visible content never disagree about which step is
shown or about whether the task is on it.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.19:** When the preview panel closes
or switches to a different task while the step disclosure is open, the disclosure
shall close with it and shall not reopen on the next preview until the user opens
it again. An in-flight move started from the closed disclosure shall still be
applied by the backend; the preview shall not cancel it.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.20:** When a fine-pointer user
hovers or focuses the step indicator, the system shall open the step
disclosure below it as an interactive dialog surface, and shall close it when
the pointer leaves both the indicator and the surface and focus is outside
both. Opening by pointer shall not move focus; opening by keyboard shall move
focus into the surface.
### REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002: Preview header containment
**Intent:** The preview panel is user-resizable down to a narrow width. The new
step indicator must take its space from the title, not from the panel controls,
and must never turn the header into a second row or a scrolling surface.
#### Acceptance criteria
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.1:** At every preview panel width
from its 300px minimum to its maximum, the preview header shall stay a single
row. No header element shall wrap to a second line.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.2:** At the 300px minimum width,
both panel controls shall stay fully inside the panel and shall stay
clickable.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.3:** At the 300px minimum width,
the task title shall keep at least 88px of rendered width and shall truncate
with an ellipsis rather than wrap or displace any other header element.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.4:** After the panel controls and
the header row's inter-element gaps take their fixed width, the step indicator
shall claim no more than half of the width that remains, at every panel width.
When the step name is too long for the space that leaves it, the step name
shall truncate while the current-step marker and the position count stay
visible. This cap is a maximum, not a reservation: when a header row cannot
satisfy both this cap and the task title floor
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.3 sets, the title floor wins and the
step indicator shall shrink below its cap. The two bounds never conflict while
the header row's inter-element gaps total 18px or less, which the system design
derives from the narrower of the two preview layouts and which the header shall
respect.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.5:** The preview header shall not
introduce horizontal scrolling in the preview panel at any supported width.
- **AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.6:** Truncated header text shall
keep its full value available to assistive technology and shall not be the
only carrier of the step name, step number, or total.
## Decisions
- The preview reuses the task top bar's compact stepper, disclosure surface,
eligibility policy, and move request rather than a preview-specific variant.
Two step surfaces with two behaviors is the failure this avoids. "Two surfaces"
means these two specifically. Other places that move a task between steps,
including board drag and drop, the swimlane and multi-select movers, and the
task session sidebar's own optimistic mover, keep their existing
implementations and are not consolidated by this requirement; the system design
enumerates them and says why.
- Moving a task from the preview keeps the task top bar's existing plan-mode
cleanup for the previewed session, including its layout reset. Plan mode is
session state, not page state, so leaving it from one surface must leave it
everywhere.
- Concurrent moves from two clients are resolved by the backend. The preview
shows whatever task state it then receives and does not attempt to reconcile
or warn.
## Out of scope
- Moving the previewed task to a different workflow. The task context menu
keeps that path, and this surface deliberately keeps the exclusion stated in
[REQ-UI-COMPACT-WORKFLOW-STEP-NAVIGATION-001](compact-workflow-step-navigation.md).
- Any change to per-workflow column visibility, to board drag and drop, or to
which columns the board renders.
- Any change to the task top bar's full or compact stepper, in presentation or
in behavior, other than the three corrections this requirement makes to shared
code deliberately. The first two are presentation: the ordering tiebreak in
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.4 and the current-step marker fix in
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14. The third is behavior: the shared
move hook scopes a late move failure, and the in-flight step id, to the
presentation that issued the request, per
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.11 and the system design's failure and
recovery rules. On the top bar that means a move failure arriving after the user
has navigated away from the task, or away and back to it, no longer raises the
move-failure message, where today it can. All three live in code the top bar
shares, and the requirement's own Decisions section forbids a second copy of
that code, so the top bar inherits all three by construction rather than by
choice. The third is deliberate and not incidental: a failure that outlives the
presentation which issued it is wrong on both surfaces, and shipping the fix on
the preview alone would leave the identical defect on its sibling. No other top
bar change is in scope.
- An archived-task presentation in the preview header. The board excludes
archived tasks and the preview closes when its task leaves the board, so the
archived indicator path is not reachable from this surface. A future change
that makes archived tasks previewable owns that presentation.
- A phone presentation. Phones open the task route instead of the preview
panel, so this surface adds no phone entry point and the existing phone task
drawer keeps its **Move to** path.
- Showing wip-limit queue state, `queued_for_step`, or dependency blocking in
the step indicator.
- Any change to what entering a step triggers. Step `on_enter` actions,
including agent auto-start, behave exactly as they do for a board drag or a
task top bar move.
- New user-facing copy. This surface reuses the existing translated strings for
the step indicator, the disclosure, and the move-failure message.
@@ -0,0 +1,411 @@
---
status: draft
system: ui
requirements:
- REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001
- REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002
---
# Kanban Preview Workflow Step Navigation System Design
## Purpose and boundaries
The UI system owns the preview panel header and the compact stepper it now
hosts. The task system owns workflow data and the task-move API. This design
adds a consumer of the existing compact stepper; it does not add a backend
contract, a store field, or a persisted preference.
## Requirement mapping
| Requirement | Design section |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001` | [Components](#components-and-responsibilities), [Step resolution](#step-resolution), [Control flow](#control-flow), [Failure and recovery](#failure-and-recovery) |
| `REQ-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002` | [Header layout](#header-layout) |
## Components and responsibilities
- `TaskPreviewPanel` owns the preview header. It gains a step indicator slot
between the title and the panel controls, and a move-failure slot below the
header row.
- The compact stepper and its disclosure body stay the single implementation of
the indicator, the step list, and eligibility. They do not implement the move
request: they take an `onMove` callback and call it. The preview renders the
compact presentation unconditionally rather than measuring available width:
the preview header is never wide enough for the full stepper, and a width
measurement here would only add a flicker.
- The move request is today private to `handleMove` inside `WorkflowStepper`,
which the preview does not render, so there is nothing for the preview to
inherit. That logic is extracted into one shared hook, `useWorkflowStepMove`,
which becomes the single implementation of the move request **for the compact
stepper surfaces** and owns: the
move call itself, the plan-mode cleanup for the task's active session, the
in-flight step id, the request-identity guard that discards a superseded or
late response, and the move-start and move-error callbacks. `WorkflowStepper`
and the preview both consume the hook; neither reimplements any part of it.
Extraction rather than duplication is what makes the plan-mode invariant in
the requirement's Decisions section hold on both surfaces, and it is why a
second copy of this logic is not an acceptable implementation.
- **That claim is scoped to the compact stepper, and deliberately so.** Moving a
task between workflow steps is not otherwise centralised in this codebase, and
this design does not centralise it. `moveTask` / `moveTaskById`
(`lib/api/domains/kanban-api.ts`, wrapped by `hooks/use-task-actions.ts`) has
several independent callers besides the stepper: the board's drag and drop
(`hooks/use-drag-and-drop.ts`), the swimlane movers
(`hooks/domains/kanban/use-swimlane-move.ts` and the
`components/kanban/swimlane-*-content.tsx` surfaces), multi-select bulk moves
(`hooks/use-task-multi-select.ts`), plan actions
(`hooks/domains/kanban/use-plan-actions.ts`), and the task session sidebar's own
mover, `useMoveToStep` (`components/task/task-session-sidebar-move.ts`).
`useWorkflowStepMove` replaces exactly one of them, `handleMove`, and leaves
every other caller untouched. Read unqualified, "the single implementation of
the move request" would tell a builder to consolidate all of them, which is a
different and much larger feature than this one.
- **`useMoveToStep` in particular stays where it is.** It is not a duplicate of
the hook being extracted: it applies the move optimistically to
`kanbanMulti.snapshots[workflowId].tasks`, keeps a per-task generation counter so
a rejection cannot roll back a newer in-flight move, and rolls the store back
when the backend refuses. Those semantics serve the sidebar's own interaction and
carry their own tests. Folding them into `useWorkflowStepMove` would turn the
preview's and the task top bar's moves from authoritative into optimistic, which
no acceptance criterion here asks for and which would change a surface this
requirement puts out of scope. The two hooks coexist.
- **They can be on screen together, and the existing rules already resolve it.**
`TaskSessionSidebar` renders from
`components/app-sidebar/sections/tasks-section.tsx`, the persistent app sidebar,
not from inside the preview, so one user can move the same task from the sidebar
and from the preview header. Neither hook needs to know about the other: the
backend serialises the moves and the preview renders whatever task state it then
receives, per the requirement's Decisions section. If the sidebar's optimistic
write, or its rollback, changes the previewed task's step, the indicator
re-derives from the newest task state exactly as it does for any other
out-of-band step change
(AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.12), including while the disclosure is
open. A rollback moving the indicator backwards is therefore correct behaviour,
not a race to guard against: the indicator is showing the step the task is
actually on.
- `canMoveToStep` remains the single UI policy for target eligibility.
- `useTouchDrawer` continues to select the popover or the drawer surface from
pointer precision.
- `KanbanWithPreview` resolves the previewed task's own workflow step list and
passes it, the task id, the workflow id, and the current step id down to
`TaskPreviewPanel`. It also owns the move-failure state for the panel, cleared
on a new move and on a task change.
## Step resolution
The previewed task carries its own workflow id, which
`KanbanWithPreview` already derives (from the task record, or from the snapshot
key when a boot-hydrated snapshot task omits it).
Steps resolve from the store in one rule, the same one `plugin-context-api`
uses:
- when the task's workflow id equals `kanban.workflowId`, use `kanban.steps`;
- otherwise use `kanbanMulti.snapshots[workflowId].steps`;
- when neither resolves, the list is empty and no indicator renders.
`useAllWorkflowSnapshots` runs whenever the board mounts, for every workflow in
the workspace, so the second branch covers the multi-workflow board and a
preview opened on a task outside the active workflow filter. The snapshot step
shape already carries `allow_manual_move`, `events`, and `agent_profile_id`, so
the mapping to the stepper's step type is the same field-for-field mapping the
task page performs.
Board column visibility (`hiddenWorkflowStepIds`) is a board rendering filter
and is deliberately not consulted here. That is the point of the feature: the
disclosure is the way to reach a step whose column is hidden.
## Ordering and determinism
The ordering AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.4 states is already
implemented in this repository, and this design adopts it rather than writing it
again. `sortWorkflowStepsByPosition`, exported from
`apps/web/lib/kanban/auto-hide-empty-columns.ts`, sorts a copy by ascending
`position` and breaks ties on ascending `id` compared as a string, which is
exactly the rule that AC names. It already has a unit test for that determinism,
and the board already orders its columns through it, from
`components/kanban/columns-menu.tsx` and `components/kanban/swimlane-container.tsx`.
So AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.4 holds on the board surfaces today.
What does not hold is the stepper: `WorkflowStepper` sorts with its own inline
comparator on `position` alone, private to the component, with no tiebreak. That
is the only non-compliant ordering, and the preview cannot inherit the fix by
rendering `WorkflowStepper`, because it does not render it.
**The comparator moves; it is not rewritten and it is not duplicated.**
`sortWorkflowStepsByPosition` moves, unchanged in name and in body, out of
`auto-hide-empty-columns.ts` and into `apps/web/lib/kanban/workflow-step-order.ts`,
a module that owns workflow step ordering and nothing else. The name and location
follow the existing `lib/kanban/task-order.ts`, which is the in-repo precedent for
a small ordering-only module and orders the board's tasks the way this one orders
its steps. Its existing unit test moves with it, to
`workflow-step-order.test.ts`. Its two existing
board consumers change their import path and nothing else. `WorkflowStepper` and
the preview's step resolution then both import that one function, and
`WorkflowStepper`'s private inline sort is deleted.
Three things this rules out, each of which a builder would otherwise have to
decide alone:
- **A second utility is not acceptable**, whatever it is called. Two shared
functions with the same comparator leave two sets of call sites free to drift,
which is the failure this section exists to prevent, and under drift the board
columns and the disclosure list could order equal-position steps by two
different rules, in a feature whose whole premise is that a disclosure row
stands in for a board column the user cannot see.
- **Importing it from `auto-hide-empty-columns.ts` where it sits today is not
acceptable either**, even though it would work. That module is named for a
different feature, and shared stepper code depending on it would break for a
reason nobody could predict the day the auto-hide feature is removed.
- **The exported name stays `sortWorkflowStepsByPosition`.** `position` is the
primary key and the tiebreak is secondary, so the name is accurate; renaming
would churn two call sites and a test for no behavioural gain. A builder should
not improve it in passing.
Moving the function changes no rendered board column order, because the
comparator is byte-identical before and after, so the requirement's exclusion of
changes to which columns the board renders is untouched. The two board files are
edited only in their import statement.
The function returns a new array and does not mutate its input, which is what
lets every surface sort the same store-owned step list independently. An empty
step list sorts to an empty list, and there is no missing-`position` case to
define: `position` is a required `number` on the store's step type, so the
comparator never sees one absent. Two steps cannot share an `id`, so the tiebreak
is total.
No other ordering exists in this surface: the disclosure renders the sorted list
top to bottom, and the position count is the current step's index in that same
list. When the step list changes underneath an open disclosure
(AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.12), it is re-sorted from the newest
store state by the same function, so a re-derivation cannot produce a different
order than a first render of the same steps.
## Header layout
The preview header stays one flex row: title, step indicator, panel controls.
- The panel controls do not shrink.
- The step indicator sits in a shrinkable container with an upper bound on the
share of the row it can claim, so a long step name cannot crowd the title out
of the row. That bound is half of the row's width remaining after BOTH the
panel controls and the row's inter-element gaps take their fixed width
(AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.4). Both subtractions are load
bearing, and so is the base width they are subtracted from; the next bullet
derives all three and records why.
- The arithmetic at the 300px panel minimum, with `g` the total of the row's
inter-element gaps. The 300px is the width of the panel's OUTER container, not
the header's content box, and several terms sit between the two. Enumerated in
order for the inline layout: that container carries `border-l` (1px); it holds
the resize handle (`w-1`, 4px, non-shrinking) as a flex sibling before the
panel; the panel's own root carries `border-l` (1px); and the header row adds
`px-4` on both sides (32px). Box sizing is `border-box` throughout, so each
border is inside the width it is measured against. The header's content box is
therefore `300 - 1 - 4 - 1 - 32` = **262px**. The two `h-8 w-8` panel controls
plus their own `gap-1` take 68px, leaving 194px, of which the gaps take `g`.
The indicator's cap is therefore `(194 - g) / 2`, and the title is left
`(194 - g) / 2`. The title floor of 88px
(AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.3) holds while `g <= 18px`. That is
the constraint the header must respect. It remains generous in practice, since
the header carries no gap class today, so `g = 0` and the title gets 97px.
- Every term in that derivation is named on purpose. Two earlier drafts of this
section each stated a bound computed from premises they had not inspected: the
first omitted the inter-element gaps, the second took the header's base width
to be the 300px minimum less only its own padding, missing the container
border, the resize handle and the panel border between them. Both produced a
bound that looked safe and was not. A bound whose terms are not enumerated
cannot be re-checked when the layout around it changes, so the enumeration is
part of the contract rather than commentary on it.
- The two layouts differ by one pixel at the same panel width. The floating
layout's outer container has no `border-l`, so its header content box is 263px
and its remainder 195px, giving `g <= 19px`. The inline layout is therefore the
binding case, and `g <= 18px` is the single bound that holds for both: a header
satisfying the inline layout satisfies the floating one by construction.
- When a row cannot satisfy both bounds — which the inequality above prevents at
the 300px minimum, but which must still resolve deterministically at any width
and under any future spacing — the title floor wins and the indicator shrinks
below its cap. The cap is a maximum, never a reservation, so yielding is always
available to it; the floor is a minimum and has nowhere to yield to. The two
bounds are stated separately because the cap governs at every width while the
floor is what an assertion at the minimum width checks.
- The cap is a share of the width remaining after the controls, NOT a percentage
of the whole row. A plain `max-width: 50%` on the indicator would resolve
against the row's full 262px content box and yield 131px, which is not this
bound. Expressing it takes a nested shrinkable group holding the title and the
indicator, or an equivalent that excludes the controls from the percentage
basis. Relatedly, the header is `justify-between` with two children today; a
third child changes how free space is distributed, so the row's alignment is
part of what the containment assertions must cover rather than something that
survives the change untouched. Free space goes to the title, which is the
shrinkable and growable element: the step indicator sits adjacent to the panel
controls at the end of the row rather than floating between the two, so a
short title does not leave the indicator stranded mid-row and the indicator's
position does not move as the title's length changes.
- The title stays a shrinkable, truncating element with a minimum width of 88px.
- Both the title and the step name truncate with an ellipsis. The position count
and the current-step marker do not shrink, so the count stays readable while
the name truncates.
- The indicator's accessible name carries step name, number, and total, so
truncation costs no information.
The narrow bound is the panel's 300px minimum width in the inline layout, which
is the narrower of the two by the pixel derived above. The header must be proven
there, not only at the 500px default and not only in the floating layout.
## Control flow
1. The user opens the preview on a task. `KanbanWithPreview` resolves the task's
own workflow steps and current step id.
2. `TaskPreviewPanel` renders the step indicator when the list is non-empty.
3. Hover, focus, or touch activation opens the disclosure with every step of that
workflow in sorted order.
4. The disclosure marks the current step and enables only eligible targets.
5. Selecting a target disables every move control in the surface and sends the
existing move request with the task's own workflow id, the target step id,
and position `0`.
6. A success closes the disclosure. The preview stays open on the same task and
the same session; live task state then drives the indicator to the new step.
7. A failure leaves the disclosure open and raises the move-failure message
below the header.
## Dismissal and stacking
`KanbanWithPreview` closes the preview from a window-level Escape listener. The
disclosure closes from its own document-level Escape handling. Without a guard,
one Escape press would close both, because the disclosure's handler runs on
`document` and the preview's runs on `window` for the same event.
The preview's Escape handler must therefore ignore an Escape that a disclosure
open inside the panel is already consuming. Whatever mechanism carries that
signal, the observable contract is the one in
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.15: the first Escape closes the
disclosure only, the second closes the preview.
The disclosure content is portalled to the document body at `z-50`, above the
floating preview panel at `z-40` and its backdrop at `z-30`. Because the
disclosure is not a descendant of the backdrop, a click inside it never reaches
the backdrop's close handler.
## Failure and recovery
The move request stays the authority for transition errors. On error the
in-flight state clears, every move control re-enables, and the panel renders the
existing move-failure banner below the header. The banner clears when the next
move starts and when the previewed task changes. A failure that arrives after
the preview has stopped showing the task that issued the request is discarded
rather than rendered, which is the case clearing alone cannot cover: clearing
acts on a message that already exists, and a late response would otherwise
create one after the fact. Per
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.11 the test is whether the preview
stayed open on that same task **continuously** since the request was issued, so
all three of switching to another task, closing the preview, and closing it and
reopening it on the same task must discard. Together the two rules give the
invariant that a stale error is never rendered against a presentation that did
not issue it.
That test is not decidable from the task id, and it is not decidable from the
in-flight counter the current move code carries. `handleMove` guards its late
response with a monotonic request counter held in a component ref, which
distinguishes only one request from a newer one issued by the same live
component; on a close and reopen of the same task the task id is unchanged and
the counter is not advanced by either event, so both signals report "same task,
newest request" for a request the user has since walked away from. Nor can the
hook lean on being unmounted: the move-failure state lives in
`KanbanWithPreview`, which stays mounted across a preview close, and the two
layouts disagree about the panel subtree — the floating layout unmounts it with
the preview while the inline layout renders it inside a pane that stays mounted.
So the identity the hook carries must be a **presentation instance**: a token
minted each time the preview begins showing a task, and invalidated when the
preview closes or switches away, such that reopening on the same task mints a
new one. A response is rendered only when the token captured at request time is
still the current one. Extracting `handleMove` verbatim does not produce this —
the counter is necessary for supersession and not sufficient for continuity, and
`useWorkflowStepMove` owns both. This is the one place where the extraction adds
behaviour rather than relocating it; the task top bar inherits it, which is
correct, because a late failure that outlives its own presentation is wrong on
both surfaces.
The token is supplied by the consuming surface, because only the surface knows
what one of its presentations is. The preview supplies its continuous
presentation of a task, per the rule above. The task top bar supplies the task
route's continuous presentation of a task, so a navigation away and back mints a
new token there for the same reason a close and reopen does here. A surface with
no finer notion than the task itself supplies a token that changes at least
whenever the task changes, which degrades to today's behaviour rather than to
something undefined.
The in-flight step id is scoped to the same token. A new presentation therefore
begins with no in-flight move and no disabled control even while an earlier
request is still outstanding, which is what keeps
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.19's guarantee — the backend still
applies that move, the preview does not cancel it — from leaving a reopened
preview with a permanently disabled row. A discarded response neither renders an
error nor re-enables or disables anything in the presentation that replaced it;
it affects only the presentation that issued it, which by then is gone.
An absent current step falls back to showing the first step in order. The
current-step marker is suppressed in that case, so the surface never asserts a
step the task is not on. The disclosure body already behaves this way; the
shared indicator does not, and is corrected here
(AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14). The correction is in shared
code, so it also removes the same false marker from the task top bar.
## Test strategy
Component tests cover: indicator presence and absence across resolvable,
unresolvable, empty, and single-step workflows; step resolution for a task
outside the active workflow filter; the position tiebreak; eligibility and the
current-step row; the in-flight disable; the success path leaving the panel open;
the failure banner and its clearing rules; and the
discard of a late failure across all three discontinuities
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.11 names, the reopen-on-the-same-task
case included, since that is the one the task id and the request counter both
report as continuous.
Because the presentation-instance token lives in the shared move hook and both
surfaces inherit it, the discard rule is tested against the hook itself, on a
token change, and not only through the preview. Otherwise the top bar's half of
the third correction the requirement's Out of scope names ships with no coverage
at all, on the same argument that applies to the marker fix below: the code runs
in production on two surfaces, so a test that exercises one of them leaves the
other unguarded. The ordering utility keeps its existing unit test, which already
asserts the tiebreak and non-mutation, and moves with it; what is new there is
that `WorkflowStepper` now sorts through it, so the stepper's own tests cover
equal positions rather than assuming they cannot occur.
The correction AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-001.14 makes to the shared
indicator needs an observable that distinguishes a resolved current step from
the first-step fallback, and the implementation must expose one; today the three
marker states differ only by presentation classes on unlabelled elements, which
is not a contract a test can hold. Which observable to expose is an
implementation choice, but a test that cannot tell the corrected marker from the
uncorrected one does not cover this AC. In particular the existing shared-stepper
test for the collapsed no-current-step case asserts only the absence of
`aria-current`, which is already conditioned correctly in the current code and so
passes against the uncorrected marker: it is a false negative for this
correction, not coverage of it. The correction lands in code the task top bar
renders in production, so an untested change here regresses two surfaces.
Desktop E2E covers the motivating scenario end to end: hide a step's column
through the board column visibility filter, open the preview on a task, open the
disclosure, confirm the hidden step is still listed, move the task to it, and
assert the task's step through the API. A second scenario resizes the preview to
its minimum width **in the inline layout**, with a step name long enough to drive
the indicator to its cap, and asserts single-row containment, both panel controls
clickable, and a title of at least the 88px
AC-UI-KANBAN-PREVIEW-STEP-NAVIGATION-002.3 requires — the literal floor, not
merely a non-zero width, because a non-zero assertion passes on a one-pixel
title and would leave the containment budget unproven at exactly the width it
was written for. The inline layout is specified because it is the binding case:
its header content box at the 300px minimum is 262px against the floating
layout's 263px, so a title floor proven there holds for both, while one proven
only in the floating layout does not. A third asserts the two-stage Escape.
Tablet E2E reuses the existing touch-drawer path against the preview indicator.
## Related decisions
No architecture decision record applies. This design reuses the compact stepper
and task-move contracts already established by
`REQ-UI-COMPACT-WORKFLOW-STEP-NAVIGATION-001`.