feat(workflows): auto-hide empty steps (#2815)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
gsimard-nordai
2026-08-22 15:01:46 +00:00
committed by GitHub
parent a2685dccae
commit 9e6bd9001c
66 changed files with 2513 additions and 332 deletions
@@ -594,10 +594,11 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar,
"simplified": settings.SystemMetricsDisplay.Simplified,
},
"appStatusBarEnabled": settings.AppStatusBarEnabled,
"appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder),
"hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs),
"loaded": true,
"appStatusBarEnabled": settings.AppStatusBarEnabled,
"appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder),
"hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs),
"workflowIdsWithAutoHideEmptySteps": stringSlice(settings.WorkflowIDsWithAutoHideEmptySteps),
"loaded": true,
}
}
@@ -103,6 +103,20 @@ func TestMapUserSettingsStateDefaultsHiddenWorkflowStepIdsToEmptyMap(t *testing.
}
}
func TestMapUserSettingsStateDefaultsAutoHideWorkflowIDsToEmptySlice(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{},
}, "workspace-1")
got, ok := state["workflowIdsWithAutoHideEmptySteps"].([]string)
if !ok || len(got) != 0 || got == nil {
t.Fatalf(
"workflowIdsWithAutoHideEmptySteps = %#v, want non-nil empty []string",
state["workflowIdsWithAutoHideEmptySteps"],
)
}
}
// TestMapUserSettingsStateIncludesAppStatusBarOrder verifies boot state carries the app status bar order.
func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
@@ -107,6 +107,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
AppStatusBarEnabled: req.AppStatusBarEnabled,
AppStatusBarOrder: req.AppStatusBarOrder,
KanbanHiddenStepIDs: req.KanbanHiddenStepIDs,
WorkflowIDsWithAutoHideEmptySteps: req.WorkflowIDsWithAutoHideEmptySteps,
})
if err != nil {
return dto.UserSettingsResponse{}, err
+3
View File
@@ -72,6 +72,7 @@ type UserSettingsDTO struct {
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"`
WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"`
Revision int64 `json:"revision"`
UpdatedAt string `json:"updated_at"`
}
@@ -148,6 +149,7 @@ type UpdateUserSettingsRequest struct {
AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"`
AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"`
KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"`
WorkflowIDsWithAutoHideEmptySteps *[]string `json:"workflow_ids_with_auto_hide_empty_steps,omitempty"`
}
type SystemMetricsDisplaySettingsPatch struct {
@@ -298,6 +300,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
AppStatusBarEnabled: settings.AppStatusBarEnabled,
AppStatusBarOrder: settings.AppStatusBarOrder,
KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs,
WorkflowIDsWithAutoHideEmptySteps: append([]string{}, settings.WorkflowIDsWithAutoHideEmptySteps...),
Revision: settings.Revision,
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
}
@@ -101,6 +101,58 @@ func TestKanbanHiddenStepIDsRequestDecode(t *testing.T) {
})
}
func TestWorkflowIDsWithAutoHideEmptyStepsContract(t *testing.T) {
field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("WorkflowIDsWithAutoHideEmptySteps")
if !ok || field.Tag.Get("json") != "workflow_ids_with_auto_hide_empty_steps,omitempty" {
t.Fatalf("WorkflowIDsWithAutoHideEmptySteps patch field = %+v, want JSON preference field", field)
}
defaultSettings := FromUserSettings(&models.UserSettings{})
encoded, err := json.Marshal(defaultSettings)
if err != nil {
t.Fatalf("encode default settings: %v", err)
}
var serialized struct {
WorkflowIDs []string `json:"workflow_ids_with_auto_hide_empty_steps"`
}
if err := json.Unmarshal(encoded, &serialized); err != nil {
t.Fatalf("decode default settings: %v", err)
}
if serialized.WorkflowIDs == nil || len(serialized.WorkflowIDs) != 0 {
t.Fatalf("serialized default WorkflowIDs = %#v, want non-nil empty", serialized.WorkflowIDs)
}
want := []string{"wf-a", "wf-b"}
source := &models.UserSettings{}
sourceField := reflect.ValueOf(source).Elem().FieldByName("WorkflowIDsWithAutoHideEmptySteps")
if !sourceField.IsValid() {
t.Fatal("models.UserSettings.WorkflowIDsWithAutoHideEmptySteps field is absent")
}
sourceField.Set(reflect.ValueOf(want))
settings := FromUserSettings(source)
value := reflect.ValueOf(settings)
mapped := value.FieldByName("WorkflowIDsWithAutoHideEmptySteps")
if !mapped.IsValid() || !reflect.DeepEqual(mapped.Interface(), want) {
t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", mapped, want)
}
var omitted UpdateUserSettingsRequest
if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil {
t.Fatalf("decode omitted preference: %v", err)
}
if omitted.WorkflowIDsWithAutoHideEmptySteps != nil {
t.Fatalf("omitted WorkflowIDsWithAutoHideEmptySteps = %#v, want nil", omitted.WorkflowIDsWithAutoHideEmptySteps)
}
var cleared UpdateUserSettingsRequest
if err := json.Unmarshal([]byte(`{"workflow_ids_with_auto_hide_empty_steps":[]}`), &cleared); err != nil {
t.Fatalf("decode cleared preference: %v", err)
}
if cleared.WorkflowIDsWithAutoHideEmptySteps == nil || len(*cleared.WorkflowIDsWithAutoHideEmptySteps) != 0 {
t.Fatalf("cleared WorkflowIDsWithAutoHideEmptySteps = %#v, want non-nil empty slice", cleared.WorkflowIDsWithAutoHideEmptySteps)
}
}
// TestTasksListShowDetailsDTO verifies the DTO mapping and the nil-versus-explicit-false patch semantics.
func TestTasksListShowDetailsDTO(t *testing.T) {
if !FromUserSettings(&models.UserSettings{TasksListShowDetails: true}).TasksListShowDetails {
@@ -142,6 +142,7 @@ type UserSettings struct {
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"`
KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"`
WorkflowIDsWithAutoHideEmptySteps []string `json:"workflow_ids_with_auto_hide_empty_steps"`
Revision int64 `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+41 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"reflect"
"sort"
"strings"
"time"
@@ -97,6 +98,7 @@ type UpdateUserSettingsRequest struct {
AppStatusBarEnabled *bool
AppStatusBarOrder *models.AppStatusBarOrder
KanbanHiddenStepIDs *map[string][]string
WorkflowIDsWithAutoHideEmptySteps *[]string
}
type SystemMetricsDisplaySettingsPatch struct {
@@ -360,6 +362,15 @@ func applyWorkspaceAndTaskListPreferences(settings *models.UserSettings, req *Up
}
settings.KanbanHiddenStepIDs = *req.KanbanHiddenStepIDs
}
if req.WorkflowIDsWithAutoHideEmptySteps != nil {
workflowIDs, err := normalizeWorkflowIDsWithAutoHideEmptySteps(
*req.WorkflowIDsWithAutoHideEmptySteps,
)
if err != nil {
return err
}
settings.WorkflowIDsWithAutoHideEmptySteps = workflowIDs
}
return nil
}
@@ -375,9 +386,37 @@ const (
// validateKanbanHiddenStepIDs, which both the REST and WebSocket update
// paths call, so it isn't bypassable by whichever transport skips a
// transport-level guard.
maxKanbanHiddenStepIDsTotalBytes = maxUserPreferenceBlobBytes
maxKanbanHiddenStepIDsTotalBytes = maxUserPreferenceBlobBytes
maxWorkflowIDsWithAutoHideEmptySteps = 200
)
func normalizeWorkflowIDsWithAutoHideEmptySteps(workflowIDs []string) ([]string, error) {
unique := make(map[string]struct{}, len(workflowIDs))
for _, workflowID := range workflowIDs {
unique[workflowID] = struct{}{}
}
if len(unique) > maxWorkflowIDsWithAutoHideEmptySteps {
return nil, fmt.Errorf(
"workflow_ids_with_auto_hide_empty_steps: max %d workflow ids allowed",
maxWorkflowIDsWithAutoHideEmptySteps,
)
}
totalBytes := 0
normalized := make([]string, 0, len(unique))
for workflowID := range unique {
totalBytes += len(workflowID)
if totalBytes > maxUserPreferenceBlobBytes {
return nil, fmt.Errorf(
"workflow_ids_with_auto_hide_empty_steps: max %d bytes allowed",
maxUserPreferenceBlobBytes,
)
}
normalized = append(normalized, workflowID)
}
sort.Strings(normalized)
return normalized, nil
}
// validateKanbanHiddenStepIDs bounds the per-workflow hidden-step-id map so a
// single settings write cannot grow the users.settings JSON blob unboundedly
// on the shared single-writer SQLite connection.
@@ -883,6 +922,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"app_status_bar_order": settings.AppStatusBarOrder,
"kanban_hidden_step_ids": settings.KanbanHiddenStepIDs,
"workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps,
"revision": settings.Revision,
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
}
@@ -1060,6 +1060,75 @@ func TestApplyWorkspaceAndTaskListPreferencesKanbanHiddenStepIDs(t *testing.T) {
}
}
func TestApplyWorkspaceAndTaskListPreferencesAutoHideWorkflowIDs(t *testing.T) {
makeWorkflowIDs := func(n int) []string {
ids := make([]string, n)
for i := range ids {
ids[i] = fmt.Sprintf("wf-%d", i)
}
return ids
}
duplicateWorkflowIDs := make([]string, maxWorkflowIDsWithAutoHideEmptySteps+1)
for i := range duplicateWorkflowIDs {
duplicateWorkflowIDs[i] = "wf-duplicate"
}
tests := []struct {
name string
value []string
want []string
wantErr string
}{
{name: "empty list clears the preference", value: []string{}, want: []string{}},
{
name: "normalizes duplicate workflow ids",
value: []string{"wf-b", "wf-a", "wf-b"},
want: []string{"wf-a", "wf-b"},
},
{
name: "applies the count limit after deduplication",
value: duplicateWorkflowIDs,
want: []string{"wf-duplicate"},
},
{
name: "rejects too many workflow ids",
value: makeWorkflowIDs(maxWorkflowIDsWithAutoHideEmptySteps + 1),
wantErr: fmt.Sprintf("workflow_ids_with_auto_hide_empty_steps: max %d workflow ids allowed", maxWorkflowIDsWithAutoHideEmptySteps),
},
{
name: "rejects an oversized preference",
value: []string{strings.Repeat("x", maxUserPreferenceBlobBytes+1)},
wantErr: fmt.Sprintf("workflow_ids_with_auto_hide_empty_steps: max %d bytes allowed", maxUserPreferenceBlobBytes),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings := &models.UserSettings{
WorkflowIDsWithAutoHideEmptySteps: []string{"wf-existing"},
}
err := applyWorkspaceAndTaskListPreferences(settings, &UpdateUserSettingsRequest{
WorkflowIDsWithAutoHideEmptySteps: &tt.value,
})
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want error containing %q", err, tt.wantErr)
}
if !reflect.DeepEqual(settings.WorkflowIDsWithAutoHideEmptySteps, []string{"wf-existing"}) {
t.Fatalf("rejected update changed settings to %#v", settings.WorkflowIDsWithAutoHideEmptySteps)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(settings.WorkflowIDsWithAutoHideEmptySteps, tt.want) {
t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", settings.WorkflowIDsWithAutoHideEmptySteps, tt.want)
}
})
}
}
// makeSidebarViews builds n SidebarView fixtures with distinct IDs and names.
func makeSidebarViews(n int) []models.SidebarView {
views := make([]models.SidebarView, n)
@@ -619,6 +619,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder),
"kanban_hidden_step_ids": settings.KanbanHiddenStepIDs,
"workflow_ids_with_auto_hide_empty_steps": settings.WorkflowIDsWithAutoHideEmptySteps,
})
}
@@ -698,6 +699,7 @@ func defaultUserSettings(userID string) *models.UserSettings {
AppStatusBarEnabled: false,
AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}),
KanbanHiddenStepIDs: map[string][]string{},
WorkflowIDsWithAutoHideEmptySteps: []string{},
}
}
@@ -781,6 +783,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
AppStatusBarEnabled *bool `json:"app_status_bar_enabled"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"`
WorkflowIDsWithAutoHideEmptySteps json.RawMessage `json:"workflow_ids_with_auto_hide_empty_steps"`
}
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
return nil, err
@@ -912,9 +915,21 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
}
settings.LastSeenDisplay = normalizeLastSeenDisplayStored(payload.LastSeenDisplay)
settings.KanbanHiddenStepIDs = decodeKanbanHiddenStepIDs(payload.KanbanHiddenStepIDs)
settings.WorkflowIDsWithAutoHideEmptySteps = decodeStringIDs(payload.WorkflowIDsWithAutoHideEmptySteps)
return settings, nil
}
func decodeStringIDs(raw json.RawMessage) []string {
if len(raw) == 0 || string(raw) == "null" {
return []string{}
}
var ids []string
if err := json.Unmarshal(raw, &ids); err != nil || ids == nil {
return []string{}
}
return ids
}
// normalizeLastSeenDisplayStored maps a stored JSON value to the canonical
// display mode: only a string exactly equal to "relative" is accepted. Every
// other JSON type (number, object, boolean, null) and every other string
@@ -885,6 +885,56 @@ func TestSQLiteRepositoryKanbanHiddenStepIDsDefaultAndRoundTrip(t *testing.T) {
}
}
func TestSQLiteRepositoryWorkflowIDsWithAutoHideEmptyStepsDefaultAndRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
if settings.WorkflowIDsWithAutoHideEmptySteps == nil || len(settings.WorkflowIDsWithAutoHideEmptySteps) != 0 {
t.Fatalf("default WorkflowIDsWithAutoHideEmptySteps = %#v, want non-nil empty", settings.WorkflowIDsWithAutoHideEmptySteps)
}
settings.WorkflowIDsWithAutoHideEmptySteps = []string{"wf-a", "wf-b"}
upsertUserSettingsForTest(t, repo, ctx, settings)
got, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get settings: %v", err)
}
if !reflect.DeepEqual(got.WorkflowIDsWithAutoHideEmptySteps, settings.WorkflowIDsWithAutoHideEmptySteps) {
t.Fatalf("WorkflowIDsWithAutoHideEmptySteps = %#v, want %#v", got.WorkflowIDsWithAutoHideEmptySteps, settings.WorkflowIDsWithAutoHideEmptySteps)
}
}
func TestDecodeWorkflowIDsWithAutoHideEmptyStepsFallsBackToEmpty(t *testing.T) {
tests := []struct {
name string
raw json.RawMessage
}{
{name: "missing", raw: nil},
{name: "null", raw: json.RawMessage(`null`)},
{name: "non-array", raw: json.RawMessage(`{"workflow":"wf-a"}`)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := decodeStringIDs(tt.raw)
if got == nil || len(got) != 0 {
t.Fatalf("decodeStringIDs(%s) = %#v, want non-nil empty", tt.raw, got)
}
})
}
}
// TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty verifies corrupt kanban_hidden_step_ids values fall back to empty while sibling fields still load.
func TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty(t *testing.T) {
tests := []struct {
@@ -611,6 +611,7 @@ export function KanbanCardShell({
data-testid={`task-card-${task.id}`}
className={cn(
"group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
"touch-none md:touch-auto",
needsAction(task) && !isSelected && "border-l-2 border-l-amber-500",
isDragging && "opacity-50 z-50",
isSelected && "ring-1 ring-primary/60 border-primary/60",
@@ -2,14 +2,21 @@
import type { ReactNode } from "react";
import type { WorkflowStep } from "@/components/kanban-column";
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
import { getKanbanColumnGridTemplate, KANBAN_COLUMN_MIN_PX } from "./kanban-grid-template";
type AdaptiveDesktopKanbanProps = {
steps: WorkflowStep[];
isDragging?: boolean;
renderColumn: (step: WorkflowStep) => ReactNode;
};
export function AdaptiveDesktopKanban({ steps, renderColumn }: AdaptiveDesktopKanbanProps) {
export const KANBAN_DRAG_END_PADDING = `max(0px, calc(100% - ${KANBAN_COLUMN_MIN_PX}px))`;
export function AdaptiveDesktopKanban({
steps,
isDragging = false,
renderColumn,
}: AdaptiveDesktopKanbanProps) {
return (
<div data-testid="desktop-kanban-layout" className="h-full min-h-0 min-w-0">
<div
@@ -19,10 +26,13 @@ export function AdaptiveDesktopKanban({ steps, renderColumn }: AdaptiveDesktopKa
<div
data-testid="desktop-kanban-lane-grid"
className="grid h-full min-h-0 min-w-full gap-0"
style={{ gridTemplateColumns: getKanbanColumnGridTemplate(steps.length) }}
style={{
gridTemplateColumns: getKanbanColumnGridTemplate(steps.length),
paddingInlineEnd: isDragging ? KANBAN_DRAG_END_PADDING : undefined,
}}
>
{steps.map((step) => (
<div key={step.id} className="min-h-0 min-w-0 snap-start">
<div key={step.id} data-kanban-step-id={step.id} className="min-h-0 min-w-0 snap-start">
{renderColumn(step)}
</div>
))}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ColumnsMenu } from "./columns-menu";
@@ -19,6 +19,8 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof ColumnsMenu>>
],
hiddenStepIds: [] as string[],
onToggle: vi.fn(),
autoHideEmpty: false,
onToggleAutoHide: vi.fn(),
...overrides,
};
}
@@ -77,6 +79,36 @@ describe("ColumnsMenu — one workflow's steps", () => {
expect(onToggle).toHaveBeenCalledExactlyOnceWith(WF.id, "step-z");
});
it("reports the workflow when automatic empty-column hiding is toggled", () => {
const onToggleAutoHide = vi.fn();
render(<ColumnsMenu {...baseProps({ onToggleAutoHide })} />);
openMenu();
fireEvent.click(screen.getByTestId(`columns-menu-auto-hide-empty-${WF.id}`));
expect(onToggleAutoHide).toHaveBeenCalledExactlyOnceWith(WF.id);
});
it("separates display behavior from individual column visibility", () => {
render(<ColumnsMenu {...baseProps()} />);
openMenu();
const toggle = screen.getByTestId(`columns-menu-auto-hide-empty-${WF.id}`);
const menu = toggle.closest<HTMLElement>('[role="menu"]');
expect(menu).not.toBeNull();
const menuQueries = within(menu!);
const label = menuQueries.getByText("Auto-hide empty columns");
expect(label.classList.contains("whitespace-normal")).toBe(true);
expect(label.classList.contains("truncate")).toBe(false);
expect(menuQueries.queryByText("Show empty steps again while moving tasks.")).toBeNull();
expect(
Array.from(menu!.querySelectorAll('[data-slot="dropdown-menu-label"]')).map(
(element) => element.textContent,
),
).toEqual(["Display Options", "Columns"]);
expect(menu!.querySelectorAll('[data-slot="dropdown-menu-separator"]')).toHaveLength(1);
});
it("ignores a hidden id that no longer matches a live step", () => {
render(<ColumnsMenu {...baseProps({ hiddenStepIds: ["step-deleted"] })} />);
openMenu();
+20 -4
View File
@@ -7,11 +7,13 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { IconColumns3 } from "@tabler/icons-react";
import { cn } from "@kandev/ui/lib/utils";
import { useTranslation } from "react-i18next";
import { sortWorkflowStepsByPosition } from "@/lib/kanban/auto-hide-empty-columns";
export type ColumnsMenuStep = { id: string; title: string; position: number };
@@ -23,6 +25,8 @@ export type ColumnsMenuProps = {
/** Persisted hidden ids for this workflow; ids with no live step are inert. */
hiddenStepIds: string[];
onToggle: (workflowId: string, stepId: string) => void;
autoHideEmpty: boolean;
onToggleAutoHide: (workflowId: string) => void;
/**
* Phone rows must clear 44 CSS px. Content is identical either way — this
* only relaxes menu density for touch.
@@ -45,13 +49,12 @@ export function ColumnsMenu({
steps,
hiddenStepIds,
onToggle,
autoHideEmpty,
onToggleAutoHide,
touchTargets = false,
}: ColumnsMenuProps) {
const { t } = useTranslation();
const orderedSteps = useMemo(
() => [...steps].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id)),
[steps],
);
const orderedSteps = useMemo(() => sortWorkflowStepsByPosition(steps), [steps]);
const hiddenSet = useMemo(() => new Set(hiddenStepIds), [hiddenStepIds]);
return (
@@ -71,6 +74,19 @@ export function ColumnsMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>{t("kanban:displayOptions")}</DropdownMenuLabel>
<DropdownMenuCheckboxItem
data-testid={`columns-menu-auto-hide-empty-${workflowId}`}
className={touchTargets ? "min-h-11" : undefined}
checked={autoHideEmpty}
onCheckedChange={() => onToggleAutoHide(workflowId)}
onSelect={(event) => event.preventDefault()}
>
<span className="min-w-0 flex-1 whitespace-normal">
{t("kanban:autoHideEmptyColumns")}
</span>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("kanban:columns")}</DropdownMenuLabel>
{orderedSteps.map((step) => (
<DropdownMenuCheckboxItem
@@ -0,0 +1,27 @@
"use client";
import { useTranslation } from "react-i18next";
import type { WorkflowStep } from "@/components/kanban-column";
import { areAllEmptyStepsAutoHidden } from "@/lib/kanban/auto-hide-empty-columns";
export function DesktopAutoHiddenEmptyState() {
const { t } = useTranslation();
return (
<div
className="mx-3 mb-3 rounded-lg border border-dashed border-border/70 px-4 py-6 text-center text-sm text-muted-foreground"
data-testid="kanban-auto-hidden-empty-state"
>
{t("kanban:allEmptyStepsAutoHidden")}
</div>
);
}
export function getDesktopEmptyState(
isMobile: boolean,
displaySteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
) {
if (isMobile || displaySteps.length > 0) return undefined;
if (!areAllEmptyStepsAutoHidden(displaySteps, moveTargetSteps)) return null;
return <DesktopAutoHiddenEmptyState />;
}
@@ -1,4 +1,4 @@
import { cleanup, render } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
@@ -155,3 +155,65 @@ describe("Graph2StepNode — waiting-for-input variants", () => {
expect(container.querySelector(ICON_LOADER2)).toBeNull();
});
});
describe("Graph2StepNode — hidden destination disclosure", () => {
function renderNextMove(nextStepHidden: boolean) {
render(
<StateProvider>
<TooltipProvider delayDuration={0}>
<Graph2StepNode
step={STEP}
phase="current"
task={makeTask()}
hasPrev={false}
hasNext
nextStepId="step-done"
nextStepTitle="Done"
nextStepHidden={nextStepHidden}
onMoveTask={() => undefined}
onPreviewTask={() => undefined}
/>
</TooltipProvider>
</StateProvider>,
);
const currentStep = screen.getByRole("button", { name: "In Progress" });
fireEvent.mouseEnter(currentStep.parentElement!);
return screen.getByRole("button", { name: "Move to Done" });
}
it("shows the destination tooltip only when the destination is hidden", async () => {
const hiddenTargetButton = renderNextMove(true);
fireEvent.focus(hiddenTargetButton);
await waitFor(() => expect(screen.getByRole("tooltip").textContent).toBe("Move to Done"));
cleanup();
const visibleTargetButton = renderNextMove(false);
fireEvent.focus(visibleTargetButton);
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("exposes move controls when the current node receives keyboard focus", () => {
render(
<StateProvider>
<TooltipProvider>
<Graph2StepNode
step={STEP}
phase="current"
task={makeTask()}
hasPrev={false}
hasNext
nextStepId="step-done"
nextStepTitle="Done"
nextStepHidden
onMoveTask={() => undefined}
onPreviewTask={() => undefined}
/>
</TooltipProvider>
</StateProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "In Progress" }));
expect(screen.getByRole("button", { name: "Move to Done" })).not.toBeNull();
});
});
@@ -14,6 +14,8 @@ import { linkToTask } from "@/lib/links";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
type StepPhase = "past" | "current" | "future";
@@ -31,6 +33,10 @@ export type Graph2StepNodeProps = {
onPreviewTask: (task: Task) => void;
prevStepId?: string;
nextStepId?: string;
prevStepTitle?: string;
nextStepTitle?: string;
prevStepHidden?: boolean;
nextStepHidden?: boolean;
isMoving?: boolean;
};
@@ -62,17 +68,22 @@ function FutureNode({ step }: { step: WorkflowStep }) {
function MoveButton({
direction,
isMoving,
label,
showTooltip,
onClick,
}: {
direction: "left" | "right";
isMoving?: boolean;
label: string;
showTooltip: boolean;
onClick: (e: React.MouseEvent) => void;
}) {
const posClass = direction === "left" ? "-left-3" : "-right-3";
const Icon = direction === "left" ? IconChevronLeft : IconChevronRight;
return (
const button = (
<button
type="button"
aria-label={label}
disabled={isMoving}
onClick={onClick}
className={cn(
@@ -86,6 +97,13 @@ function MoveButton({
<Icon className="h-3 w-3" />
</button>
);
if (!showTooltip) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function Graph2StepNode({
@@ -97,10 +115,16 @@ export function Graph2StepNode({
onMoveTask,
prevStepId,
nextStepId,
prevStepTitle,
nextStepTitle,
prevStepHidden = false,
nextStepHidden = false,
isMoving,
}: Graph2StepNodeProps) {
const { t } = useTranslation();
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const pendingInput = useTaskPendingInput(task.primarySessionId, {
taskId: task.id,
taskPendingAction: task.taskPendingAction,
@@ -118,16 +142,26 @@ export function Graph2StepNode({
router.push(linkToTask(task.id));
};
const showMoveControls = isHovered || isFocused;
return (
<div
className="relative shrink-0"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocusCapture={() => setIsFocused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setIsFocused(false);
}
}}
>
{isHovered && hasPrev && prevStepId && (
{showMoveControls && hasPrev && prevStepId && (
<MoveButton
direction="left"
isMoving={isMoving}
label={t("kanban:moveToStep", { step: prevStepTitle ?? prevStepId })}
showTooltip={prevStepHidden}
onClick={(e) => {
e.stopPropagation();
onMoveTask(task, prevStepId);
@@ -158,10 +192,12 @@ export function Graph2StepNode({
</div>
</button>
{isHovered && hasNext && nextStepId && (
{showMoveControls && hasNext && nextStepId && (
<MoveButton
direction="right"
isMoving={isMoving}
label={t("kanban:moveToStep", { step: nextStepTitle ?? nextStepId })}
showTooltip={nextStepHidden}
onClick={(e) => {
e.stopPropagation();
onMoveTask(task, nextStepId);
@@ -27,6 +27,7 @@ type ConnectorType = "past" | "transition" | "future";
export type Graph2TaskPipelineProps = {
task: Task;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
onMoveTask: (task: Task, targetStepId: string) => void;
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
@@ -62,6 +63,13 @@ export type StepAdjacency = {
nextStepId?: string;
};
export type StepMoveTargets = StepAdjacency & {
prevStepTitle?: string;
prevStepHidden: boolean;
nextStepTitle?: string;
nextStepHidden: boolean;
};
/**
* Computes the prev/next move targets for the node at `index`. The synthetic
* "Needs Reassignment" node is display-only: it marks where an orphaned task
@@ -80,8 +88,36 @@ export function getStepAdjacency(steps: WorkflowStep[], index: number): StepAdja
};
}
export function getStepAdjacencyForStep(
moveTargetSteps: WorkflowStep[],
stepId: string,
): StepAdjacency {
const index = moveTargetSteps.findIndex((step) => step.id === stepId);
if (index < 0) return { hasPrev: false, hasNext: false };
return getStepAdjacency(moveTargetSteps, index);
}
export function getStepMoveTargets(
visibleSteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
stepId: string,
): StepMoveTargets {
const adjacency = getStepAdjacencyForStep(moveTargetSteps, stepId);
const visibleStepIds = new Set(visibleSteps.map((step) => step.id));
const prevStep = moveTargetSteps.find((step) => step.id === adjacency.prevStepId);
const nextStep = moveTargetSteps.find((step) => step.id === adjacency.nextStepId);
return {
...adjacency,
prevStepTitle: prevStep?.title,
prevStepHidden: !!adjacency.prevStepId && !visibleStepIds.has(adjacency.prevStepId),
nextStepTitle: nextStep?.title,
nextStepHidden: !!adjacency.nextStepId && !visibleStepIds.has(adjacency.nextStepId),
};
}
function PipelineStepNodes({
steps,
moveTargetSteps,
currentStepIndex,
task,
onMoveTask,
@@ -89,6 +125,7 @@ function PipelineStepNodes({
isMoving,
}: {
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
currentStepIndex: number;
task: Task;
onMoveTask: (task: Task, targetStepId: string) => void;
@@ -104,7 +141,7 @@ function PipelineStepNodes({
? getConnectorType(phase, getStepPhase(index + 1, currentStepIndex))
: null;
const { hasPrev, prevStepId, hasNext, nextStepId } = getStepAdjacency(steps, index);
const moveTargets = getStepMoveTargets(steps, moveTargetSteps, step.id);
return (
<div key={step.id} className="flex items-center">
@@ -112,10 +149,14 @@ function PipelineStepNodes({
step={step}
phase={phase}
task={task}
hasPrev={hasPrev}
hasNext={hasNext}
prevStepId={prevStepId}
nextStepId={nextStepId}
hasPrev={moveTargets.hasPrev}
hasNext={moveTargets.hasNext}
prevStepId={moveTargets.prevStepId}
nextStepId={moveTargets.nextStepId}
prevStepTitle={moveTargets.prevStepTitle}
nextStepTitle={moveTargets.nextStepTitle}
prevStepHidden={moveTargets.prevStepHidden}
nextStepHidden={moveTargets.nextStepHidden}
onMoveTask={onMoveTask}
onPreviewTask={onPreviewTask}
isMoving={isMoving}
@@ -264,6 +305,7 @@ function useTaskRepoName(task: Task): string | undefined {
export function Graph2TaskPipeline({
task,
steps,
moveTargetSteps,
onMoveTask,
onPreviewTask,
onOpenTask,
@@ -324,6 +366,7 @@ export function Graph2TaskPipeline({
/>
<PipelineStepNodes
steps={steps}
moveTargetSteps={moveTargetSteps}
currentStepIndex={currentStepIndex}
task={task}
onMoveTask={onMoveTask}
@@ -0,0 +1,39 @@
import { createRef } from "react";
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const dndContextProps = vi.hoisted(() => vi.fn());
const pointerWithinMock = vi.hoisted(() => vi.fn());
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children, ...props }: { children: React.ReactNode }) => {
dndContextProps(props);
return <>{children}</>;
},
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
pointerWithin: pointerWithinMock,
}));
import { KANBAN_AUTO_SCROLL_OPTIONS, KanbanDragSurface } from "./kanban-drag-surface";
describe("KanbanDragSurface", () => {
it("leaves horizontal layout compensation to the board anchor", () => {
render(
<KanbanDragSurface
layoutContent={<div />}
activeTask={null}
boardRef={createRef<HTMLDivElement>()}
/>,
);
expect(KANBAN_AUTO_SCROLL_OPTIONS).toEqual({
layoutShiftCompensation: { x: false, y: true },
});
expect(dndContextProps).toHaveBeenCalledWith(
expect.objectContaining({
autoScroll: KANBAN_AUTO_SCROLL_OPTIONS,
collisionDetection: pointerWithinMock,
}),
);
});
});
@@ -0,0 +1,41 @@
"use client";
import type { ComponentProps, ReactNode, RefObject } from "react";
import { DndContext, DragOverlay, pointerWithin } from "@dnd-kit/core";
import { KanbanCardPreview } from "@/components/kanban-card-preview";
import type { Task } from "@/components/kanban-card";
type KanbanDragSurfaceProps = Pick<
ComponentProps<typeof DndContext>,
"sensors" | "onDragStart" | "onDragEnd" | "onDragCancel"
> & {
layoutContent: ReactNode;
activeTask: Task | null;
boardRef: RefObject<HTMLDivElement | null>;
};
export const KANBAN_AUTO_SCROLL_OPTIONS = {
layoutShiftCompensation: { x: false, y: true },
} as const;
export function KanbanDragSurface({
layoutContent,
activeTask,
boardRef,
...dndProps
}: KanbanDragSurfaceProps) {
return (
<DndContext
autoScroll={KANBAN_AUTO_SCROLL_OPTIONS}
collisionDetection={pointerWithin}
{...dndProps}
>
<div ref={boardRef} className="relative h-full min-h-0">
{layoutContent}
</div>
<DragOverlay dropAnimation={null}>
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
</DragOverlay>
</DndContext>
);
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
import { KANBAN_DRAG_END_PADDING } from "./adaptive-desktop-kanban";
describe("getKanbanColumnGridTemplate", () => {
it("keeps every desktop column readable", () => {
@@ -14,3 +15,9 @@ describe("getKanbanColumnGridTemplate", () => {
expect(getKanbanColumnGridTemplate(0)).toBe("repeat(0, minmax(280px, 1fr))");
});
});
describe("KANBAN_DRAG_END_PADDING", () => {
it("reserves one viewport minus one readable column for drag anchoring", () => {
expect(KANBAN_DRAG_END_PADDING).toBe("max(0px, calc(100% - 280px))");
});
});
+174 -62
View File
@@ -30,6 +30,7 @@ type MobileColumnTabsProps = {
taskCounts: Record<string, number>;
onColumnChange: (index: number) => void;
workflowNavigation?: MobileWorkflowNavigation;
allStepsAutoHidden?: boolean;
};
function StepCount({ step, count }: { step: WorkflowStep; count: number }) {
@@ -114,12 +115,14 @@ function StepOptions({
taskCounts,
onSelect,
separated,
allStepsAutoHidden,
}: {
steps: WorkflowStep[];
activeIndex: number;
taskCounts: Record<string, number>;
onSelect: (index: number) => void;
separated: boolean;
allStepsAutoHidden?: boolean;
}) {
const { t } = useTranslation();
return (
@@ -134,7 +137,9 @@ function StepOptions({
{t("kanban:step")}
</h3>
{steps.length === 0 && (
<p className="px-3 py-3 text-sm text-muted-foreground">{t("kanban:noStepsConfigured")}</p>
<p className="px-3 py-3 text-sm text-muted-foreground">
{allStepsAutoHidden ? t("kanban:allEmptyStepsAutoHidden") : t("kanban:noStepsConfigured")}
</p>
)}
{steps.map((step, index) => {
const isActive = index === activeIndex;
@@ -167,6 +172,7 @@ function NavigatorDrawerContent({
activeIndex,
taskCounts,
workflowNavigation,
allStepsAutoHidden,
onSelectStep,
onSelectWorkflow,
}: Omit<MobileColumnTabsProps, "onColumnChange"> & {
@@ -192,18 +198,173 @@ function NavigatorDrawerContent({
taskCounts={taskCounts}
onSelect={onSelectStep}
separated={!!workflowNavigation}
allStepsAutoHidden={allStepsAutoHidden}
/>
</div>
</DrawerContent>
);
}
function getEmptyStepsLabel(allStepsAutoHidden: boolean, t: (key: string) => string): string {
return t(allStepsAutoHidden ? "kanban:allEmptyStepsAutoHidden" : "kanban:noStepsConfigured");
}
function PreviousStepButton({
activeStep,
activeIndex,
onColumnChange,
t,
}: {
activeStep?: WorkflowStep;
activeIndex: number;
onColumnChange: (index: number) => void;
t: (key: string) => string;
}) {
return (
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]"
disabled={!activeStep || activeIndex === 0}
onClick={() => onColumnChange(activeIndex - 1)}
aria-label={t("kanban:previousStep")}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
);
}
function NextStepButton({
activeStep,
activeIndex,
stepCount,
onColumnChange,
t,
}: {
activeStep?: WorkflowStep;
activeIndex: number;
stepCount: number;
onColumnChange: (index: number) => void;
t: (key: string) => string;
}) {
return (
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]"
disabled={!activeStep || activeIndex === stepCount - 1}
onClick={() => onColumnChange(activeIndex + 1)}
aria-label={t("kanban:nextStep")}
>
<IconChevronRight className="h-4 w-4" />
</Button>
);
}
function NavigatorTrigger({
activeStep,
activeWorkflow,
stepLabel,
taskCounts,
t,
}: {
activeStep?: WorkflowStep;
activeWorkflow?: MobileWorkflowNavigation["workflows"][number];
stepLabel: string;
taskCounts: Record<string, number>;
t: (key: string, options?: Record<string, string>) => string;
}) {
const ariaLabel = activeWorkflow
? t("kanban:chooseWorkflowOrStep", { name: activeWorkflow.name, stepLabel })
: t("kanban:chooseStep", { stepLabel });
return (
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="h-14 min-w-0 cursor-pointer justify-between rounded-xl bg-muted/30 px-3 shadow-sm transition-[background-color,color,border-color,box-shadow,transform] duration-150 ease-out active:scale-[0.96]"
data-testid="mobile-board-navigator"
aria-label={ariaLabel}
>
<span className="flex min-w-0 items-center gap-2.5 text-left">
<IconLayoutKanban className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
{activeWorkflow && (
<span className="truncate text-[11px] font-medium leading-4 text-muted-foreground">
{activeWorkflow.name}
</span>
)}
<span className="flex min-w-0 items-center gap-1.5">
{activeStep && (
<span className={cn("h-2 w-2 shrink-0 rounded-full", activeStep.color)} />
)}
<span className="truncate text-sm font-semibold leading-5">{stepLabel}</span>
</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{activeStep && <StepCount step={activeStep} count={taskCounts[activeStep.id] ?? 0} />}
<IconChevronDown className="h-4 w-4 text-muted-foreground" />
</span>
</Button>
</DrawerTrigger>
);
}
function MobileColumnTabsHeader({
steps,
activeStep,
activeIndex,
activeWorkflow,
stepLabel,
taskCounts,
onColumnChange,
t,
}: {
steps: WorkflowStep[];
activeStep?: WorkflowStep;
activeIndex: number;
activeWorkflow?: MobileWorkflowNavigation["workflows"][number];
stepLabel: string;
taskCounts: Record<string, number>;
onColumnChange: (index: number) => void;
t: (key: string, options?: Record<string, string>) => string;
}) {
return (
<div className="grid shrink-0 grid-cols-[44px_minmax(0,1fr)_44px] items-center gap-2 border-b border-border/70 px-4 py-2">
<PreviousStepButton
activeStep={activeStep}
activeIndex={activeIndex}
onColumnChange={onColumnChange}
t={t}
/>
<NavigatorTrigger
activeStep={activeStep}
activeWorkflow={activeWorkflow}
stepLabel={stepLabel}
taskCounts={taskCounts}
t={t}
/>
<NextStepButton
activeStep={activeStep}
activeIndex={activeIndex}
stepCount={steps.length}
onColumnChange={onColumnChange}
t={t}
/>
</div>
);
}
export function MobileColumnTabs({
steps,
activeIndex,
taskCounts,
onColumnChange,
workflowNavigation,
allStepsAutoHidden = false,
}: MobileColumnTabsProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -212,7 +373,7 @@ export function MobileColumnTabs({
workflowNavigation?.workflows.find(
(workflow) => workflow.id === workflowNavigation.activeWorkflowId,
) ?? workflowNavigation?.workflows[0];
const stepLabel = activeStep?.title ?? t("task:noStepsConfigured");
const stepLabel = activeStep?.title ?? getEmptyStepsLabel(allStepsAutoHidden, t);
const selectStep = (index: number) => {
onColumnChange(index);
@@ -224,72 +385,23 @@ export function MobileColumnTabs({
return (
<Drawer open={open} onOpenChange={setOpen}>
<div className="grid shrink-0 grid-cols-[44px_minmax(0,1fr)_44px] items-center gap-2 border-b border-border/70 px-4 py-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]"
disabled={!activeStep || activeIndex === 0}
onClick={() => onColumnChange(activeIndex - 1)}
aria-label={t("kanban:previousStep")}
>
<IconChevronLeft className="h-4 w-4" />
</Button>
<DrawerTrigger asChild>
<Button
type="button"
variant="outline"
className="h-14 min-w-0 cursor-pointer justify-between rounded-xl bg-muted/30 px-3 shadow-sm transition-[background-color,color,border-color,box-shadow,transform] duration-150 ease-out active:scale-[0.96]"
data-testid="mobile-board-navigator"
aria-label={
activeWorkflow
? t("kanban:chooseWorkflowOrStep", { name: activeWorkflow.name, stepLabel })
: t("kanban:chooseStep", { stepLabel })
}
>
<span className="flex min-w-0 items-center gap-2.5 text-left">
<IconLayoutKanban className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
{activeWorkflow && (
<span className="truncate text-[11px] font-medium leading-4 text-muted-foreground">
{activeWorkflow.name}
</span>
)}
<span className="flex min-w-0 items-center gap-1.5">
{activeStep && (
<span className={cn("h-2 w-2 shrink-0 rounded-full", activeStep.color)} />
)}
<span className="truncate text-sm font-semibold leading-5">{stepLabel}</span>
</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{activeStep && <StepCount step={activeStep} count={taskCounts[activeStep.id] ?? 0} />}
<IconChevronDown className="h-4 w-4 text-muted-foreground" />
</span>
</Button>
</DrawerTrigger>
<Button
type="button"
variant="outline"
size="icon"
className="h-11 w-11 cursor-pointer rounded-xl transition-[background-color,color,border-color,transform] duration-150 ease-out active:scale-[0.96]"
disabled={!activeStep || activeIndex === steps.length - 1}
onClick={() => onColumnChange(activeIndex + 1)}
aria-label={t("kanban:nextStep")}
>
<IconChevronRight className="h-4 w-4" />
</Button>
</div>
<MobileColumnTabsHeader
steps={steps}
activeStep={activeStep}
activeIndex={activeIndex}
activeWorkflow={activeWorkflow}
stepLabel={stepLabel}
taskCounts={taskCounts}
onColumnChange={onColumnChange}
t={t}
/>
<NavigatorDrawerContent
steps={steps}
activeIndex={activeIndex}
taskCounts={taskCounts}
workflowNavigation={workflowNavigation}
allStepsAutoHidden={allStepsAutoHidden}
onSelectStep={selectStep}
onSelectWorkflow={selectWorkflow}
/>
@@ -18,6 +18,7 @@ function MobileDropTarget({ step, isCurrentStep }: MobileDropTargetProps) {
return (
<div
ref={setNodeRef}
data-testid={`mobile-drop-target-${step.id}`}
className={cn(
"flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed px-3 py-2 transition-all",
(() => {
@@ -76,11 +76,13 @@ function defaultDisplaySettings() {
eligibleWorkflows: [],
snapshots: {},
hiddenWorkflowStepIds: {},
workflowIdsWithAutoHideEmptySteps: [],
onWorkflowChange: vi.fn(),
onRepositoryChange: vi.fn(),
onTogglePreviewOnClick: vi.fn(),
onToggleTasksListShowDetails: vi.fn(),
onToggleStepVisibility: vi.fn(),
onToggleAutoHideEmpty: vi.fn(),
effectiveTaskListingView: "kanban",
onViewModeChange: vi.fn(),
};
@@ -72,6 +72,8 @@ export type MobileColumnsSection = {
steps: ColumnsMenuStep[];
hiddenStepIds: string[];
onToggle: (workflowId: string, stepId: string) => void;
autoHideEmpty: boolean;
onToggleAutoHide: (workflowId: string) => void;
};
function MobileDisplaySelects({
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { ORPHAN_STEP_ID, isOrphanMoveTarget } from "./swimlane-kanban-content";
import { getStepAdjacency } from "./graph2-task-pipeline";
import {
getStepAdjacency,
getStepAdjacencyForStep,
getStepMoveTargets,
} from "./graph2-task-pipeline";
import type { WorkflowStep } from "@/components/kanban-column";
describe("isOrphanMoveTarget", () => {
@@ -14,6 +18,45 @@ describe("isOrphanMoveTarget", () => {
});
});
describe("getStepAdjacencyForStep", () => {
it("keeps auto-hidden workflow steps available as adjacent pipeline move targets", () => {
const steps = [{ id: "todo" }, { id: "doing" }, { id: "done" }] as WorkflowStep[];
expect(getStepAdjacencyForStep(steps, "doing")).toEqual({
hasPrev: true,
prevStepId: "todo",
hasNext: true,
nextStepId: "done",
});
});
it("returns no moves for a display-only step", () => {
expect(getStepAdjacencyForStep([{ id: "todo" } as WorkflowStep], "orphan")).toEqual({
hasPrev: false,
hasNext: false,
});
});
});
describe("getStepMoveTargets", () => {
const moveTargets = [
{ id: "backlog", title: "Backlog" },
{ id: "review", title: "Review" },
{ id: "done", title: "Done" },
] as WorkflowStep[];
it("flags only a hidden neighboring destination for tooltip disclosure", () => {
const result = getStepMoveTargets([moveTargets[1], moveTargets[2]], moveTargets, "review");
expect(result).toMatchObject({
prevStepTitle: "Backlog",
prevStepHidden: true,
nextStepTitle: "Done",
nextStepHidden: false,
});
});
});
describe("getStepAdjacency", () => {
const realSteps: WorkflowStep[] = [
{ id: "todo", title: "Todo", color: "#64748b" },
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@/components/kanban-card";
import { filterTasks } from "./swimlane-container";
import { filterTasks, projectWorkflowTasks } from "@/lib/kanban/task-projections";
import { mapSelectedRepositoryIds } from "@/lib/kanban/filters";
function makeTask(overrides: Partial<Task> & { id: string }): Task {
@@ -163,3 +163,29 @@ describe("filterTasks — hiddenStepIds (per-workflow step visibility filter)",
expect(resultB.map((t) => t.id)).toEqual(["2"]);
});
});
describe("projectWorkflowTasks — visible cards vs column occupancy", () => {
const NO_REPO_FILTER = mapSelectedRepositoryIds([], []);
it("keeps search and manual hiding out of occupancy while applying plugin filters", () => {
const snapshots = {
wf: {
tasks: [
makeTask({ id: "visible", title: "Needle", workflowStepId: "step-a" }),
makeTask({ id: "hidden", title: "Other", workflowStepId: "step-b" }),
makeTask({ id: "plugin-rejected", title: "Needle", workflowStepId: "step-c" }),
],
steps: [{ id: "step-a" }, { id: "step-b" }, { id: "step-c" }],
},
};
const projection = projectWorkflowTasks(snapshots, "wf", NO_REPO_FILTER, {
searchQuery: "needle",
hiddenStepIds: new Set(["step-b"]),
matchesPluginTaskFilters: (taskId) => taskId !== "plugin-rejected",
});
expect(projection.visibleTasks.map((task) => task.id)).toEqual(["visible"]);
expect(projection.occupancyTasks.map((task) => task.id)).toEqual(["visible", "hidden"]);
});
});
@@ -19,7 +19,8 @@ import { CSS } from "@dnd-kit/utilities";
import { useAppStore } from "@/components/state-provider";
import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters";
import { mapSelectedRepositoryIds } from "@/lib/kanban/filters";
import { projectWorkflowTasks } from "@/lib/kanban/task-projections";
import {
selectMobileNavigatorWorkflows,
selectWorkflowSwimlanes,
@@ -28,6 +29,10 @@ import {
import { reorderWorkflows } from "@/lib/api";
import { SwimlaneSection } from "./swimlane-section";
import { ColumnsMenu } from "./columns-menu";
import {
deriveAutoHiddenStepIds,
sortWorkflowStepsByPosition,
} from "@/lib/kanban/auto-hide-empty-columns";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import {
getEffectiveView,
@@ -95,45 +100,6 @@ function renderEmptyState(emptyMessage: string) {
);
}
type FilterTasksOptions = {
searchQuery?: string;
matchesPluginTaskFilters?: (taskId: string) => boolean;
hiddenStepIds?: Set<string>;
};
/** Exported for unit testing; not part of the module's public component API. */
export function filterTasks(
snapshots: Record<string, { tasks: Task[]; steps: { id: string }[] }>,
workflowId: string,
repoFilter: ReturnType<typeof mapSelectedRepositoryIds>,
options?: FilterTasksOptions,
): Task[] {
const snapshot = snapshots[workflowId];
if (!snapshot) return [];
let tasks = snapshot.tasks as Task[];
const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {};
if (hiddenStepIds && hiddenStepIds.size > 0) {
const liveStepIds = new Set(snapshot.steps.map((s) => s.id));
const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id)));
if (effectiveHidden.size > 0) {
tasks = tasks.filter((t) => !effectiveHidden.has(t.workflowStepId));
}
}
tasks = filterTasksByRepositories(tasks, repoFilter);
if (searchQuery) {
const q = searchQuery.toLowerCase();
tasks = tasks.filter(
(t) =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)),
);
}
if (matchesPluginTaskFilters) {
tasks = tasks.filter((t) => matchesPluginTaskFilters(t.id));
}
return tasks;
}
/** Stable identity so an unhidden workflow does not remount its menu each render. */
const EMPTY_HIDDEN_IDS: string[] = [];
@@ -141,6 +107,7 @@ type WorkflowItemProps = {
wf: { id: string; name: string };
snapshot: WorkflowSnapshotData;
tasks: Task[];
occupancyTasks: Task[];
ViewComponent: ComponentType<ViewContentProps>;
hideHeader: boolean;
isSortable: boolean;
@@ -163,6 +130,7 @@ type WorkflowItemProps = {
fillHeight?: boolean;
mobileWorkflowNavigation?: MobileWorkflowNavigation;
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
onToggleAutoHideEmpty: (workflowId: string) => void;
};
function SortableWorkflowItem({
@@ -203,6 +171,7 @@ function WorkflowItemContent({
wf,
snapshot,
tasks,
occupancyTasks,
ViewComponent,
hideHeader,
isCollapsed,
@@ -211,23 +180,52 @@ function WorkflowItemContent({
onToggleMultiSelect,
fillHeight,
onToggleStepVisibility,
onToggleAutoHideEmpty,
...viewProps
}: Omit<WorkflowItemProps, "isSortable"> & { dragHandleProps?: HTMLAttributes<HTMLDivElement> }) {
const hiddenWorkflowStepIds = useAppStore((state) => state.userSettings.hiddenWorkflowStepIds);
const workflowIdsWithAutoHideEmptySteps = useAppStore(
(state) => state.userSettings.workflowIdsWithAutoHideEmptySteps,
);
const hiddenSet = useMemo(() => {
const ids = hiddenWorkflowStepIds[wf.id];
if (!ids || ids.length === 0) return new Set<string>();
const liveStepIds = new Set(snapshot.steps.map((s) => s.id));
return new Set(ids.filter((id) => liveStepIds.has(id)));
}, [hiddenWorkflowStepIds, wf.id, snapshot.steps]);
const steps = useMemo(
const autoHiddenSet = useMemo(
() =>
[...snapshot.steps]
.filter((step) => !hiddenSet.has(step.id))
.sort((a, b) => a.position - b.position),
deriveAutoHiddenStepIds(
snapshot.steps,
occupancyTasks,
workflowIdsWithAutoHideEmptySteps.includes(wf.id),
hiddenWorkflowStepIds[wf.id] ?? EMPTY_HIDDEN_IDS,
),
[
snapshot.steps,
occupancyTasks,
workflowIdsWithAutoHideEmptySteps,
wf.id,
hiddenWorkflowStepIds,
],
);
const moveTargetSteps = useMemo(
() => sortWorkflowStepsByPosition(snapshot.steps).filter((step) => !hiddenSet.has(step.id)),
[snapshot.steps, hiddenSet],
);
const content = <ViewComponent workflowId={wf.id} steps={steps} tasks={tasks} {...viewProps} />;
const steps = useMemo(
() => moveTargetSteps.filter((step) => !autoHiddenSet.has(step.id)),
[moveTargetSteps, autoHiddenSet],
);
const content = (
<ViewComponent
workflowId={wf.id}
steps={steps}
moveTargetSteps={moveTargetSteps}
tasks={tasks}
{...viewProps}
/>
);
if (hideHeader) {
return <div className={fillHeight ? "h-full min-h-0" : undefined}>{content}</div>;
@@ -251,6 +249,8 @@ function WorkflowItemContent({
steps={snapshot.steps}
hiddenStepIds={hiddenWorkflowStepIds[wf.id] ?? EMPTY_HIDDEN_IDS}
onToggle={onToggleStepVisibility}
autoHideEmpty={workflowIdsWithAutoHideEmptySteps.includes(wf.id)}
onToggleAutoHide={onToggleAutoHideEmpty}
/>
}
>
@@ -302,6 +302,9 @@ function useSwimlaneData(
const workflows = useAppStore((state) => state.workflows.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const hiddenWorkflowStepIds = useAppStore((state) => state.userSettings.hiddenWorkflowStepIds);
const workflowIdsWithAutoHideEmptySteps = useAppStore(
(state) => state.userSettings.workflowIdsWithAutoHideEmptySteps,
);
const repositories = useMemo(
() => Object.values(repositoriesByWorkspace).flat() as Repository[],
@@ -320,27 +323,40 @@ function useSwimlaneData(
[workflowFilter, workflows, snapshots],
);
const getFilteredTasks = useCallback(
(wfId: string) => {
const getTaskProjection = useMemo(() => {
const cache = new Map<string, ReturnType<typeof projectWorkflowTasks>>();
return (wfId: string) => {
const cached = cache.get(wfId);
if (cached) return cached;
const hidden = hiddenWorkflowStepIds[wfId];
const hiddenSet = hidden && hidden.length > 0 ? new Set(hidden) : undefined;
return filterTasks(snapshots, wfId, repoFilter, {
const projection = projectWorkflowTasks(snapshots, wfId, repoFilter, {
searchQuery,
matchesPluginTaskFilters,
hiddenStepIds: hiddenSet,
});
},
[snapshots, repoFilter, searchQuery, matchesPluginTaskFilters, hiddenWorkflowStepIds],
cache.set(wfId, projection);
return projection;
};
}, [snapshots, repoFilter, searchQuery, matchesPluginTaskFilters, hiddenWorkflowStepIds]);
const getFilteredTasks = useCallback(
(wfId: string) => getTaskProjection(wfId).visibleTasks,
[getTaskProjection],
);
const getOccupancyTasks = useCallback(
(wfId: string) => getTaskProjection(wfId).occupancyTasks,
[getTaskProjection],
);
const hasLiveHiddenSteps = useCallback(
(workflowId: string) => {
const hidden = hiddenWorkflowStepIds[workflowId];
if (workflowIdsWithAutoHideEmptySteps.includes(workflowId)) return true;
if (!hidden || hidden.length === 0) return false;
const liveStepIds = new Set((snapshots[workflowId]?.steps ?? []).map((step) => step.id));
return hidden.some((id) => liveStepIds.has(id));
},
[hiddenWorkflowStepIds, snapshots],
[hiddenWorkflowStepIds, workflowIdsWithAutoHideEmptySteps, snapshots],
);
// The mobile board navigator is the only workflow switcher on the mobile
@@ -367,6 +383,7 @@ function useSwimlaneData(
allOrderedWorkflows,
workflowOptions,
getFilteredTasks,
getOccupancyTasks,
hasLiveHiddenSteps,
};
}
@@ -399,11 +416,13 @@ type WorkflowItemsProps = {
workflows: { id: string; name: string }[];
snapshots: Record<string, WorkflowSnapshotData>;
getFilteredTasks: (workflowId: string) => Task[];
getOccupancyTasks: (workflowId: string) => Task[];
ViewComponent: ComponentType<ViewContentProps>;
hideHeaders: boolean;
fillHeight: boolean;
isMobileKanban: boolean;
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
onToggleAutoHideEmpty: (workflowId: string) => void;
canSortWorkflows: boolean;
isCollapsed: (workflowId: string) => boolean;
toggleCollapse: (workflowId: string) => void;
@@ -415,11 +434,13 @@ function WorkflowItems({
workflows,
snapshots,
getFilteredTasks,
getOccupancyTasks,
ViewComponent,
hideHeaders,
fillHeight,
isMobileKanban,
onToggleStepVisibility,
onToggleAutoHideEmpty,
canSortWorkflows,
isCollapsed,
toggleCollapse,
@@ -436,6 +457,7 @@ function WorkflowItems({
wf={workflow}
snapshot={snapshot}
tasks={getFilteredTasks(workflow.id)}
occupancyTasks={getOccupancyTasks(workflow.id)}
ViewComponent={ViewComponent}
hideHeader={hideHeaders}
fillHeight={fillHeight && !collapsed}
@@ -458,6 +480,7 @@ function WorkflowItems({
onToggleMultiSelect={index === 0 ? containerProps.onToggleMultiSelect : undefined}
mobileWorkflowNavigation={mobileWorkflowNavigation}
onToggleStepVisibility={onToggleStepVisibility}
onToggleAutoHideEmpty={onToggleAutoHideEmpty}
/>
);
});
@@ -480,7 +503,7 @@ function usePublishMobileFocus(focusedWorkflowId: string | null) {
export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
const { viewMode, workflowFilter, searchQuery, selectedRepositoryIds = [] } = containerProps;
const { isMobile } = useResponsiveBreakpoint();
const { onToggleStepVisibility } = useKanbanDisplaySettings();
const { onToggleStepVisibility, onToggleAutoHideEmpty } = useKanbanDisplaySettings();
const { isCollapsed, toggleCollapse } = useSwimlaneCollapse();
const {
snapshots,
@@ -488,6 +511,7 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
orderedWorkflows,
workflowOptions,
getFilteredTasks,
getOccupancyTasks,
hasLiveHiddenSteps,
} = useSwimlaneData(
workflowFilter,
@@ -559,9 +583,11 @@ export function SwimlaneContainer(containerProps: SwimlaneContainerProps) {
workflows={renderedWorkflows}
snapshots={snapshots}
getFilteredTasks={getFilteredTasks}
getOccupancyTasks={getOccupancyTasks}
ViewComponent={ViewComponent}
hideHeaders={hideHeaders}
onToggleStepVisibility={onToggleStepVisibility}
onToggleAutoHideEmpty={onToggleAutoHideEmpty}
fillHeight={view.id === "kanban"}
isMobileKanban={isMobileKanban}
canSortWorkflows={canSortWorkflows}
@@ -8,6 +8,7 @@ import type { ViewContentProps } from "@/lib/kanban/view-registry";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
import { useTranslation } from "react-i18next";
import { areAllEmptyStepsAutoHidden } from "@/lib/kanban/auto-hide-empty-columns";
export function getGraph2DisplayState(
tasks: Task[],
@@ -25,6 +26,7 @@ export function getGraph2DisplayState(
export function SwimlaneGraph2Content({
workflowId,
steps,
moveTargetSteps,
tasks,
onPreviewTask,
onOpenTask,
@@ -46,6 +48,10 @@ export function SwimlaneGraph2Content({
() => getGraph2DisplayState(tasks, steps, t("kanban:needsReassignment")),
[tasks, steps, t],
);
const pipelineMoveTargetSteps = useMemo(() => {
const orphan = displaySteps.find((step) => step.id === ORPHAN_STEP_ID);
return orphan ? [...moveTargetSteps, orphan] : moveTargetSteps;
}, [displaySteps, moveTargetSteps]);
const sortedTasks = useMemo(
() =>
@@ -70,7 +76,18 @@ export function SwimlaneGraph2Content({
if (displayTasks.length === 0) {
return (
<div className="px-3 pb-3">
<div className="text-xs text-muted-foreground text-center py-4">{t("kanban:noTasks")}</div>
<div
className="text-xs text-muted-foreground text-center py-4"
data-testid={
areAllEmptyStepsAutoHidden(steps, moveTargetSteps)
? "pipeline-auto-hidden-empty-state"
: undefined
}
>
{areAllEmptyStepsAutoHidden(steps, moveTargetSteps)
? t("kanban:allEmptyStepsAutoHidden")
: t("kanban:noTasks")}
</div>
</div>
);
}
@@ -83,6 +100,7 @@ export function SwimlaneGraph2Content({
key={task.id}
task={task}
steps={displaySteps}
moveTargetSteps={pipelineMoveTargetSteps}
onMoveTask={handleMoveTask}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ORPHAN_STEP_ID } from "./swimlane-kanban-content";
import { getGraph2DisplayState } from "./swimlane-graph2-content";
import { getGraph2DisplayState, SwimlaneGraph2Content } from "./swimlane-graph2-content";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
@@ -18,6 +22,8 @@ const steps: WorkflowStep[] = [
{ id: "done", title: "Done", color: "#22c55e" },
];
afterEach(cleanup);
describe("getGraph2DisplayState", () => {
it("adds a Needs Reassignment pipeline step for tasks on deleted workflow steps", () => {
const { displayTasks, displaySteps } = getGraph2DisplayState(
@@ -42,3 +48,59 @@ describe("getGraph2DisplayState", () => {
expect(displayTasks.map((task) => task.workflowStepId)).toEqual(["todo", "done"]);
});
});
describe("SwimlaneGraph2Content auto-hidden steps", () => {
function renderPipeline(
visibleSteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
tasks: Task[],
) {
return render(
createElement(
StateProvider,
null,
createElement(TooltipProvider, {
delayDuration: 0,
children: createElement(SwimlaneGraph2Content, {
workflowId: "wf-1",
steps: visibleSteps,
moveTargetSteps,
tasks,
onPreviewTask: () => undefined,
onOpenTask: () => undefined,
onEditTask: () => undefined,
onDeleteTask: () => undefined,
}),
}),
),
);
}
it("keeps auto-hidden stages out of the rendered pipeline", () => {
const doing = { id: "doing", title: "Doing", color: "#3b82f6" };
renderPipeline(
[doing],
[
{ id: "todo", title: "Todo", color: "#64748b" },
doing,
{ id: "done", title: "Done", color: "#22c55e" },
],
[makeTask("task-1", "doing")],
);
expect(screen.getByText("Doing")).toBeTruthy();
expect(screen.queryByText("Todo")).toBeNull();
expect(screen.queryByText("Done")).toBeNull();
fireEvent.mouseEnter(screen.getByRole("button", { name: "Doing" }).parentElement!);
expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Move to Done" })).toBeTruthy();
});
it("explains when every empty pipeline stage is auto-hidden", () => {
renderPipeline([], steps, []);
expect(screen.getByTestId("pipeline-auto-hidden-empty-state")).toBeTruthy();
expect(screen.queryByText("No tasks")).toBeNull();
});
});
@@ -2,9 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
TouchSensor,
@@ -13,7 +11,6 @@ import {
} from "@dnd-kit/core";
import { KanbanColumn } from "@/components/kanban-column";
import { type Task } from "@/components/kanban-card";
import { KanbanCardPreview } from "@/components/kanban-card-preview";
import type { WorkflowStep } from "@/components/kanban-column";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
import { useTaskActions } from "@/hooks/use-task-actions";
@@ -22,44 +19,40 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MobileColumnTabs } from "./mobile-column-tabs";
import { SwipeableColumns } from "./swipeable-columns";
import { MobileDropTargets } from "./mobile-drop-targets";
import { KanbanDragSurface } from "./kanban-drag-surface";
import { getDesktopEmptyState } from "./desktop-auto-hidden-empty-state";
import { isOrphanMoveTarget, useOrphanDisplay } from "./swimlane-orphan-display";
export {
ORPHAN_STEP,
ORPHAN_STEP_ID,
isOrphanMoveTarget,
remapOrphanTasks,
} from "./swimlane-orphan-display";
import { AdaptiveDesktopKanban } from "./adaptive-desktop-kanban";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { MobileWorkflowNavigation } from "@/lib/kanban/view-registry";
import { resolveMobileColumnIndex } from "@/lib/kanban/mobile-column-index";
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
import { countAdmittedTasks } from "@/lib/kanban/wip-limit";
import { areAllEmptyStepsAutoHidden } from "@/lib/kanban/auto-hide-empty-columns";
import {
type KanbanExternalLinkAvailability,
useKanbanExternalLinkAvailability,
} from "@/components/kanban-external-link-availability";
getDragDisplaySteps,
getTemporaryStepIds,
useKanbanDragScrollAnchor,
} from "@/hooks/domains/kanban/use-kanban-drag-scroll-anchor";
import {
type SharedKanbanLayoutProps,
useSharedKanbanLayoutProps,
} from "@/hooks/domains/kanban/use-shared-kanban-layout-props";
import { cn } from "@kandev/ui/lib/utils";
import { useKanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
/**
* Sentinel step ID used to collect tasks whose workflow_step_id no longer
* matches any rendered column. Tasks remapped here are visible as a
* "Needs Reassignment" fallback column so they are never silently hidden.
*/
export const ORPHAN_STEP_ID = "__kandev_orphan__";
export const ORPHAN_STEP: WorkflowStep = {
id: ORPHAN_STEP_ID,
title: "",
color: "#f59e0b",
};
/**
* The "Needs Reassignment" column is a display-only fallback, not a real
* workflow step — it must never be offered as a manual move destination
* (drag-and-drop, "Move to" menus, or Pipeline navigation).
*/
export function isOrphanMoveTarget(targetStepId: string): boolean {
return targetStepId === ORPHAN_STEP_ID;
}
export type SwimlaneKanbanContentProps = {
workflowId: string;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
@@ -174,6 +167,40 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban
};
}
function useSwimlaneKanbanPresentationDnd({
displaySteps,
moveTargetSteps,
isMobile,
...dndOptions
}: SwimlaneKanbanDndOptions & {
displaySteps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
isMobile: boolean;
}) {
const dnd = useSwimlaneKanbanDnd(dndOptions);
const renderedSteps = useMemo(
() => getDragDisplaySteps(displaySteps, moveTargetSteps, !!dnd.activeTask, isMobile),
[displaySteps, moveTargetSteps, dnd.activeTask, isMobile],
);
const { boardRef, handleAnchoredDragStart } = useKanbanDragScrollAnchor({
tasks: dndOptions.tasks,
activeTask: dnd.activeTask,
renderedSteps,
onDragStart: dnd.handleDragStart,
});
const temporaryStepIds = useMemo(
() => getTemporaryStepIds(displaySteps, moveTargetSteps),
[displaySteps, moveTargetSteps],
);
return {
...dnd,
renderedSteps,
temporaryStepIds,
boardRef,
handleDragStart: handleAnchoredDragStart,
};
}
function useMobileColumnIndex(workflowId: string, steps: WorkflowStep[], tasks: Task[]) {
const storedStepId = useAppStore(
(state) => state.mobileKanban.activeStepIdByWorkflowId[workflowId],
@@ -203,49 +230,6 @@ function useMobileColumnIndex(workflowId: string, steps: WorkflowStep[], tasks:
return { activeIndex, setActiveIndex };
}
/**
* remapOrphanTasks re-keys any task whose workflowStepId matches no step in
* `stepIds` to `orphanStepId`. Returns the remapped task list and whether
* any orphans were found. Pure function — safe to call outside React.
*/
export function remapOrphanTasks(
tasks: Task[],
stepIds: Set<string>,
orphanStepId: string,
): { tasks: Task[]; hasOrphans: boolean } {
let hasOrphans = false;
const remapped = tasks.map((t) => {
if (!t.workflowStepId || stepIds.has(t.workflowStepId)) return t;
hasOrphans = true;
return { ...t, workflowStepId: orphanStepId };
});
return { tasks: remapped, hasOrphans };
}
/**
* useOrphanDisplay remaps tasks with an unknown workflowStepId to the sentinel
* ORPHAN_STEP so they appear in a visible fallback column instead of being
* silently dropped from the board.
*
* Returns:
* displayTasks – all tasks, with orphaned ones keyed to ORPHAN_STEP_ID
* displaySteps – original steps plus ORPHAN_STEP when orphans are present
*/
function useOrphanDisplay(
tasks: Task[],
steps: WorkflowStep[],
): { displayTasks: Task[]; displaySteps: WorkflowStep[] } {
const { t } = useTranslation("kanban");
return useMemo(() => {
const stepIds = new Set(steps.map((s) => s.id));
const { tasks: displayTasks, hasOrphans } = remapOrphanTasks(tasks, stepIds, ORPHAN_STEP_ID);
const displaySteps = hasOrphans
? [...steps, { ...ORPHAN_STEP, title: t("kanban:needsReassignment") }]
: steps;
return { displayTasks, displaySteps };
}, [tasks, steps, t]);
}
function useTasksByStep(tasks: Task[]) {
return useCallback(
(stepId: string) =>
@@ -292,6 +276,7 @@ function MobileKanbanLayout({
}, [steps, tasks]);
const currentStepId = steps[activeIndex]?.id ?? null;
const allStepsAutoHidden = areAllEmptyStepsAutoHidden(steps, moveTargetSteps);
return (
<div
@@ -305,14 +290,19 @@ function MobileKanbanLayout({
taskCounts={taskCounts}
onColumnChange={onIndexChange}
workflowNavigation={mobileWorkflowNavigation}
allStepsAutoHidden={allStepsAutoHidden}
/>
)}
{steps.length === 0 ? (
<div
className="mx-4 my-3 flex flex-1 items-center justify-center rounded-xl border border-dashed border-border/70 px-6 text-center text-sm text-muted-foreground"
data-testid="mobile-kanban-no-steps"
data-testid={
allStepsAutoHidden ? "kanban-auto-hidden-empty-state" : "mobile-kanban-no-steps"
}
>
{t("kanban:noStepsConfiguredChooseAnotherWorkflow")}
{allStepsAutoHidden
? t("kanban:allEmptyStepsAutoHidden")
: t("kanban:noStepsConfiguredChooseAnotherWorkflow")}
</div>
) : (
<SwipeableColumns
@@ -347,29 +337,6 @@ function MobileKanbanLayout({
);
}
type SharedKanbanLayoutProps = {
steps: WorkflowStep[];
// Real workflow steps only (excludes the synthetic "Needs Reassignment"
// sentinel) — used wherever a step is offered as a move destination
// (move menus, drop targets), since that sentinel is display-only.
moveTargetSteps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onArchiveTask?: (task: Task) => void;
moveTaskToStep: (task: Task, targetStepId: string) => Promise<void>;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
archivingTaskId?: string | null;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
};
function TabletKanbanLayout({
steps,
moveTargetSteps,
@@ -388,6 +355,7 @@ function TabletKanbanLayout({
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
temporaryStepIds,
}: SharedKanbanLayoutProps) {
const getTasksForStep = useTasksByStep(tasks);
@@ -397,7 +365,14 @@ function TabletKanbanLayout({
data-testid="tablet-kanban-layout"
>
{steps.map((step) => (
<div key={step.id} className="h-full min-h-0 w-[calc(50%-4px)] flex-shrink-0 snap-start">
<div
key={step.id}
data-kanban-step-id={step.id}
className={cn(
"h-full min-h-0 w-[calc(50%-4px)] flex-shrink-0 snap-start",
temporaryStepIds.has(step.id) && "opacity-70",
)}
>
<KanbanColumn
step={step}
tasks={getTasksForStep(step.id)}
@@ -442,33 +417,38 @@ function DesktopKanbanLayout({
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
temporaryStepIds,
isDragging,
}: SharedKanbanLayoutProps) {
const getTasksForStep = useTasksByStep(tasks);
return (
<AdaptiveDesktopKanban
steps={steps}
isDragging={isDragging}
renderColumn={(step) => (
<KanbanColumn
step={step}
tasks={getTasksForStep(step.id)}
presentation="desktop"
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
onMoveTask={moveTaskToStep}
steps={moveTargetSteps}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
showMaximizeButton={showMaximizeButton}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
/>
<div className={cn("h-full", temporaryStepIds.has(step.id) && "opacity-70")}>
<KanbanColumn
step={step}
tasks={getTasksForStep(step.id)}
presentation="desktop"
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
onMoveTask={moveTaskToStep}
steps={moveTargetSteps}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
showMaximizeButton={showMaximizeButton}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
/>
</div>
)}
/>
);
@@ -515,6 +495,7 @@ function renderKanbanLayout({
export function SwimlaneKanbanContent({
workflowId,
steps,
moveTargetSteps,
tasks,
onPreviewTask,
onOpenTask,
@@ -535,8 +516,6 @@ export function SwimlaneKanbanContent({
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId);
// Remap tasks with a dead workflowStepId to the orphan sentinel so they
// always appear in a visible column rather than being silently dropped.
const { displayTasks, displaySteps } = useOrphanDisplay(tasks, steps);
const { activeIndex, setActiveIndex } = useMobileColumnIndex(
@@ -544,54 +523,35 @@ export function SwimlaneKanbanContent({
displaySteps,
displayTasks,
);
const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } =
useSwimlaneKanbanDnd({ tasks: displayTasks, workflowId, onMoveError });
const drag = useSwimlaneKanbanPresentationDnd({
tasks: displayTasks,
workflowId,
onMoveError,
displaySteps,
moveTargetSteps,
isMobile,
});
const sharedProps = useSharedKanbanLayoutProps({
drag,
moveTargetSteps,
displayTasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onArchiveTask,
showMaximizeButton,
deletingTaskId,
archivingTaskId,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
});
// Memoized so the layout components don't re-render from a fresh props object
// on every parent render. Declared before the early return to keep hook order
// stable.
const sharedProps = useMemo(
() => ({
steps: displaySteps,
moveTargetSteps: steps,
tasks: displayTasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onArchiveTask,
moveTaskToStep,
showMaximizeButton,
deletingTaskId,
archivingTaskId,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}),
[
displaySteps,
steps,
displayTasks,
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onArchiveTask,
moveTaskToStep,
showMaximizeButton,
deletingTaskId,
archivingTaskId,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
],
);
if (displaySteps.length === 0 && !isMobile) return null;
const desktopEmptyState = getDesktopEmptyState(isMobile, displaySteps, moveTargetSteps);
if (desktopEmptyState !== undefined) return desktopEmptyState;
const layoutContent = renderKanbanLayout({
isMobile,
@@ -599,21 +559,19 @@ export function SwimlaneKanbanContent({
sharedProps,
activeIndex,
setActiveIndex,
activeTask,
activeTask: drag.activeTask,
mobileWorkflowNavigation,
});
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{layoutContent}
<DragOverlay dropAnimation={null}>
{activeTask ? <KanbanCardPreview task={activeTask} /> : null}
</DragOverlay>
</DndContext>
<KanbanDragSurface
sensors={drag.sensors}
onDragStart={drag.handleDragStart}
onDragEnd={drag.handleDragEnd}
onDragCancel={drag.handleDragCancel}
layoutContent={layoutContent}
activeTask={drag.activeTask}
boardRef={drag.boardRef}
/>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
export const ORPHAN_STEP_ID = "__kandev_orphan__";
export const ORPHAN_STEP: WorkflowStep = {
id: ORPHAN_STEP_ID,
title: "",
color: "#f59e0b",
};
export function isOrphanMoveTarget(targetStepId: string): boolean {
return targetStepId === ORPHAN_STEP_ID;
}
export function remapOrphanTasks(
tasks: Task[],
stepIds: Set<string>,
orphanStepId: string,
): { tasks: Task[]; hasOrphans: boolean } {
let hasOrphans = false;
const remapped = tasks.map((task) => {
if (!task.workflowStepId || stepIds.has(task.workflowStepId)) return task;
hasOrphans = true;
return { ...task, workflowStepId: orphanStepId };
});
return { tasks: remapped, hasOrphans };
}
export function useOrphanDisplay(
tasks: Task[],
steps: WorkflowStep[],
): { displayTasks: Task[]; displaySteps: WorkflowStep[] } {
const { t } = useTranslation("kanban");
return useMemo(() => {
const stepIds = new Set(steps.map((step) => step.id));
const { tasks: displayTasks, hasOrphans } = remapOrphanTasks(tasks, stepIds, ORPHAN_STEP_ID);
const displaySteps = hasOrphans
? [...steps, { ...ORPHAN_STEP, title: t("kanban:needsReassignment") }]
: steps;
return { displayTasks, displaySteps };
}, [tasks, steps, t]);
}
+1
View File
@@ -1103,6 +1103,7 @@ export class ApiClient {
tasks_list_group?: string;
task_create_last_used?: TaskCreateLastUsedApi;
kanban_hidden_step_ids?: Record<string, string[]>;
workflow_ids_with_auto_hide_empty_steps?: string[];
}): Promise<void> {
await this.request("PATCH", "/api/v1/user/settings", settings);
}
+2
View File
@@ -6,6 +6,7 @@ export class MobileKanbanPage {
readonly mobileSearchBar: Locator;
readonly mobileSearchToggle: Locator;
readonly mobileMenuButton: Locator;
readonly menuCard: Locator;
readonly swimlaneContainer: Locator;
readonly boardNavigator: Locator;
@@ -15,6 +16,7 @@ export class MobileKanbanPage {
this.mobileSearchBar = page.getByTestId("mobile-search-bar");
this.mobileSearchToggle = page.getByTestId("mobile-search-toggle");
this.mobileMenuButton = page.getByRole("button", { name: "Open menu" });
this.menuCard = page.getByTestId("mobile-home-menu-card");
this.swimlaneContainer = page.getByTestId("swimlane-container");
this.boardNavigator = page.getByTestId("mobile-board-navigator");
}
@@ -0,0 +1,122 @@
import { type Locator, type Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
const TASK_TITLE = "Auto-hide drag source";
async function openColumnsMenu(page: Page, workflowId: string) {
const trigger = page.getByTestId(`columns-menu-${workflowId}`);
await trigger.click();
await expect(trigger).toHaveAttribute("data-state", "open");
}
async function closeColumnsMenu(page: Page, workflowId: string) {
const trigger = page.getByTestId(`columns-menu-${workflowId}`);
if ((await trigger.getAttribute("data-state")) === "open") {
await page.keyboard.press("Escape");
}
await expect(trigger).not.toHaveAttribute("data-state", "open");
}
async function beginPointerDrag(page: Page, card: Locator) {
const box = await card.boundingBox();
if (!box) throw new Error("drag source has no layout box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + 20, y, { steps: 4 });
}
test("auto-hides empty columns without changing drag destinations", async ({
testPage,
apiClient,
seedData,
}) => {
await testPage.setViewportSize({ width: 1440, height: 900 });
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Auto-hide E2E");
const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Source", 0, {
is_start_step: true,
});
const autoHiddenStep = await apiClient.createWorkflowStep(workflow.id, "Auto hidden", 1);
const manuallyHiddenStep = await apiClient.createWorkflowStep(workflow.id, "Manual hidden", 2);
const task = await apiClient.createTask(seedData.workspaceId, TASK_TITLE, {
workflow_id: workflow.id,
workflow_step_id: sourceStep.id,
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
kanban_hidden_step_ids: {},
workflow_ids_with_auto_hide_empty_steps: [],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
// Compatibility: the feature is off until this workflow opts in.
await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible();
await expect(kanban.columnByStepId(autoHiddenStep.id)).toBeVisible();
await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toBeVisible();
await openColumnsMenu(testPage, workflow.id);
const autoHideToggle = testPage.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`);
await expect(autoHideToggle).toHaveAttribute("aria-checked", "false");
await autoHideToggle.click();
await expect(autoHideToggle).toHaveAttribute("aria-checked", "true");
await testPage.getByTestId(`columns-menu-step-${manuallyHiddenStep.id}`).click();
await closeColumnsMenu(testPage, workflow.id);
await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible();
await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0);
await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0);
await testPage.reload();
await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible();
await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0);
await openColumnsMenu(testPage, workflow.id);
await expect(autoHideToggle).toHaveAttribute("aria-checked", "true");
await closeColumnsMenu(testPage, workflow.id);
// Occupancy is computed before text search: filtering the card does not
// make its source column disappear.
const search = testPage.getByTestId("kanban-header-search").getByRole("textbox");
await search.fill("does not match the source task");
await expect(kanban.taskCard(task.id)).toHaveCount(0);
await expect(kanban.columnByStepId(sourceStep.id)).toBeVisible();
await search.fill("");
await expect(kanban.taskCard(task.id)).toBeVisible();
await beginPointerDrag(testPage, kanban.taskCard(task.id));
await expect(kanban.columnByStepId(autoHiddenStep.id)).toBeVisible();
await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0);
await testPage.keyboard.press("Escape");
await testPage.mouse.up();
await expect(kanban.columnByStepId(autoHiddenStep.id)).toHaveCount(0);
await expect
.poll(async () => (await apiClient.getTask(task.id)).workflow_step_id)
.toBe(sourceStep.id);
await beginPointerDrag(testPage, kanban.taskCard(task.id));
const destination = kanban.columnByStepId(autoHiddenStep.id);
await expect(destination).toBeVisible();
const destinationBox = await destination.boundingBox();
if (!destinationBox) throw new Error("auto-hidden destination has no layout box");
await testPage.mouse.move(
destinationBox.x + destinationBox.width / 2,
destinationBox.y + Math.min(160, destinationBox.height / 2),
{ steps: 12 },
);
await testPage.mouse.up();
await expect
.poll(async () => (await apiClient.getTask(task.id)).workflow_step_id)
.toBe(autoHiddenStep.id);
await expect(kanban.taskCardInColumn(TASK_TITLE, autoHiddenStep.id)).toBeVisible();
const settledDestinationBox = await destination.boundingBox();
if (!settledDestinationBox) throw new Error("occupied destination has no layout box");
const viewportWidth = await testPage.evaluate(() => window.innerWidth);
expect(settledDestinationBox.x).toBeLessThan(viewportWidth);
expect(settledDestinationBox.x + settledDestinationBox.width).toBeGreaterThan(0);
await expect(kanban.columnByStepId(manuallyHiddenStep.id)).toHaveCount(0);
});
@@ -0,0 +1,153 @@
import { type CDPSession, type Locator, type Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import { dwell } from "../../helpers/causal-waits";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
const TASK_TITLE = "Mobile auto-hide drag source";
async function openMobileMenu(mobile: MobileKanbanPage) {
await mobile.mobileMenuButton.click();
await mobile.menuCard.waitFor({ state: "visible" });
}
async function closeMobileMenu(page: Page, mobile: MobileKanbanPage) {
await expect(async () => {
if ((await mobile.menuCard.count()) > 0) {
await page.keyboard.press("Escape");
}
await expect(mobile.menuCard).toHaveCount(0, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
async function openColumnsMenu(
page: Page,
mobile: MobileKanbanPage,
workflowId: string,
): Promise<Locator> {
const trigger = mobile.menuCard.getByTestId(`columns-menu-${workflowId}`);
await trigger.click();
await expect(trigger).toHaveAttribute("data-state", "open");
const menu = page.locator('[role="menu"]:visible');
await expect(menu).toBeVisible();
return menu;
}
async function touchDragToTarget(page: Page, card: Locator, target: Locator) {
const box = await card.boundingBox();
if (!box) throw new Error("mobile drag source has no layout box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
const cdp: CDPSession = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x, y, id: 1 }],
});
await dwell(
page,
350,
"library-timer",
"dnd-kit's TouchSensor needs its 250ms activation delay before the move target appears",
);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: x + 14, y, id: 1 }],
});
await expect(target).toBeVisible();
await expectMinTouchTarget(target);
const targetBox = await target.boundingBox();
if (!targetBox) throw new Error("mobile drop target has no layout box");
const targetX = targetBox.x + targetBox.width / 2;
const targetY = targetBox.y + targetBox.height / 2;
for (let step = 1; step <= 10; step += 1) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [
{
x: x + ((targetX - x) * step) / 10,
y: y + ((targetY - y) * step) / 10,
id: 1,
},
],
});
}
await expect(target).toHaveClass(/border-primary/);
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
}
async function expectMinTouchTarget(locator: Locator) {
await expect(async () => {
const box = await locator.boundingBox();
if (!box) throw new Error("touch target has no layout box");
expect(box.height).toBeGreaterThanOrEqual(44);
}).toPass({ timeout: 5_000 });
}
test("keeps auto-hide scoped and restores mobile move targets during drag", async ({
testPage,
apiClient,
seedData,
}) => {
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Mobile auto-hide E2E");
const sourceStep = await apiClient.createWorkflowStep(workflow.id, "Source", 0, {
is_start_step: true,
});
const hiddenDestination = await apiClient.createWorkflowStep(workflow.id, "Hidden target", 1);
const otherWorkflow = await apiClient.createWorkflow(seedData.workspaceId, "Unaffected workflow");
const otherStart = await apiClient.createWorkflowStep(otherWorkflow.id, "Other source", 0, {
is_start_step: true,
});
const otherEmpty = await apiClient.createWorkflowStep(otherWorkflow.id, "Other empty", 1);
const task = await apiClient.createTask(seedData.workspaceId, TASK_TITLE, {
workflow_id: workflow.id,
workflow_step_id: sourceStep.id,
});
await apiClient.createTask(seedData.workspaceId, "Unaffected task", {
workflow_id: otherWorkflow.id,
workflow_step_id: otherStart.id,
});
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: workflow.id,
kanban_hidden_step_ids: {},
workflow_ids_with_auto_hide_empty_steps: [],
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await openMobileMenu(mobile);
await expect(mobile.menuCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0);
const columnsMenu = await openColumnsMenu(testPage, mobile, workflow.id);
const autoHideToggle = columnsMenu.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`);
await expectMinTouchTarget(autoHideToggle);
await autoHideToggle.click();
await expect(autoHideToggle).toHaveAttribute("aria-checked", "true");
await testPage.keyboard.press("Escape");
await closeMobileMenu(testPage, mobile);
await expect(testPage.getByTestId(`kanban-column-${hiddenDestination.id}`)).toHaveCount(0);
const recoveredTarget = testPage.getByTestId(`mobile-drop-target-${hiddenDestination.id}`);
await touchDragToTarget(testPage, mobile.taskCard(task.id), recoveredTarget);
const overflow = await testPage.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
await expect(recoveredTarget).toHaveCount(0);
await expect
.poll(async () => (await apiClient.getTask(task.id)).workflow_step_id)
.toBe(hiddenDestination.id);
await expect(testPage.getByTestId(`kanban-column-${hiddenDestination.id}`)).toBeVisible();
const persistedSettings = (await apiClient.getUserSettings()).settings;
expect(persistedSettings.workflow_ids_with_auto_hide_empty_steps).toEqual([workflow.id]);
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: otherWorkflow.id,
});
await testPage.reload();
await mobile.mobileKanbanLayout().waitFor({ state: "visible" });
await expect(mobile.boardNavigator).toContainText("Unaffected workflow");
await expect(testPage.getByTestId(`kanban-column-${otherEmpty.id}`)).toHaveCount(1);
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
getDragDisplaySteps,
getPreservedScrollLeft,
getRenderedStepKey,
getTemporaryStepIds,
} from "./use-kanban-drag-scroll-anchor";
import type { WorkflowStep } from "@/components/kanban-column";
describe("getPreservedScrollLeft", () => {
it("keeps the source at the same viewport position when columns expand", () => {
expect(getPreservedScrollLeft(840, 72, 352)).toBe(1120);
});
it("preserves the drag-time scroll when the source column disappears after drop", () => {
expect(getPreservedScrollLeft(1400, 72, undefined)).toBe(1400);
});
});
describe("getRenderedStepKey", () => {
it("is stable when a render recreates the same ordered steps", () => {
const first = [{ id: "todo" }, { id: "done" }] as WorkflowStep[];
const second = [{ id: "todo" }, { id: "done" }] as WorkflowStep[];
expect(getRenderedStepKey(first)).toBe(getRenderedStepKey(second));
});
});
describe("getDragDisplaySteps", () => {
const visible = [{ id: "review", title: "Review" }] as WorkflowStep[];
const targets = [
{ id: "backlog", title: "Backlog" },
{ id: "review", title: "Review" },
] as WorkflowStep[];
it("reveals every real target during desktop drag", () => {
expect(getDragDisplaySteps(visible, targets, true, false)).toBe(targets);
});
it("keeps a display-only orphan sentinel after every real drag target", () => {
const orphan = { id: "__orphan__", title: "Needs Reassignment" } as WorkflowStep;
expect(getDragDisplaySteps([...visible, orphan], targets, true, false)).toEqual([
...targets,
orphan,
]);
});
it("keeps mobile columns stable because mobile has separate drop targets", () => {
expect(getDragDisplaySteps(visible, targets, true, true)).toBe(visible);
});
it("marks only temporarily revealed real steps", () => {
expect([...getTemporaryStepIds(visible, targets)]).toEqual(["backlog"]);
});
});
@@ -0,0 +1,129 @@
"use client";
import { type RefObject, useCallback, useLayoutEffect, useRef } from "react";
import type { DragStartEvent } from "@dnd-kit/core";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
type DragScrollAnchor = {
stepId: string;
viewportLeft: number;
};
function findStepElement(board: HTMLDivElement, stepId: string): HTMLElement | undefined {
return Array.from(board.querySelectorAll<HTMLElement>("[data-kanban-step-id]")).find(
(element) => element.dataset.kanbanStepId === stepId,
);
}
function findScrollWindow(board: HTMLDivElement): HTMLElement | null {
return board.querySelector<HTMLElement>(
'[data-testid="desktop-kanban-scroll-window"], [data-testid="tablet-kanban-layout"]',
);
}
export function getPreservedScrollLeft(
currentScrollLeft: number,
anchorViewportLeft: number,
currentAnchorLeft: number | undefined,
): number {
if (currentAnchorLeft == null) return currentScrollLeft;
return currentScrollLeft + currentAnchorLeft - anchorViewportLeft;
}
export function getRenderedStepKey(steps: WorkflowStep[]): string {
return steps.map((step) => step.id).join("\0");
}
export function getDragDisplaySteps(
displaySteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
isDragging: boolean,
isMobile: boolean,
): WorkflowStep[] {
if (isMobile || !isDragging) return displaySteps;
const moveTargetIds = new Set(moveTargetSteps.map((step) => step.id));
const displayOnlyStep = displaySteps.find((step) => !moveTargetIds.has(step.id));
return displayOnlyStep ? [...moveTargetSteps, displayOnlyStep] : moveTargetSteps;
}
export function getTemporaryStepIds(
displaySteps: WorkflowStep[],
moveTargetSteps: WorkflowStep[],
): Set<string> {
const visibleStepIds = new Set(displaySteps.map((step) => step.id));
return new Set(
moveTargetSteps.filter((step) => !visibleStepIds.has(step.id)).map((step) => step.id),
);
}
export function useKanbanDragScrollAnchor({
tasks,
activeTask,
renderedSteps,
onDragStart,
}: {
tasks: Task[];
activeTask: Task | null;
renderedSteps: WorkflowStep[];
onDragStart: (event: DragStartEvent) => void;
}): {
boardRef: RefObject<HTMLDivElement | null>;
handleAnchoredDragStart: (event: DragStartEvent) => void;
} {
const boardRef = useRef<HTMLDivElement | null>(null);
const anchorRef = useRef<DragScrollAnchor | null>(null);
const restoreFrameRef = useRef<number | null>(null);
const handleAnchoredDragStart = useCallback(
(event: DragStartEvent) => {
const task = tasks.find((candidate) => candidate.id === event.active.id);
const board = boardRef.current;
const scrollWindow = board ? findScrollWindow(board) : null;
const stepElement =
board && task?.workflowStepId ? findStepElement(board, task.workflowStepId) : undefined;
if (scrollWindow && stepElement && task?.workflowStepId) {
anchorRef.current = {
stepId: task.workflowStepId,
viewportLeft: stepElement.getBoundingClientRect().left,
};
}
onDragStart(event);
},
[tasks, onDragStart],
);
const renderedStepKey = getRenderedStepKey(renderedSteps);
useLayoutEffect(() => {
const anchor = anchorRef.current;
const board = boardRef.current;
if (!anchor || !board) return;
const scrollWindow = findScrollWindow(board);
if (!scrollWindow) return;
const stepElement = findStepElement(board, anchor.stepId);
const desiredScrollLeft = getPreservedScrollLeft(
scrollWindow.scrollLeft,
anchor.viewportLeft,
stepElement?.getBoundingClientRect().left,
);
scrollWindow.scrollLeft = desiredScrollLeft;
if (!activeTask) {
if (restoreFrameRef.current != null) cancelAnimationFrame(restoreFrameRef.current);
restoreFrameRef.current = requestAnimationFrame(() => {
if (boardRef.current) {
const liveScrollWindow = findScrollWindow(boardRef.current);
if (liveScrollWindow) liveScrollWindow.scrollLeft = desiredScrollLeft;
}
anchorRef.current = null;
});
}
return () => {
if (restoreFrameRef.current != null) cancelAnimationFrame(restoreFrameRef.current);
};
}, [activeTask, renderedStepKey]);
return { boardRef, handleAnchoredDragStart };
}
@@ -0,0 +1,91 @@
import { useMemo } from "react";
import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column";
import type { KanbanExternalLinkAvailability } from "@/components/kanban-external-link-availability";
export type SharedKanbanLayoutProps = {
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onArchiveTask?: (task: Task) => void;
moveTaskToStep: (task: Task, targetStepId: string) => Promise<void>;
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
archivingTaskId?: string | null;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
temporaryStepIds: Set<string>;
isDragging: boolean;
};
type SharedLayoutDragState = {
renderedSteps: WorkflowStep[];
moveTaskToStep: SharedKanbanLayoutProps["moveTaskToStep"];
temporaryStepIds: Set<string>;
activeTask: Task | null;
};
type SharedLayoutHookOptions = Omit<
SharedKanbanLayoutProps,
"steps" | "tasks" | "moveTaskToStep" | "temporaryStepIds" | "isDragging"
> & {
drag: SharedLayoutDragState;
displayTasks: Task[];
};
export function useSharedKanbanLayoutProps(
options: SharedLayoutHookOptions,
): SharedKanbanLayoutProps {
const { drag, displayTasks, externalLinkAvailability } = options;
return useMemo(
() => ({
steps: drag.renderedSteps,
moveTargetSteps: options.moveTargetSteps,
tasks: displayTasks,
onPreviewTask: options.onPreviewTask,
onOpenTask: options.onOpenTask,
onEditTask: options.onEditTask,
onDeleteTask: options.onDeleteTask,
onArchiveTask: options.onArchiveTask,
moveTaskToStep: drag.moveTaskToStep,
showMaximizeButton: options.showMaximizeButton,
deletingTaskId: options.deletingTaskId,
archivingTaskId: options.archivingTaskId,
selectedIds: options.selectedIds,
onToggleSelect: options.onToggleSelect,
onSelectRange: options.onSelectRange,
isMultiSelectMode: options.isMultiSelectMode,
externalLinkAvailability,
temporaryStepIds: drag.temporaryStepIds,
isDragging: !!drag.activeTask,
}),
[
drag.renderedSteps,
drag.moveTaskToStep,
drag.temporaryStepIds,
drag.activeTask,
options.moveTargetSteps,
displayTasks,
options.onPreviewTask,
options.onOpenTask,
options.onEditTask,
options.onDeleteTask,
options.onArchiveTask,
options.showMaximizeButton,
options.deletingTaskId,
options.archivingTaskId,
options.selectedIds,
options.onToggleSelect,
options.onSelectRange,
options.isMultiSelectMode,
externalLinkAvailability,
],
);
}
@@ -108,6 +108,7 @@ function makeUnloadedSettings(): UserSettingsState {
lspServerConfigs: {},
lspStatusLocation: "toolbar",
hiddenWorkflowStepIds: {},
workflowIdsWithAutoHideEmptySteps: [],
loaded: false,
};
}
+21 -8
View File
@@ -14,6 +14,7 @@ type UserSettingsFields = {
workflowId: string | null;
repositoryIds: string[];
hiddenWorkflowStepIds?: Record<string, string[]>;
workflowIdsWithAutoHideEmptySteps?: string[];
};
type CommitSettingsFn = (
@@ -29,6 +30,8 @@ function baseSettingsPayload(settings: UserSettingsFields): UserSettingsFields {
workspaceId: settings.workspaceId,
workflowId: settings.workflowId,
repositoryIds: settings.repositoryIds,
hiddenWorkflowStepIds: settings.hiddenWorkflowStepIds,
workflowIdsWithAutoHideEmptySteps: settings.workflowIdsWithAutoHideEmptySteps,
};
}
@@ -131,7 +134,20 @@ function useStepVisibilityHandlers(
},
[commitSettings, userSettings],
);
return { eligibleWorkflows, onToggleStepVisibility };
const onToggleAutoHideEmpty = useCallback(
(workflowId: string) => {
const current = userSettings.workflowIdsWithAutoHideEmptySteps ?? [];
const next = current.includes(workflowId)
? current.filter((id) => id !== workflowId)
: [...current, workflowId];
commitSettings({
...baseSettingsPayload(userSettings),
workflowIdsWithAutoHideEmptySteps: next,
});
},
[commitSettings, userSettings],
);
return { eligibleWorkflows, onToggleStepVisibility, onToggleAutoHideEmpty };
}
/**
@@ -188,13 +204,8 @@ export function useKanbanDisplaySettings() {
[commitSettings, userSettings],
);
const { eligibleWorkflows, onToggleStepVisibility } = useStepVisibilityHandlers(
workflows,
snapshots,
userSettings,
commitSettings,
activeWorkflowId,
);
const { eligibleWorkflows, onToggleStepVisibility, onToggleAutoHideEmpty } =
useStepVisibilityHandlers(workflows, snapshots, userSettings, commitSettings, activeWorkflowId);
const { effectiveView, onViewModeChange } = useViewModeChange();
@@ -213,12 +224,14 @@ export function useKanbanDisplaySettings() {
eligibleWorkflows,
snapshots,
hiddenWorkflowStepIds: userSettings.hiddenWorkflowStepIds ?? {},
workflowIdsWithAutoHideEmptySteps: userSettings.workflowIdsWithAutoHideEmptySteps ?? [],
onWorkspaceChange,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
onToggleTasksListShowDetails,
onToggleStepVisibility,
onToggleAutoHideEmpty,
onViewModeChange,
};
}
@@ -92,6 +92,8 @@ function buildColumnsSection({
snapshots,
hiddenWorkflowStepIds,
onToggleStepVisibility,
workflowIdsWithAutoHideEmptySteps,
onToggleAutoHideEmpty,
}: {
isMobile: boolean;
currentPage: "kanban" | "tasks";
@@ -100,6 +102,8 @@ function buildColumnsSection({
snapshots: Record<string, { steps?: Array<{ id: string; title: string; position: number }> }>;
hiddenWorkflowStepIds: Record<string, string[]>;
onToggleStepVisibility: (workflowId: string, stepId: string) => void;
workflowIdsWithAutoHideEmptySteps: string[];
onToggleAutoHideEmpty: (workflowId: string) => void;
}): MobileColumnsSection | null {
if (!isMobile || currentPage !== "kanban" || !focusedWorkflowId) return null;
const workflow = eligibleWorkflows.find((item) => item.id === focusedWorkflowId);
@@ -110,6 +114,8 @@ function buildColumnsSection({
steps: snapshots[workflow.id]?.steps ?? [],
hiddenStepIds: hiddenWorkflowStepIds[workflow.id] ?? [],
onToggle: onToggleStepVisibility,
autoHideEmpty: workflowIdsWithAutoHideEmptySteps.includes(workflow.id),
onToggleAutoHide: onToggleAutoHideEmpty,
};
}
@@ -145,6 +151,8 @@ export function useMobileMenuSheetState({
snapshots,
hiddenWorkflowStepIds,
onToggleStepVisibility,
workflowIdsWithAutoHideEmptySteps,
onToggleAutoHideEmpty,
} = useKanbanDisplaySettings();
const focusedWorkflowId = useAppStore((state) => state.mobileKanban.focusedWorkflowId);
const columnsSection = buildColumnsSection({
@@ -155,6 +163,8 @@ export function useMobileMenuSheetState({
snapshots,
hiddenWorkflowStepIds,
onToggleStepVisibility,
workflowIdsWithAutoHideEmptySteps,
onToggleAutoHideEmpty,
});
const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all");
const viewValue = currentPage === "tasks" ? "list" : effectiveTaskListingView;
@@ -27,6 +27,18 @@ function settingsWithHidden(hiddenWorkflowStepIds: Record<string, string[]>): Us
} as unknown as UserSettingsState;
}
function settingsWithAutoHide(workflowIdsWithAutoHideEmptySteps: string[]): UserSettingsState {
return {
loaded: true,
workspaceId: "workspace-1",
workflowId: null,
repositoryIds: [],
tasksListShowDetails: false,
hiddenWorkflowStepIds: {},
workflowIdsWithAutoHideEmptySteps,
} as unknown as UserSettingsState;
}
describe("isSettingsUnchanged", () => {
it("detects a details-only preference change", () => {
expect(isSettingsUnchanged(settings(true), settings(false))).toBe(false);
@@ -53,6 +65,21 @@ describe("isSettingsUnchanged", () => {
it("treats both empty hidden-step maps as unchanged", () => {
expect(isSettingsUnchanged(settingsWithHidden({}), settingsWithHidden({}))).toBe(true);
});
it("treats reordered auto-hide workflow ids as unchanged", () => {
expect(
isSettingsUnchanged(
settingsWithAutoHide(["wf-b", "wf-a"]),
settingsWithAutoHide(["wf-a", "wf-b"]),
),
).toBe(true);
});
it("detects an auto-hide workflow id change", () => {
expect(
isSettingsUnchanged(settingsWithAutoHide(["wf-a"]), settingsWithAutoHide(["wf-b"])),
).toBe(false);
});
});
describe("normalizeHiddenStepIds", () => {
@@ -90,4 +117,11 @@ describe("buildSettingsUpdatePayload", () => {
kanban_hidden_step_ids: {},
});
});
it("sends the per-workflow auto-hide preference under its wire key", () => {
const normalized = settingsWithAutoHide(["wf-b", "wf-a"]);
expect(buildSettingsUpdatePayload(normalized)).toMatchObject({
workflow_ids_with_auto_hide_empty_steps: ["wf-b", "wf-a"],
});
});
});
+15 -1
View File
@@ -27,8 +27,13 @@ type CommitPayload = {
tasksListShowDetails?: boolean;
kanbanViewMode?: string | null;
hiddenWorkflowStepIds?: Record<string, string[]>;
workflowIdsWithAutoHideEmptySteps?: string[];
};
export function normalizeWorkflowIds(ids: string[]): string[] {
return Array.from(new Set(ids)).sort();
}
export function normalizeHiddenStepIds(raw: Record<string, string[]>): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [workflowId, ids] of Object.entries(raw)) {
@@ -64,6 +69,9 @@ function buildNormalizedSettings(next: CommitPayload, current: DisplaySettings):
hiddenWorkflowStepIds: normalizeHiddenStepIds(
next.hiddenWorkflowStepIds ?? current.hiddenWorkflowStepIds ?? {},
),
workflowIdsWithAutoHideEmptySteps: normalizeWorkflowIds(
next.workflowIdsWithAutoHideEmptySteps ?? current.workflowIdsWithAutoHideEmptySteps ?? [],
),
loaded: true,
};
}
@@ -80,7 +88,12 @@ export function isSettingsUnchanged(
normalized.tasksListShowDetails === current.tasksListShowDetails &&
normalized.repositoryIds.length === current.repositoryIds.length &&
normalized.repositoryIds.every((id, index) => id === current.repositoryIds[index]) &&
hiddenStepIdsEqual(normalized.hiddenWorkflowStepIds ?? {}, current.hiddenWorkflowStepIds ?? {})
hiddenStepIdsEqual(
normalized.hiddenWorkflowStepIds ?? {},
current.hiddenWorkflowStepIds ?? {},
) &&
normalizeWorkflowIds(normalized.workflowIdsWithAutoHideEmptySteps ?? []).join("\0") ===
normalizeWorkflowIds(current.workflowIdsWithAutoHideEmptySteps ?? []).join("\0")
);
}
@@ -92,6 +105,7 @@ export function buildSettingsUpdatePayload(normalized: DisplaySettings): Record<
enable_preview_on_click: normalized.enablePreviewOnClick,
tasks_list_show_details: normalized.tasksListShowDetails,
kanban_hidden_step_ids: normalized.hiddenWorkflowStepIds,
workflow_ids_with_auto_hide_empty_steps: normalized.workflowIdsWithAutoHideEmptySteps,
};
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
areAllEmptyStepsAutoHidden,
deriveAutoHiddenStepIds,
sortWorkflowStepsByPosition,
} from "./auto-hide-empty-columns";
const steps = [{ id: "backlog" }, { id: "doing" }, { id: "done" }];
describe("deriveAutoHiddenStepIds", () => {
it("hides only unoccupied live steps when enabled", () => {
const result = deriveAutoHiddenStepIds(steps, [{ workflowStepId: "doing" }], true, []);
expect([...result]).toEqual(["backlog", "done"]);
});
it("returns no automatic hidden steps when disabled", () => {
expect([...deriveAutoHiddenStepIds(steps, [], false, [])]).toEqual([]);
});
it("leaves manually hidden steps to the manual visibility contract", () => {
const result = deriveAutoHiddenStepIds(steps, [], true, ["backlog"]);
expect([...result]).toEqual(["doing", "done"]);
});
});
describe("areAllEmptyStepsAutoHidden", () => {
it("distinguishes auto-hidden steps from a workflow with no configured steps", () => {
expect(areAllEmptyStepsAutoHidden([], [{ id: "todo" }])).toBe(true);
expect(areAllEmptyStepsAutoHidden([], [])).toBe(false);
expect(areAllEmptyStepsAutoHidden([{ id: "todo" }], [{ id: "todo" }])).toBe(false);
});
});
describe("sortWorkflowStepsByPosition", () => {
it("orders workflow steps deterministically without mutating the snapshot", () => {
const unordered = [
{ id: "step-b", position: 1 },
{ id: "step-c", position: 0 },
{ id: "step-a", position: 1 },
];
expect(sortWorkflowStepsByPosition(unordered).map((step) => step.id)).toEqual([
"step-c",
"step-a",
"step-b",
]);
expect(unordered.map((step) => step.id)).toEqual(["step-b", "step-c", "step-a"]);
});
});
@@ -0,0 +1,28 @@
type Step = { id: string };
type StepTask = { workflowStepId: string | null };
export function sortWorkflowStepsByPosition<T extends Step & { position: number }>(
steps: T[],
): T[] {
return [...steps].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
}
export function areAllEmptyStepsAutoHidden(visibleSteps: Step[], moveTargetSteps: Step[]): boolean {
return visibleSteps.length === 0 && moveTargetSteps.length > 0;
}
export function deriveAutoHiddenStepIds(
steps: Step[],
tasks: StepTask[],
enabled: boolean,
manuallyHiddenStepIds: string[],
): Set<string> {
if (!enabled) return new Set();
const occupied = new Set(tasks.map((task) => task.workflowStepId).filter(Boolean));
const manuallyHidden = new Set(manuallyHiddenStepIds);
return new Set(
steps
.filter((step) => !occupied.has(step.id) && !manuallyHidden.has(step.id))
.map((step) => step.id),
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Task } from "@/components/kanban-card";
import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters";
type FilterTasksOptions = {
searchQuery?: string;
matchesPluginTaskFilters?: (taskId: string) => boolean;
hiddenStepIds?: Set<string>;
};
export function filterTasks(
snapshots: Record<string, { tasks: Task[]; steps: { id: string }[] }>,
workflowId: string,
repoFilter: ReturnType<typeof mapSelectedRepositoryIds>,
options?: FilterTasksOptions,
): Task[] {
const snapshot = snapshots[workflowId];
if (!snapshot) return [];
let tasks = snapshot.tasks;
const { hiddenStepIds, searchQuery, matchesPluginTaskFilters } = options ?? {};
if (hiddenStepIds && hiddenStepIds.size > 0) {
const liveStepIds = new Set(snapshot.steps.map((step) => step.id));
const effectiveHidden = new Set([...hiddenStepIds].filter((id) => liveStepIds.has(id)));
if (effectiveHidden.size > 0) {
tasks = tasks.filter((task) => !effectiveHidden.has(task.workflowStepId));
}
}
tasks = filterTasksByRepositories(tasks, repoFilter);
if (searchQuery) {
const query = searchQuery.toLowerCase();
tasks = tasks.filter(
(task) =>
task.title.toLowerCase().includes(query) ||
(task.description && task.description.toLowerCase().includes(query)),
);
}
if (matchesPluginTaskFilters) {
tasks = tasks.filter((task) => matchesPluginTaskFilters(task.id));
}
return tasks;
}
type WorkflowTaskProjectionOptions = {
searchQuery: string;
matchesPluginTaskFilters?: (taskId: string) => boolean;
hiddenStepIds?: Set<string>;
};
export function projectWorkflowTasks(
snapshots: Record<string, { tasks: Task[]; steps: { id: string }[] }>,
workflowId: string,
repoFilter: ReturnType<typeof mapSelectedRepositoryIds>,
options: WorkflowTaskProjectionOptions,
): { visibleTasks: Task[]; occupancyTasks: Task[] } {
return {
visibleTasks: filterTasks(snapshots, workflowId, repoFilter, options),
occupancyTasks: filterTasks(snapshots, workflowId, repoFilter, {
matchesPluginTaskFilters: options.matchesPluginTaskFilters,
}),
};
}
+1
View File
@@ -9,6 +9,7 @@ import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
export type ViewContentProps = {
workflowId: string;
steps: WorkflowStep[];
moveTargetSteps: WorkflowStep[];
tasks: Task[];
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
+11
View File
@@ -575,6 +575,17 @@ describe("last seen display hydration", () => {
});
});
describe("auto-hide empty steps preference hydration", () => {
it("hydrates the canonical workflow-scoped key", () => {
const mapped = mapUserSettingsData(
{ workflow_ids_with_auto_hide_empty_steps: ["wf-a"] },
createDefaultUserSettings(),
);
expect(mapped.workflowIdsWithAutoHideEmptySteps).toEqual(["wf-a"]);
});
});
describe("prevent auto-start on open preference", () => {
it("defaults the missing preference to false", () => {
expect(buildCoreFields({}).preventAutoStartAgentOnOpen).toBe(false);
+3
View File
@@ -84,6 +84,7 @@ export function createDefaultUserSettings(): UserSettingsState {
appStatusBarEnabled: false,
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
hiddenWorkflowStepIds: {},
workflowIdsWithAutoHideEmptySteps: [],
loaded: false,
};
}
@@ -334,6 +335,8 @@ export function buildCoreFields(
),
appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled,
hiddenWorkflowStepIds: s.kanban_hidden_step_ids ?? current.hiddenWorkflowStepIds,
workflowIdsWithAutoHideEmptySteps:
s.workflow_ids_with_auto_hide_empty_steps ?? current.workflowIdsWithAutoHideEmptySteps,
...buildTerminalFields(s, current),
...buildSystemMetricsDisplayFields(s, current),
};
@@ -438,6 +438,7 @@ export type UserSettingsState = {
appStatusBarEnabled: boolean;
appStatusBarOrder: AppStatusBarOrderState;
hiddenWorkflowStepIds: Record<string, string[]>;
workflowIdsWithAutoHideEmptySteps: string[];
loaded: boolean;
};
+2
View File
@@ -106,6 +106,7 @@ export type UserSettings = {
app_status_bar_enabled?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
kanban_hidden_step_ids?: Record<string, string[]>;
workflow_ids_with_auto_hide_empty_steps?: string[];
revision?: number;
updated_at: string;
};
@@ -171,4 +172,5 @@ export type UserSettingsUpdatePayload = {
app_status_bar_enabled?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
kanban_hidden_step_ids?: Record<string, string[]>;
workflow_ids_with_auto_hide_empty_steps?: string[];
};
+3
View File
@@ -53,6 +53,7 @@
"menu": "Menu",
"moreOptions": "More options",
"moveTo": "Move to",
"moveToStep": "Move to {{step}}",
"multiSelect": "Multi-select",
"nextCapacity": "next capacity",
"nextStep": "Next step",
@@ -99,6 +100,8 @@
"workflow": "Workflow",
"workflowStepFallback": "workflow step {{stepId}}",
"columns": "Columns",
"autoHideEmptyColumns": "Auto-hide empty columns",
"allEmptyStepsAutoHidden": "All empty steps are hidden. Turn off Auto-hide empty columns in Columns to show them again.",
"columnsForWorkflow": "Columns for {{name}}",
"needsReassignment": "Needs Reassignment",
"noTasksYet": "No tasks yet",
+3
View File
@@ -53,6 +53,7 @@
"menu": "Ḿēńũ",
"moreOptions": "Ḿōŕē ōƥţĩōńś",
"moveTo": "Ḿōvē ţō",
"moveToStep": "Ḿōvē ţō {{step}}",
"multiSelect": "Ḿũĺţĩ-śēĺēćţ",
"nextCapacity": "ńēxţ ćàƥàćĩţŷ",
"nextStep": "Ńēxţ śţēƥ",
@@ -99,6 +100,8 @@
"workflow": "Ŵōŕķƒĺōŵ",
"workflowStepFallback": "ŵōŕķƒĺōŵ śţēƥ {{stepId}}",
"columns": "Ćōĺũḿńś",
"autoHideEmptyColumns": "Àũţō-ĥĩďē ēḿƥţŷ ćōĺũḿńś",
"allEmptyStepsAutoHidden": "Àĺĺ ēḿƥţŷ śţēƥś àŕē ĥĩďďēń. Ţũŕń ōƒƒ Àũţō-ĥĩďē ēḿƥţŷ ćōĺũḿńś ĩń Ćōĺũḿńś ţō śĥōŵ ţĥēḿ àĝàĩń.",
"columnsForWorkflow": "Ćōĺũḿńś ƒōŕ {{name}}",
"needsReassignment": "Ńēēďś Ŕēàśśĩĝńḿēńţ",
"noTasksYet": "Ńō ţàśķś ŷēţ",
+3
View File
@@ -48,6 +48,7 @@
"menu": "Menu",
"moreOptions": "Mais opções",
"moveTo": "Mover para",
"moveToStep": "Mover para {{step}}",
"multiSelect": "Seleção múltipla",
"nextCapacity": "próxima capacidade",
"nextStep": "Passo seguinte",
@@ -100,6 +101,8 @@
"blockedFailed": "O bloqueio falhou",
"blockedPredecessorFailed": "Uma tarefa de que esta depende falhou: {{tasks}}",
"columns": "Colunas",
"autoHideEmptyColumns": "Ocultar colunas vazias automaticamente",
"allEmptyStepsAutoHidden": "Todos os passos vazios estão ocultos. Desative Ocultar colunas vazias automaticamente em Colunas para os mostrar novamente.",
"columnsForWorkflow": "Colunas de {{name}}",
"noTasksYet": "Ainda sem tarefas",
"noWorkflowsAvailableYet": "Ainda não há fluxos de trabalho disponíveis.",
+3
View File
@@ -48,6 +48,7 @@
"menu": "菜单",
"moreOptions": "更多选项",
"moveTo": "移动到",
"moveToStep": "移动到 {{step}}",
"multiSelect": "多选",
"nextCapacity": "下一容量",
"nextStep": "下一步",
@@ -94,6 +95,8 @@
"workflow": "工作流",
"workflowStepFallback": "工作流步骤 {{stepId}}",
"columns": "列",
"autoHideEmptyColumns": "自动隐藏空列",
"allEmptyStepsAutoHidden": "所有空步骤都已隐藏。在“列”中关闭“自动隐藏空列”即可重新显示它们。",
"columnsForWorkflow": "{{name}} 的列",
"needsReassignment": "需要重新分配",
"blockedByCount_one": "被 {{count}} 项阻塞",
+4 -1
View File
@@ -48,6 +48,7 @@
"menu": "菜單",
"moreOptions": "更多選項",
"moveTo": "移動到",
"moveToStep": "移動到 {{step}}",
"multiSelect": "多選",
"nextCapacity": "下一容量",
"nextStep": "下一步",
@@ -93,7 +94,9 @@
"wipTaskCount": "{{label}} 個任務",
"workflow": "工作流程",
"workflowStepFallback": "工作流程步驟 {{stepId}}",
"columns": "列",
"columns": "欄",
"autoHideEmptyColumns": "自動隱藏空白欄",
"allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。",
"columnsForWorkflow": "{{name}} 的列",
"needsReassignment": "需要重新分配",
"blockedByCount_one": "被 {{count}} 項阻塞",
+4 -1
View File
@@ -48,6 +48,7 @@
"menu": "選單",
"moreOptions": "更多選項",
"moveTo": "移動到",
"moveToStep": "移動到 {{step}}",
"multiSelect": "多選",
"nextCapacity": "下一容量",
"nextStep": "下一步",
@@ -93,7 +94,9 @@
"wipTaskCount": "{{label}} 個任務",
"workflow": "工作流程",
"workflowStepFallback": "工作流程步驟 {{stepId}}",
"columns": "列",
"columns": "欄",
"autoHideEmptyColumns": "自動隱藏空白欄",
"allEmptyStepsAutoHidden": "所有空白步驟都已隱藏。在「欄」中關閉「自動隱藏空白欄」即可重新顯示它們。",
"columnsForWorkflow": "{{name}} 的列",
"needsReassignment": "需要重新分配",
"blockedByCount_one": "被 {{count}} 項阻塞",
@@ -0,0 +1,135 @@
---
spec: docs/specs/kanban-auto-hide-empty-columns/spec.md
created: 2026-08-18
status: complete
---
# Implementation Plan: Auto-hide empty workflow steps
## Overview
Extend the manual column-visibility feature with a separate, opt-in per-workflow preference. Persist
only the preference. Derive empty steps from the rendered task projection, keep manual hiding
authoritative, and temporarily restore auto-hidden steps as drop targets while a move is active.
The implementation is sequential because the frontend behavior depends on the persisted settings
contract and drag behavior touches shared Kanban and Pipeline composition.
## Existing seams to reuse
- User display settings round-trip in `apps/backend/internal/user/**`, boot state, REST hydration,
WebSocket echo, and `apps/web/hooks/use-user-display-settings.ts`.
- Per-workflow Columns UI in `apps/web/components/kanban/columns-menu.tsx`.
- Workflow-scoped visibility handling in `use-kanban-display-settings.ts` and
`swimlane-container.tsx`.
- Kanban drag state in `swimlane-kanban-content.tsx`, Pipeline drag state in
`swimlane-graph-content.tsx`, and phone drop targets in `mobile-drop-targets.tsx`.
- Existing manual-hidden move-target filtering in `kanban-board.tsx`.
## Data model and API
- Add `WorkflowIDsWithAutoHideEmptySteps []string` to the existing Go user-settings model and DTO.
- Add optional update field `workflow_ids_with_auto_hide_empty_steps` and propagate it through controller,
service, storage JSON, boot payload, and user-settings events.
- Add `workflowIdsWithAutoHideEmptySteps: string[]` to frontend settings state and hydrate/persist it through
existing settings helpers.
- Normalize as a sorted, deduplicated list. Default legacy state to `[]`.
- No new endpoint, SQL column, or migration.
## Frontend behavior
### Columns control
- Add a translated display-behavior section at the top of `ColumnsMenu`, separated from the manual
column list, with no secondary description under the switch.
- Add `autoHideEmpty`, `onToggleAutoHide`, and touch-target-safe rendering to the shared component.
- Reuse the same component in the desktop/tablet swimlane header and phone drawer.
### Occupancy projection
- Extract a pure helper that returns occupied step ids from tasks after workflow, repository, and
plugin filters but before free-text search.
- Derive auto-hidden live step ids only for workflows whose preference is enabled.
- Keep the manual hidden set separate and authoritative.
- Preserve the lane/header when all real steps are auto-hidden and render translated contextual
guidance.
### Move presentation
- Pass all non-manually-hidden live steps as move targets.
- In Kanban DnD, reveal auto-hidden steps as droppable targets while drag state is active, without
duplicating droppable ids. Keep Pipeline compact while its existing move controls retain the full
non-manually-hidden step list.
- Keep Bulk Move behavior unchanged except that auto-hidden steps remain available.
- Reuse the existing phone drop-target surface and preserve mobile navigation.
## Internationalization
Add English source keys and complete `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw` catalogs for:
- auto-hide toggle label;
- conditional Pipeline destination tooltip copy;
- all-columns-empty contextual state;
- optional drag-target explanation if the existing accessible step title is insufficient.
No Unicode em dash is permitted in locale values.
## Public documentation
Update `docs/public/tasks-and-workflows.md` to distinguish:
- manually hidden columns, which stay hidden and are not move targets;
- auto-hidden empty columns, which return as destinations during a move.
Update the feature-spec index with the new spec.
## Tests
- Backend model/DTO/controller/service/store/boot tests for persistence and defaults.
- Frontend settings normalization, equality, payload, hydration, and toggle-hook tests.
- Pure occupancy and effective-step helper tests, including search stability and workflow isolation.
- Columns menu tests for default-off, toggling, touch geometry, and manual-state independence.
- Kanban/Pipeline component tests for ordinary hiding and drag-time targets.
- Chromium E2E for enable, persistence, collapse, drag reveal, cancel, successful drop, and manual-hidden
precedence.
- Mobile Chrome E2E for the focused workflow, touch target, move destination, and no overflow.
## Implementation tasks
- [x] [Task 01 - Persist the per-workflow preference](task-01-persist-preference.md)
- [x] [Task 02 - Derive and render empty-column visibility](task-02-derived-visibility.md)
- [x] [Task 03 - Restore auto-hidden move targets](task-03-drag-targets.md)
- [x] [Task 04 - Prove end-to-end behavior and document it](task-04-e2e-docs.md)
All tasks are sequential. Tasks 02-04 depend on the contracts established by the preceding task and
touch overlapping Kanban composition or acceptance tests.
## Risks
- Search-driven layout churn if occupancy is derived at the wrong filtering seam.
- Duplicate DnD droppable ids if ghost targets and real columns mount together.
- A lane becoming unreachable when every column is auto-hidden.
- Manual and automatic state accidentally sharing persistence or move-target semantics.
- Incomplete locale updates failing the repo-wide i18n gates.
## Planned verification
```bash
make fmt
make typecheck
make test
make lint
(cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet)
(cd apps/web && pnpm e2e:run --host --project chromium tests/kanban/auto-hide-empty-columns.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/kanban/mobile-auto-hide-empty-columns.spec.ts)
```
## Verification results
- Targeted Prettier, ESLint, and the E2E sleep ratchet passed for the new coverage and selectors.
- Chromium passed 1/1 test, covering default-off compatibility, persisted enablement, search-stable
occupancy, manual-hidden precedence, drag reveal, cancellation, and successful drop.
- Mobile Chrome passed 1/1 test, covering focused-workflow persistence, the 44 CSS px toggle,
drag-time destination recovery, cancellation cleanup, and document overflow.
- The E2E backend required a dedicated `KANDEV_AGENT_STANDALONE_PORT` locally because the live Kandev
runtime already owned the default port; both successful runs used isolated temporary databases.
@@ -0,0 +1,49 @@
---
id: "01-persist-preference"
title: "Persist the per-workflow auto-hide preference"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/kanban-auto-hide-empty-columns/spec.md"
---
# Task 01: Persist the per-workflow auto-hide preference
## Acceptance
- User settings persist a sorted, deduplicated `workflow_ids_with_auto_hide_empty_steps` list and
hydrate it as `workflowIdsWithAutoHideEmptySteps`, defaulting missing state to `[]`.
- The existing Columns menu exposes an accessible, translated per-workflow toggle on desktop and
phone without modifying `hiddenWorkflowStepIds`.
- Two workflows can hold different values, and toggling one never changes the other.
## Likely files
- `apps/backend/internal/user/models/models.go`
- `apps/backend/internal/user/dto/dto.go` and focused tests
- `apps/backend/internal/user/controller/controller.go`
- `apps/backend/internal/user/service/service.go`
- `apps/backend/internal/user/store/sqlite.go`
- `apps/backend/internal/backendapp/boot_state_routes.go`
- `apps/web/lib/state/slices/settings/types.ts`
- `apps/web/lib/state/default-state.ts`
- `apps/web/lib/ssr/user-settings.ts`
- `apps/web/hooks/use-user-display-settings.ts` and tests
- `apps/web/hooks/use-kanban-display-settings.ts` and tests
- `apps/web/components/kanban/columns-menu.tsx` and tests
- `apps/web/src/locales/*/kanban.json`
## Verification
```bash
make -C apps/backend test
(cd apps && pnpm --filter @kandev/web test -- --run hooks/use-user-display-settings.test.ts hooks/use-kanban-display-settings.test.ts components/kanban/columns-menu.test.tsx)
(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet)
```
## Risks
- The boot-state key must match the Zustand field exactly.
- Empty values must serialize authoritatively so clearing the preference cannot preserve stale state.
- The control needs complete translations in every gated locale.
@@ -0,0 +1,41 @@
---
id: "02-derived-visibility"
title: "Derive and render empty-column visibility"
status: done
wave: 2
depends_on: ["01-persist-preference"]
plan: "plan.md"
spec: "../../specs/kanban-auto-hide-empty-columns/spec.md"
---
# Task 02: Derive and render empty-column visibility
## Acceptance
- A pure, tested projection derives occupied and auto-hidden live steps after workflow, repository,
and plugin filters but before free-text search.
- Ordinary Kanban and Pipeline rendering removes auto-hidden empty steps while leaving manual hidden
semantics unchanged.
- A workflow whose every real step is auto-hidden retains its lane, Columns menu, and contextual
translated empty state.
## Likely files
- `apps/web/components/kanban/swimlane-container.tsx` and tests
- `apps/web/lib/kanban/workflow-swimlanes.ts` and tests
- `apps/web/components/kanban/swimlane-kanban-content.tsx`
- `apps/web/components/kanban/swimlane-graph-content.tsx`
- `apps/web/src/locales/*/kanban.json`
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- --run components/kanban/swimlane-container.test.ts lib/kanban/workflow-swimlanes.test.ts)
(cd apps/web && pnpm run typecheck && pnpm run i18n:ratchet)
```
## Risks
- Reusing the fully searched task list would cause layout churn while typing.
- Filtering steps too early can turn valid tasks into orphans.
- All-empty lanes must remain recoverable without restoring full empty columns.
@@ -0,0 +1,44 @@
---
id: "03-drag-targets"
title: "Restore auto-hidden steps as move targets"
status: done
wave: 3
depends_on: ["02-derived-visibility"]
plan: "plan.md"
spec: "../../specs/kanban-auto-hide-empty-columns/spec.md"
---
# Task 03: Restore auto-hidden steps as move targets
## Acceptance
- Starting a pointer or touch drag reveals every auto-hidden, non-manually-hidden live step as an
accessible droppable target with a unique real step id.
- Cancelling or failing the drag removes temporary targets; a successful authoritative move keeps
the occupied destination visible.
- Bulk Move, Pipeline move controls, and phone move targets include auto-hidden steps and continue
excluding manually hidden steps.
- A Pipeline arrow shows the destination name in a tooltip only when that adjacent destination is
auto-hidden between visible stages.
## Likely files
- `apps/web/components/kanban/swimlane-kanban-content.tsx` and focused tests
- `apps/web/components/kanban/swimlane-graph2-content.tsx` and focused tests
- `apps/web/components/kanban/mobile-drop-targets.tsx` and tests
- `apps/web/components/kanban-board.tsx` and tests
- shared Kanban presentation helper extracted during Task 02
## Verification
```bash
(cd apps && pnpm --filter @kandev/web test -- --run components/kanban-board.test.ts components/kanban/mobile-drop-targets.test.tsx components/kanban/swimlane-kanban-content.test.tsx components/kanban/swimlane-graph-content.test.tsx components/kanban/swimlane-graph2-orphan-display.test.ts)
(cd apps/web && pnpm run typecheck && pnpm run i18n:ratchet)
```
## Risks
- Ghost targets and real columns must never mount the same droppable id simultaneously.
- DnD completion is asynchronous; local drag state cannot be treated as authoritative task state.
- Pipeline uses explicit move controls rather than DnD and must stay compact while retaining hidden
destinations in its movement model.
@@ -0,0 +1,56 @@
---
id: "04-e2e-docs"
title: "Prove end-to-end behavior and document it"
status: done
wave: 4
depends_on: ["03-drag-targets"]
plan: "plan.md"
spec: "../../specs/kanban-auto-hide-empty-columns/spec.md"
---
# Task 04: Prove end-to-end behavior and document it
## Acceptance
- Desktop E2E covers default-off compatibility, persisted enablement, empty-column collapse, search
stability, drag reveal, cancellation, successful drop, and manual-hidden precedence.
- Mobile E2E covers focused-workflow isolation, a 44 CSS px toggle surface, move-target recovery, and
absence of document overflow.
- Public tasks/workflows documentation and the feature-spec index explain the new preference and its
distinction from manual hiding.
## Likely files
- `apps/web/e2e/tests/kanban/auto-hide-empty-columns.spec.ts`
- `apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts`
- `apps/web/e2e/pages/kanban-page.ts` if a shared locator is justified
- `docs/public/tasks-and-workflows.md`
- `docs/specs/INDEX.md`
- this plan and its task files for final status and command evidence
## Verification
```bash
(cd apps/web && pnpm e2e:run --host --project chromium tests/kanban/auto-hide-empty-columns.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/kanban/mobile-auto-hide-empty-columns.spec.ts)
make fmt
make typecheck
make test
make lint
```
## Risks
- E2E must wait on causal task/settings updates rather than unconditional sleeps.
- The tests must prove manual and automatic hiding are separate, not merely that a column vanished.
- User-facing documentation must not imply that manually hidden steps become move targets.
## Results
- Added `auto-hide-empty-columns.spec.ts`; Chromium passed 1/1 and exercises the complete desktop
acceptance path through the real settings UI and pointer drag surface.
- Added `mobile-auto-hide-empty-columns.spec.ts`; Mobile Chrome passed 1/1 and verifies workflow
isolation, settled 44 CSS px geometry, recovered drop targets, cancellation cleanup, and no
document-level overflow.
- Added a stable mobile drop-target test id and the auto-hide field to the typed E2E settings helper.
- Both runs used the production E2E build, real backend, isolated SQLite database, and no raw sleeps.
+1 -1
View File
@@ -368,7 +368,7 @@ On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and
Under **Settings → General → Appearance → Startup Page**, choose **Task overview** (the default) or **Last visited task**. The latter resumes the most recently opened task in the current workspace on that device when Kandev starts or you open bare Home. It does not change an explicit task or workflow link. Home navigation and a task's Back action always return to the task overview; when there is no matching local recent task, Kandev opens the overview instead.
- Search matches tasks without changing their state.
- The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**. In Kanban/Pipeline, each workflow lane has a **Columns** menu to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. On phones, open the menu drawer to change columns for the focused workflow.
- The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**. In Kanban/Pipeline, each workflow lane has a **Columns** menu to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. The optional **Auto-hide empty columns** setting collapses unoccupied steps without changing those manual choices; auto-hidden empty steps return as move destinations while a task is being moved, while manually hidden steps remain unavailable for pointer and bulk moves. On phones, open the menu drawer to change columns for the focused workflow.
- In **List**, the display menu can enable **Show task details** to include available repository, description, pull-request, session, parent, review, and archive context in each row. This option is off by default and follows the user across devices.
- **List** can group by **State**, **Workflow**, **Repository**, or **None**.
- **List** can sort by updated time, created time, or title in either direction.
+1
View File
@@ -186,6 +186,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| Spec | Status |
|---|---|
| [kanban-auto-hide-empty-columns](kanban-auto-hide-empty-columns/spec.md) | shipped |
| [workspace-active-first-order](ui/workspace-active-first-order.md) | shipped |
| [ci-pr-automation](ui/ci-pr-automation.md) | building |
| [github-pr-review-actions](ui/github-pr-review-actions.md) | shipped |
@@ -0,0 +1,199 @@
---
status: shipped
created: 2026-08-18
updated: 2026-08-18
owner: gsimard-nordai
---
# Auto-hide empty workflow steps
## Why
Long workflows spend most of their lifetime with tasks in only a few steps. The board still renders
every empty step, which consumes horizontal space and makes active work harder to scan.
The shipped per-workflow Columns menu intentionally models explicit, persistent hiding. It is not an
adaptive layout mode: a user must maintain the hidden set as work moves, and a manually hidden step
is not a move target. Automatic empty-column hiding must therefore remain a separate concept with a
different drag-and-drop contract.
## Users and outcome
- An individual developer can opt one workflow into a compact board that shows occupied columns.
- A team sharing a Kandev instance retains user-scoped display preferences; one user's compact board
does not change another user's board.
- Every real workflow step remains reachable as a move destination even while its empty column is
auto-hidden.
## What
### Per-workflow preference
- Each workflow's existing Columns menu SHALL expose an **Auto-hide empty columns** switch.
- The adaptive display switch SHALL be visually separated from the individual manual column list
and SHALL not carry secondary descriptive copy.
- The preference SHALL be user-scoped, persisted per workflow, and disabled by default.
- Enabling it SHALL NOT change `kanban_hidden_step_ids` or the checked state of individual manual
column toggles.
- The same preference SHALL control Kanban and Pipeline surfaces for that workflow on desktop,
tablet, and phone.
### Occupancy
- A live step is occupied when at least one task that passes the current workflow, repository, and
plugin filters belongs to that step.
- Free-text search SHALL NOT affect occupancy. Typing in search may filter cards, but SHALL NOT make
the board structure appear and disappear on every keystroke.
- Tasks in a manually hidden step SHALL still count toward occupancy for the automatic calculation.
Manual hiding is authoritative and keeps the step hidden regardless of occupancy.
- Orphaned tasks SHALL continue to use the shipped Needs Reassignment behavior and SHALL NOT make an
unrelated real step occupied.
### Ordinary rendering
- With auto-hide disabled, current board behavior SHALL remain unchanged.
- With auto-hide enabled, each unoccupied, non-manually-hidden live step SHALL be removed from normal
column rendering.
- A task created in or moved into an auto-hidden step SHALL make that column appear immediately.
- A task leaving a column SHALL make it collapse after the authoritative move update leaves it empty.
- If every live step is auto-hidden, the workflow lane and its Columns menu SHALL remain reachable.
The lane SHALL show a contextual empty state rather than the generic "No tasks yet" state.
### Drag and move behavior
- When a pointer drag starts, every auto-hidden, non-manually-hidden live step SHALL become an
accessible drop target for that workflow.
- Auto-hidden destinations MAY render as compact ghost targets rather than full empty columns, but
they SHALL expose the step title and the same droppable identity as the real step.
- Cancelling a drag SHALL remove ghost targets and restore automatic hiding.
- Dropping into an auto-hidden step SHALL keep that step visible once the task update is authoritative.
- Manually hidden steps SHALL remain hidden and excluded from pointer and bulk-move destinations, as
required by the existing column-visibility contract.
- Bulk Move and other explicit move selectors SHALL include auto-hidden steps and exclude manually
hidden steps.
- Pipeline arrows SHALL name an adjacent destination in a tooltip only when that destination is
auto-hidden; a directly visible neighboring step needs no tooltip.
- Keyboard and screen-reader users SHALL retain access to auto-hidden destinations through the
existing move controls; this feature does not add keyboard drag-and-drop.
### Mobile and responsive behavior
- The phone Columns control SHALL expose the same preference for the focused workflow.
- Existing phone focused-column navigation, mobile drop targets, safe-area behavior, and 44 CSS px
touch targets SHALL remain intact.
- Tablet and compact desktop boards SHALL not gain document-level horizontal overflow.
- The preference SHALL not persist responsive state, drag state, or a derived list of empty steps.
## State and persistence
### Persisted preference
The user settings store gains a set of workflow ids whose empty columns are auto-hidden:
```text
workflowIdsWithAutoHideEmptySteps: string[]
```
The REST/WebSocket wire field is:
```text
workflow_ids_with_auto_hide_empty_steps: string[]
```
- Values SHALL be deduplicated and sorted before comparison and persistence.
- Missing or `null` legacy values SHALL hydrate to `[]`.
- Removing a workflow from the set SHALL restore normal rendering without touching manual hidden ids.
- The setting lives in the existing user settings JSON payload; no SQL column or data migration is
required.
### Derived presentation state
`autoHiddenStepIds` is ephemeral and SHALL be derived from the current filtered task projection and
live workflow steps. It SHALL NOT be stored in the backend, URL, session, or browser storage.
`manuallyHiddenStepIds` and `autoHiddenStepIds` remain separate. Ordinary rendered steps are:
```text
live steps - manually hidden steps - auto-hidden steps
```
During an active drag, move destinations are:
```text
live steps - manually hidden steps
```
## API surface
- Extend the existing user settings GET/update/boot/WS contracts with
`workflow_ids_with_auto_hide_empty_steps`.
- Do not add a dedicated endpoint.
- Do not change task, workflow-step, move, WIP-limit, or drag payloads.
## Permissions
- The preference uses the current authenticated user's display settings.
- It does not modify a workflow or require workflow-administration permission.
- It never changes which tasks the user is authorized to read or move.
## Failure modes
- A settings persistence failure MAY leave the optimistic local toggle visible until authoritative
settings reconcile, matching existing display-setting behavior. It SHALL NOT mutate manual hidden
ids.
- Stale workflow ids in the preference are inert. They SHALL not hide a newly created workflow that
reuses a title.
- A stale step id is irrelevant because automatic state is derived only from live steps.
- A cancelled or failed move SHALL clear temporary drop-target presentation.
- If a task update arrives after drag state clears, the authoritative task projection determines
whether the destination column remains visible.
- If every column is empty, the lane header and Columns control remain reachable so the preference
can be disabled from the same surface.
## Scenarios
- **GIVEN** auto-hide is disabled, **WHEN** a workflow has empty steps, **THEN** every non-manually-
hidden column renders exactly as it does today.
- **GIVEN** auto-hide is enabled, **WHEN** a step has no task after workflow, repository, and plugin
filters, **THEN** its ordinary column is absent.
- **GIVEN** a free-text search matches no task in an occupied step, **WHEN** search results update,
**THEN** the column structure remains stable.
- **GIVEN** an auto-hidden step, **WHEN** pointer drag begins, **THEN** that step becomes an accessible
drop target.
- **GIVEN** an auto-hidden drop target, **WHEN** the drag is cancelled, **THEN** it disappears again.
- **GIVEN** an auto-hidden drop target, **WHEN** a task is dropped into it and the move succeeds,
**THEN** the destination column remains visible with the task.
- **GIVEN** a manually hidden step, **WHEN** pointer drag or Bulk Move begins, **THEN** that step is not
offered as a destination.
- **GIVEN** all live steps are empty, **WHEN** auto-hide is enabled, **THEN** the workflow lane and
Columns menu remain visible with contextual guidance.
- **GIVEN** two workflows share step titles, **WHEN** one enables auto-hide, **THEN** the other
workflow is unaffected.
- **GIVEN** the preference is enabled, **WHEN** the page reloads or another tab hydrates settings,
**THEN** the same workflow resumes automatic empty-column hiding.
- **GIVEN** a phone viewport, **WHEN** the user enables auto-hide for the focused workflow, **THEN**
focused-column navigation and drag targets remain usable without document overflow.
## Out of scope
- Automatically or semantically classifying steps as Backlog, Done, Review, or another phase.
- Matching steps by title or `stage_type` across workflows.
- Automatically changing the manually hidden step set.
- Making manually hidden steps available during drag.
- Persisting derived empty-step ids, drag state, search state, or responsive layout state.
- Redesigning the task move API, workflow engine, WIP limits, or task lifecycle.
- A global install-wide auto-hide policy or administrator-enforced preference.
## References
- [Per-workflow column visibility spec](../board-step-visibility-filter/spec.md)
- [Merged PR #2467](https://github.com/kdlbs/kandev/pull/2467)
- `apps/web/components/kanban/columns-menu.tsx`
- `apps/web/components/kanban/swimlane-container.tsx`
- `apps/web/components/kanban/swimlane-kanban-content.tsx`
- `apps/web/components/kanban/swimlane-graph-content.tsx`
- `apps/web/components/kanban/mobile-drop-targets.tsx`
## Implementation plan
[Auto-hide empty Kanban columns implementation plan](../../plans/kanban-auto-hide-empty-columns/plan.md)