feat(web): remember task listing display preferences (#1944)

This commit is contained in:
Carlos Florêncio
2026-07-25 19:30:52 +01:00
committed by GitHub
parent dba2057590
commit a4d53bdf42
57 changed files with 1969 additions and 173 deletions
@@ -448,6 +448,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
"repositoryIds": stringSlice(settings.RepositoryIDs),
"tasksListSort": usermodels.NormalizeTasksListSort(settings.TasksListSort),
"tasksListGroup": usermodels.NormalizeTasksListGroup(settings.TasksListGroup),
"tasksListShowDetails": settings.TasksListShowDetails,
"preferredShell": nullString(settings.PreferredShell),
"shellOptions": response.ShellOptions,
"defaultEditorId": nullString(settings.DefaultEditorID),
@@ -18,6 +18,16 @@ func TestMapUserSettingsStateIncludesArchiveConfirmation(t *testing.T) {
}
}
func TestMapUserSettingsStateIncludesTasksListShowDetails(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{TasksListShowDetails: true},
}, "workspace-1")
if got, ok := state["tasksListShowDetails"].(bool); !ok || !got {
t.Fatalf("tasksListShowDetails = %#v, want true", state["tasksListShowDetails"])
}
}
func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{MCPTaskAgentProfileDefault: "future_value"},
@@ -50,6 +50,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
RepositoryIDs: req.RepositoryIDs,
TasksListSort: req.TasksListSort,
TasksListGroup: req.TasksListGroup,
TasksListShowDetails: req.TasksListShowDetails,
InitialSetupComplete: req.InitialSetupComplete,
PreferredShell: req.PreferredShell,
DefaultEditorID: req.DefaultEditorID,
+3
View File
@@ -22,6 +22,7 @@ type UserSettingsDTO struct {
RepositoryIDs []string `json:"repository_ids"`
TasksListSort string `json:"tasks_list_sort"`
TasksListGroup string `json:"tasks_list_group"`
TasksListShowDetails bool `json:"tasks_list_show_details"`
InitialSetupComplete bool `json:"initial_setup_complete"`
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
@@ -81,6 +82,7 @@ type UpdateUserSettingsRequest struct {
RepositoryIDs *[]string `json:"repository_ids,omitempty"`
TasksListSort *string `json:"tasks_list_sort,omitempty"`
TasksListGroup *string `json:"tasks_list_group,omitempty"`
TasksListShowDetails *bool `json:"tasks_list_show_details,omitempty"`
InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"`
PreferredShell *string `json:"preferred_shell,omitempty"`
DefaultEditorID *string `json:"default_editor_id,omitempty"`
@@ -201,6 +203,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
RepositoryIDs: settings.RepositoryIDs,
TasksListSort: settings.TasksListSort,
TasksListGroup: settings.TasksListGroup,
TasksListShowDetails: settings.TasksListShowDetails,
InitialSetupComplete: settings.InitialSetupComplete,
PreferredShell: settings.PreferredShell,
DefaultEditorID: settings.DefaultEditorID,
@@ -8,6 +8,32 @@ import (
"github.com/kandev/kandev/internal/user/models"
)
func TestTasksListShowDetailsDTO(t *testing.T) {
if !FromUserSettings(&models.UserSettings{TasksListShowDetails: true}).TasksListShowDetails {
t.Fatal("TasksListShowDetails = false, want true")
}
t.Run("omitted value stays nil", func(t *testing.T) {
var req UpdateUserSettingsRequest
if err := json.Unmarshal([]byte(`{}`), &req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.TasksListShowDetails != nil {
t.Fatalf("TasksListShowDetails = %#v, want nil", req.TasksListShowDetails)
}
})
t.Run("explicit false is retained", func(t *testing.T) {
var req UpdateUserSettingsRequest
if err := json.Unmarshal([]byte(`{"tasks_list_show_details":false}`), &req); err != nil {
t.Fatalf("decode request: %v", err)
}
if req.TasksListShowDetails == nil || *req.TasksListShowDetails {
t.Fatalf("TasksListShowDetails = %#v, want false", req.TasksListShowDetails)
}
})
}
func TestAppStatusBarOrderDTOAndPatchSemantics(t *testing.T) {
want := models.AppStatusBarOrder{
LeftItemIDs: []string{"builtin:connection", "plugin:left"},
@@ -50,6 +50,7 @@ type UserSettings struct {
RepositoryIDs []string `json:"repository_ids"`
TasksListSort string `json:"tasks_list_sort"`
TasksListGroup string `json:"tasks_list_group"`
TasksListShowDetails bool `json:"tasks_list_show_details"`
InitialSetupComplete bool `json:"initial_setup_complete"`
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
@@ -41,6 +41,7 @@ type UpdateUserSettingsRequest struct {
RepositoryIDs *[]string
TasksListSort *string
TasksListGroup *string
TasksListShowDetails *bool
InitialSetupComplete *bool
PreferredShell *string
DefaultEditorID *string
@@ -206,6 +207,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe
if err := applyTasksListPreferences(settings, req.TasksListSort, req.TasksListGroup); err != nil {
return err
}
if req.TasksListShowDetails != nil {
settings.TasksListShowDetails = *req.TasksListShowDetails
}
if req.InitialSetupComplete != nil {
settings.InitialSetupComplete = *req.InitialSetupComplete
}
@@ -589,6 +593,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"repository_ids": settings.RepositoryIDs,
"tasks_list_sort": settings.TasksListSort,
"tasks_list_group": settings.TasksListGroup,
"tasks_list_show_details": settings.TasksListShowDetails,
"initial_setup_complete": settings.InitialSetupComplete,
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
@@ -27,6 +27,30 @@ func rawClear() **json.RawMessage {
return ptr((*json.RawMessage)(nil))
}
func TestApplyBasicSettingsTasksListShowDetails(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{TasksListShowDetails: true}
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil {
t.Fatalf("apply basic settings: %v", err)
}
if !settings.TasksListShowDetails {
t.Fatal("TasksListShowDetails = false, want true")
}
})
for _, value := range []bool{false, true} {
t.Run(fmt.Sprintf("explicit %t is applied", value), func(t *testing.T) {
settings := &models.UserSettings{TasksListShowDetails: !value}
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{TasksListShowDetails: ptr(value)}); err != nil {
t.Fatalf("apply basic settings: %v", err)
}
if settings.TasksListShowDetails != value {
t.Fatalf("TasksListShowDetails = %t, want %t", settings.TasksListShowDetails, value)
}
})
}
}
func TestApplyBasicSettingsSystemMetricsDisplayPreservesOmittedFields(t *testing.T) {
settings := &models.UserSettings{
SystemMetricsDisplay: models.SystemMetricsDisplaySettings{
@@ -882,6 +906,27 @@ func TestPublishUserSettingsEventIncludesArchiveConfirmation(t *testing.T) {
}
}
func TestPublishUserSettingsEventIncludesTasksListShowDetails(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("logger.NewFromZap: %v", err)
}
eventBus := &recordingEventBus{}
svc := NewService(&recordingUserRepository{}, eventBus, log)
svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{TasksListShowDetails: true})
if len(eventBus.publishedEvents) != 1 {
t.Fatalf("expected one settings event, got %d", len(eventBus.publishedEvents))
}
eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{})
if !ok {
t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data)
}
if showDetails, ok := eventData["tasks_list_show_details"].(bool); !ok || !showDetails {
t.Fatalf("tasks_list_show_details = %#v, want true", eventData["tasks_list_show_details"])
}
}
func TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -443,6 +443,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
"repository_ids": settings.RepositoryIDs,
"tasks_list_sort": models.NormalizeTasksListSort(settings.TasksListSort),
"tasks_list_group": models.NormalizeTasksListGroup(settings.TasksListGroup),
"tasks_list_show_details": settings.TasksListShowDetails,
"initial_setup_complete": settings.InitialSetupComplete,
"preferred_shell": settings.PreferredShell,
"default_editor_id": settings.DefaultEditorID,
@@ -587,6 +588,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
RepositoryIDs []string `json:"repository_ids"`
TasksListSort string `json:"tasks_list_sort"`
TasksListGroup string `json:"tasks_list_group"`
TasksListShowDetails bool `json:"tasks_list_show_details"`
InitialSetupComplete bool `json:"initial_setup_complete"`
PreferredShell string `json:"preferred_shell"`
DefaultEditorID string `json:"default_editor_id"`
@@ -631,6 +633,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
settings.RepositoryIDs = payload.RepositoryIDs
settings.TasksListSort = models.NormalizeTasksListSort(payload.TasksListSort)
settings.TasksListGroup = models.NormalizeTasksListGroup(payload.TasksListGroup)
settings.TasksListShowDetails = payload.TasksListShowDetails
settings.InitialSetupComplete = payload.InitialSetupComplete
settings.PreferredShell = payload.PreferredShell
settings.DefaultEditorID = payload.DefaultEditorID
@@ -140,6 +140,39 @@ func TestMarshalUserSettingsPersistsDisabledArchiveConfirmation(t *testing.T) {
}
}
func TestMarshalUserSettingsPersistsTasksListShowDetails(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{TasksListShowDetails: true})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode settings: %v", err)
}
if got, ok := payload["tasks_list_show_details"].(bool); !ok || !got {
t.Fatalf("tasks_list_show_details = %#v, want true", payload["tasks_list_show_details"])
}
}
func TestScanUserSettingsTasksListShowDetailsDefaultsAndLoads(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
t.Fatalf("scan defaults: %v", err)
}
if settings.TasksListShowDetails {
t.Fatal("TasksListShowDetails = true, want false")
}
settings, err = scanUserSettings(settingsScanner{raw: `{"tasks_list_show_details":true}`}, DefaultUserID)
if err != nil {
t.Fatalf("scan stored settings: %v", err)
}
if !settings.TasksListShowDetails {
t.Fatal("TasksListShowDetails = false, want true")
}
}
func TestMarshalUserSettingsPersistsMCPTaskAgentProfileDefault(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{
MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultWorkspaceDefault,
+49
View File
@@ -0,0 +1,49 @@
import { render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const replaceMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/kanban-with-preview", () => ({
KanbanWithPreview: () => null,
}));
vi.mock("@/components/onboarding-dialog", () => ({
OnboardingDialog: () => null,
}));
vi.mock("@/hooks/use-task-listing-view", () => ({
useTaskListingView: () => ({ preferredView: "list" }),
}));
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ replace: replaceMock }),
}));
import { PageClient } from "./page-client";
afterEach(() => {
replaceMock.mockReset();
});
describe("PageClient", () => {
it("restores List in the resolved workspace", async () => {
render(<PageClient workspaceId="workspace-1" />);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith("/tasks?workspace=workspace-1");
});
});
it("does not restore List while opening a task", async () => {
render(<PageClient workspaceId="workspace-1" initialTaskId="task-1" />);
await waitFor(() => {
expect(replaceMock).not.toHaveBeenCalled();
});
});
it("does not restore List while opening a session", async () => {
render(<PageClient workspaceId="workspace-1" initialSessionId="session-1" />);
await waitFor(() => {
expect(replaceMock).not.toHaveBeenCalled();
});
});
});
+15 -2
View File
@@ -1,18 +1,25 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { KanbanWithPreview } from "@/components/kanban-with-preview";
import { OnboardingDialog } from "@/components/onboarding-dialog";
import { getLocalStorage, setLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { useRouter } from "@/lib/routing/client-router";
import { useTaskListingView } from "@/hooks/use-task-listing-view";
import { linkToTasks } from "@/lib/links";
import { shouldRestoreHomeTaskListingView } from "@/lib/task-listing/view-preference";
type PageClientProps = {
workspaceId?: string;
initialTaskId?: string;
initialSessionId?: string;
};
export function PageClient({ initialTaskId, initialSessionId }: PageClientProps) {
export function PageClient({ workspaceId, initialTaskId, initialSessionId }: PageClientProps) {
const router = useRouter();
const { preferredView } = useTaskListingView();
const [showOnboarding, setShowOnboarding] = useState(() => {
if (typeof window === "undefined") return false;
const completed = getLocalStorage(STORAGE_KEYS.ONBOARDING_COMPLETED, false);
@@ -26,6 +33,12 @@ export function PageClient({ initialTaskId, initialSessionId }: PageClientProps)
setBoardKey((prev) => prev + 1);
};
useEffect(() => {
if (shouldRestoreHomeTaskListingView(preferredView, initialTaskId, initialSessionId)) {
router.replace(linkToTasks(workspaceId));
}
}, [initialSessionId, initialTaskId, preferredView, router, workspaceId]);
return (
<>
<OnboardingDialog open={showOnboarding} onComplete={handleOnboardingComplete} />
+98 -69
View File
@@ -139,6 +139,90 @@ async function loadSnapshotState(
return state;
}
async function loadWorkspaceState({
activeWorkspaceId,
initialState,
settingsWorkflowId,
workflowIdParam,
}: {
activeWorkspaceId: string;
initialState: Partial<AppState>;
settingsWorkflowId: string | null;
workflowIdParam: string | undefined;
}) {
// Fire-and-forget: warm the backend PR cache for this workspace.
// The client will fetch the data after mount via useWorkspacePRs.
listWorkspaceTaskPRs(activeWorkspaceId, { cache: "no-store" }).catch(() => {});
const [workflowList, repositoriesResponse, quickChatResponse] = await Promise.all([
listWorkflows(activeWorkspaceId, { cache: "no-store", includeHidden: true }),
listRepositories(activeWorkspaceId, undefined, { cache: "no-store" }).catch(() => ({
repositories: [],
})),
listQuickChatSessions(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ tasks: [] })),
]);
// null preserves the user's explicit "All Workflows" choice.
const workflowId = resolveDesiredWorkflowId({
activeWorkflowId: workflowIdParam ?? null,
settingsWorkflowId,
workspaceWorkflows: workflowList.workflows,
});
const quickChatSessions = mapQuickChatSessions(quickChatResponse.tasks);
return {
workflowId,
initialState: {
...initialState,
userSettings: {
...(initialState.userSettings as AppState["userSettings"]),
workflowId,
},
workflows: {
items: workflowList.workflows.map((workflow) => ({
id: workflow.id,
workspaceId: workflow.workspace_id,
name: workflow.name,
hidden: workflow.hidden,
})),
activeId: workflowId,
},
repositories: {
itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories },
loadingByWorkspaceId: { [activeWorkspaceId]: false },
loadedByWorkspaceId: { [activeWorkspaceId]: true },
},
quickChat: {
isOpen: false,
sessions: quickChatSessions,
activeSessionId: null,
},
},
};
}
function renderPageClient(
initialState: Partial<AppState>,
workspaceId: string,
taskId?: string,
sessionId?: string,
) {
return (
<>
<StateHydrator initialState={initialState} />
<PageClient workspaceId={workspaceId} initialTaskId={taskId} initialSessionId={sessionId} />
</>
);
}
function renderUnresolvedPage(initialState: Partial<AppState>) {
return (
<>
<StateHydrator initialState={initialState} />
<PageClient />
</>
);
}
export default async function Page({ searchParams }: PageProps) {
try {
const resolvedParams = searchParams ? await searchParams : {};
@@ -172,83 +256,28 @@ export default async function Page({ searchParams }: PageProps) {
settingsWorkspaceId,
);
let initialState = buildBaseState(workspaces, userSettingsResponse, activeWorkspaceId);
const initialState = buildBaseState(workspaces, userSettingsResponse, activeWorkspaceId);
if (!activeWorkspaceId) {
return (
<>
<StateHydrator initialState={initialState} />
<PageClient />
</>
);
return renderUnresolvedPage(initialState);
}
// Fire-and-forget: warm the backend PR cache for this workspace.
// The client will fetch the data after mount via useWorkspacePRs.
listWorkspaceTaskPRs(activeWorkspaceId, { cache: "no-store" }).catch(() => {});
const [workflowList, repositoriesResponse, quickChatResponse] = await Promise.all([
listWorkflows(activeWorkspaceId, { cache: "no-store", includeHidden: true }),
listRepositories(activeWorkspaceId, undefined, { cache: "no-store" }).catch(() => ({
repositories: [],
})),
listQuickChatSessions(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ tasks: [] })),
]);
// null preserves the user's "All Workflows" choice when more than one
// workflow is visible — only auto-pick when there's exactly one.
const workflowId = resolveDesiredWorkflowId({
activeWorkflowId: workflowIdParam ?? null,
const workspaceState = await loadWorkspaceState({
activeWorkspaceId,
initialState,
settingsWorkflowId,
workspaceWorkflows: workflowList.workflows,
workflowIdParam,
});
const quickChatSessions = mapQuickChatSessions(quickChatResponse.tasks);
if (!workspaceState.workflowId)
return renderPageClient(workspaceState.initialState, activeWorkspaceId);
initialState = {
...initialState,
userSettings: {
...(initialState.userSettings as AppState["userSettings"]),
workflowId,
},
workflows: {
items: workflowList.workflows.map((w) => ({
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
hidden: w.hidden,
})),
activeId: workflowId,
},
repositories: {
itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories },
loadingByWorkspaceId: { [activeWorkspaceId]: false },
loadedByWorkspaceId: { [activeWorkspaceId]: true },
},
quickChat: {
isOpen: false,
sessions: quickChatSessions,
activeSessionId: null,
},
};
if (!workflowId) {
return (
<>
<StateHydrator initialState={initialState} />
<PageClient />
</>
);
}
const snapshotState = await loadSnapshotState(workflowId, taskId, sessionId);
initialState = { ...initialState, ...snapshotState };
return (
<>
<StateHydrator initialState={initialState} />
<PageClient initialTaskId={taskId} initialSessionId={sessionId} />
</>
const snapshotState = await loadSnapshotState(workspaceState.workflowId, taskId, sessionId);
return renderPageClient(
{ ...workspaceState.initialState, ...snapshotState },
activeWorkspaceId,
taskId,
sessionId,
);
} catch {
return <PageClient />;
+2 -3
View File
@@ -72,9 +72,8 @@ async function fetchWorkspaceData(
]);
const workflows = workflowsResponse.workflows;
// Preserve "All Workflows" (null/empty saved filter) instead of falling back
// to the first workflow — resolveDesiredWorkflowId handles this consistently
// with the kanban page and the root tasks page.
// Preserve "All Workflows" (null/empty saved filter) consistently with the
// Kanban and root task pages.
const activeWorkflowId = resolveDesiredWorkflowId({
settingsWorkflowId: savedWorkflowId,
workspaceWorkflows: workflows,
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { IconAlertCircle, IconSubtask } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { useTaskPendingInput, type PendingInput } from "@/hooks/use-task-pending-input";
import { getTaskStateIcon } from "@/lib/ui/state-icons";
import type { Repository, Task } from "@/lib/types/http";
import { resolveRichTaskRowDetails } from "./rich-task-row-details";
type RichTaskRowDetails = ReturnType<typeof resolveRichTaskRowDetails>;
function ArchivedBadge({ task }: { task: Task }) {
if (!task.archived_at) return null;
return (
<Badge
variant="outline"
className="shrink-0 border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-500"
>
Archived
</Badge>
);
}
function PrimaryTaskLine({
task,
pendingInput,
showPullRequest,
}: {
task: Task;
pendingInput: PendingInput;
showPullRequest: boolean;
}) {
return (
<>
{getTaskStateIcon(
task.state,
"h-4 w-4 shrink-0",
pendingInput.clarification,
task.foreground_activity,
pendingInput.permission,
)}
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
{showPullRequest && (
<span
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<PRTaskIcon taskId={task.id} />
</span>
)}
<ArchivedBadge task={task} />
</>
);
}
function RichMetadataBadges({ details }: { details: RichTaskRowDetails }) {
if (details.repositoryLabels.length === 0 && !details.sessionCount && !details.parentTitle) {
return null;
}
return (
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1.5 pl-6">
{details.repositoryLabels.map((label) => (
<Badge
key={label}
variant="secondary"
className="max-w-full truncate px-1.5 py-0 text-[10px]"
>
{label}
</Badge>
))}
{details.parentTitle && (
<Badge variant="outline" className="max-w-full gap-1 truncate px-1.5 py-0 text-[10px]">
<IconSubtask className="h-3 w-3 shrink-0" />
<span className="truncate">{details.parentTitle}</span>
</Badge>
)}
{details.sessionCount && (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{details.sessionCount} sessions
</Badge>
)}
</div>
);
}
function ReviewAttention({ attention }: { attention: RichTaskRowDetails["reviewAttention"] }) {
if (attention === "approval_required") {
return (
<div className="mt-1 flex items-center gap-1 pl-6 text-amber-700 dark:text-amber-600">
<IconAlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="text-[10px] font-medium">Approval Required</span>
</div>
);
}
if (attention !== "changes_requested") return null;
return (
<Badge
className="mt-1 ml-6 border-amber-500 bg-amber-50 px-1.5 py-0 text-[10px] text-amber-600 dark:bg-amber-950/50"
variant="outline"
>
Changes Requested
</Badge>
);
}
function RichTaskContent({
task,
details,
level,
pendingInput,
}: {
task: Task;
details: RichTaskRowDetails;
level: number;
pendingInput: PendingInput;
}) {
return (
<div
className="min-w-0 py-1"
data-testid="tasks-list-row-content"
style={{ paddingLeft: `${level * 28}px` }}
>
<div className="flex min-w-0 items-center gap-2">
<PrimaryTaskLine task={task} pendingInput={pendingInput} showPullRequest />
</div>
<RichMetadataBadges details={details} />
{details.description && (
<p className="mt-1 line-clamp-2 pl-6 text-xs leading-tight text-muted-foreground">
{details.description}
</p>
)}
<ReviewAttention attention={details.reviewAttention} />
</div>
);
}
export function TaskListRowPrimaryContent({
task,
level,
repositories,
parentTasks,
showTaskDetails,
}: {
task: Task;
level: number;
repositories: Repository[];
parentTasks: Task[];
showTaskDetails: boolean;
}) {
const pendingInput = useTaskPendingInput(task.primary_session_id, {
taskId: task.id,
taskPendingAction: task.task_pending_action,
primarySessionState: task.primary_session_state,
primarySessionPendingAction: task.primary_session_pending_action,
});
if (showTaskDetails) {
const details = resolveRichTaskRowDetails({ task, repositories, parentTasks });
return (
<RichTaskContent task={task} details={details} level={level} pendingInput={pendingInput} />
);
}
return (
<div
className="flex min-w-0 items-center gap-2"
data-testid="tasks-list-row-content"
style={{ paddingLeft: `${level * 28}px` }}
>
<PrimaryTaskLine task={task} pendingInput={pendingInput} showPullRequest={false} />
</div>
);
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { Repository, Task } from "@/lib/types/http";
import { resolveRichTaskRowDetails } from "./rich-task-row-details";
function task(overrides: Partial<Task>): Task {
return {
id: "task-1",
workspace_id: "workspace-1",
workflow_id: "workflow-1",
workflow_step_id: "step-1",
position: 0,
title: "Child task",
description: "",
state: "TODO",
priority: 0,
created_at: "2026-07-25T00:00:00Z",
updated_at: "2026-07-25T00:00:00Z",
...overrides,
} as Task;
}
function repository(overrides: Partial<Repository>): Repository {
return {
id: "repo-1",
workspace_id: "workspace-1",
name: "",
source_type: "github",
local_path: "/work/kandev",
provider: "github",
provider_repo_id: "1",
provider_owner: "kdlbs",
provider_name: "kandev",
default_branch: "main",
...overrides,
} as Repository;
}
describe("resolveRichTaskRowDetails", () => {
it("derives safe row metadata without local paths", () => {
const details = resolveRichTaskRowDetails({
task: task({
description: "Explain the work clearly.",
session_count: 2,
review_status: "changes_requested",
parent_id: "parent-1" as Task["id"],
repositories: [
{ repository_id: "repo-1" as Task["id"], position: 0 },
{ repository_id: "missing-repo" as Task["id"], position: 1 },
] as unknown as Task["repositories"],
}),
repositories: [repository({ local_path: "/private/project/kandev" })],
parentTasks: [task({ id: "parent-1" as Task["id"], title: "Parent task" })],
});
expect(details).toEqual({
repositoryLabels: ["kdlbs/kandev"],
description: "Explain the work clearly.",
sessionCount: 2,
parentTitle: "Parent task",
reviewAttention: "changes_requested",
});
});
it("deduplicates repeated repository links", () => {
const details = resolveRichTaskRowDetails({
task: task({
repositories: [
{ repository_id: "repo-1" as Task["id"], position: 0 },
{ repository_id: "repo-1" as Task["id"], position: 1 },
] as unknown as Task["repositories"],
}),
repositories: [repository({})],
parentTasks: [],
});
expect(details.repositoryLabels).toEqual(["kdlbs/kandev"]);
});
it("omits a repository badge when only a local path is available", () => {
const details = resolveRichTaskRowDetails({
task: task({
repositories: [
{ repository_id: "repo-1" as Task["id"], position: 0 },
] as unknown as Task["repositories"],
}),
repositories: [
repository({
name: "",
provider_owner: undefined,
provider_name: undefined,
local_path: "/private/engineering/kandev",
}),
],
parentTasks: [],
});
expect(details.repositoryLabels).toEqual([]);
});
});
@@ -0,0 +1,62 @@
import type { Repository, Task } from "@/lib/types/http";
export type RichTaskRowDetails = {
repositoryLabels: string[];
description: string | null;
sessionCount: number | null;
parentTitle: string | null;
reviewAttention: "approval_required" | "changes_requested" | null;
};
function reviewAttentionFor(task: Task): RichTaskRowDetails["reviewAttention"] {
if (task.review_status === "changes_requested") return "changes_requested";
if (task.review_status === "pending" && task.state !== "IN_PROGRESS") return "approval_required";
return null;
}
export function resolveRichTaskRowDetails({
task,
repositories,
parentTasks,
}: {
task: Task;
repositories: Repository[];
parentTasks: Task[];
}): RichTaskRowDetails {
const repositoriesById = new Map(repositories.map((repository) => [repository.id, repository]));
const seenRepositoryIds = new Set<string>();
const seenRepositoryLabels = new Set<string>();
const repositoryLabels: string[] = [];
for (const taskRepository of [...(task.repositories ?? [])].sort(
(a, b) => a.position - b.position,
)) {
if (seenRepositoryIds.has(taskRepository.repository_id)) continue;
seenRepositoryIds.add(taskRepository.repository_id);
const repository = repositoriesById.get(taskRepository.repository_id);
if (!repository) continue;
const label = repositoryLabel(repository);
if (!label) continue;
if (seenRepositoryLabels.has(label)) continue;
seenRepositoryLabels.add(label);
repositoryLabels.push(label);
}
const parentTitle = task.parent_id
? (parentTasks.find((parentTask) => parentTask.id === task.parent_id)?.title ?? "Subtask")
: null;
const reviewAttention = reviewAttentionFor(task);
return {
repositoryLabels,
description: task.description || null,
sessionCount: task.session_count && task.session_count > 1 ? task.session_count : null,
parentTitle,
reviewAttention,
};
}
function repositoryLabel(repository: Repository): string | null {
if (repository.provider_owner && repository.provider_name) {
return `${repository.provider_owner}/${repository.provider_name}`;
}
return repository.name || null;
}
+16 -2
View File
@@ -49,6 +49,7 @@ function props(tasks: Task[]): TasksListViewProps {
tasks,
workflows: [],
repositories: [],
showTaskDetails: false,
pageCount: 1,
pagination: { pageIndex: 0, pageSize: 25 },
setPagination: () => undefined,
@@ -61,11 +62,15 @@ function props(tasks: Task[]): TasksListViewProps {
};
}
function renderList(task: Task, messagesBySession: Record<string, Message[]> = {}) {
function renderList(
task: Task,
messagesBySession: Record<string, Message[]> = {},
showTaskDetails = false,
) {
return render(
<StateProvider initialState={{ messages: { bySession: messagesBySession, metaBySession: {} } }}>
<TooltipProvider>
<TasksListView {...props([task])} />
<TasksListView {...props([task])} showTaskDetails={showTaskDetails} />
</TooltipProvider>
</StateProvider>,
);
@@ -90,6 +95,15 @@ describe("TasksListView row — waiting-for-input parity", () => {
expect(container.querySelector(".tabler-icon-loader-2")).toBeNull();
});
it("keeps the pending permission indicator when rich rows are enabled", () => {
const { container } = renderList(
makeTask({}),
{ "session-1": [message({ type: "permission_request", metadata: { status: "pending" } })] },
true,
);
expect(container.querySelector(".tabler-icon-shield-question")).not.toBeNull();
});
it("falls back to the boot snapshot pending action when messages are not loaded", () => {
const { container } = renderList(
makeTask({
+32 -37
View File
@@ -2,7 +2,6 @@
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
@@ -13,10 +12,10 @@ import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http";
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
import { getTaskStateIcon, isTaskInFlight } from "@/lib/ui/state-icons";
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
import { isTaskInFlight } from "@/lib/ui/state-icons";
import { formatRelativeTime } from "@/lib/utils";
import { TasksPagination } from "./tasks-pagination";
import { TaskListRowPrimaryContent } from "./rich-task-list-row";
import {
TASKS_LIST_GROUP_OPTIONS,
TASKS_LIST_SORT_OPTIONS,
@@ -36,6 +35,7 @@ export type TasksListViewProps = {
tasks: Task[];
workflows: Workflow[];
repositories: Repository[];
showTaskDetails: boolean;
pageCount: number;
pagination: PaginationState;
setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void;
@@ -58,6 +58,7 @@ export function TasksListView({
tasks,
workflows,
repositories,
showTaskDetails,
pageCount,
pagination,
setPagination,
@@ -83,6 +84,7 @@ export function TasksListView({
tasks={tasks}
workflows={workflows}
repositories={repositories}
showTaskDetails={showTaskDetails}
tasksListGroup={tasksListGroup}
isLoading={isLoading}
deletingTaskId={deletingTaskId}
@@ -193,6 +195,7 @@ function TaskRows({
tasks,
workflows,
repositories,
showTaskDetails,
tasksListGroup,
isLoading,
deletingTaskId,
@@ -204,6 +207,7 @@ function TaskRows({
tasks: Task[];
workflows: Workflow[];
repositories: Repository[];
showTaskDetails: boolean;
tasksListGroup: TasksListGroup;
isLoading: boolean;
deletingTaskId: string | null;
@@ -245,6 +249,9 @@ function TaskRows({
onUnarchive={onUnarchive}
onDelete={onDelete}
onRowClick={onRowClick}
repositories={repositories}
parentTasks={tasks}
showTaskDetails={showTaskDetails}
/>
))}
</div>
@@ -361,6 +368,9 @@ function flattenTaskTree(nodes: TaskTreeNode[]): TaskTreeNode[] {
function TaskListRow({
task,
level,
repositories,
parentTasks,
showTaskDetails,
deletingTaskId,
onArchive,
onUnarchive,
@@ -369,6 +379,9 @@ function TaskListRow({
}: {
task: Task;
level: number;
repositories: Repository[];
parentTasks: Task[];
showTaskDetails: boolean;
deletingTaskId: string | null;
onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
onUnarchive: (taskId: string) => Promise<void>;
@@ -379,16 +392,6 @@ function TaskListRow({
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const isDeleting = deletingTaskId === task.id;
const isArchived = !!task.archived_at;
// Carry the sidebar's rich "needs me" reading to the list row: derive the
// message-derived pending-clarification / pending-permission flags (with the
// boot-payload snapshot fallback) instead of collapsing to the coarse state
// Waiting-for-input takes precedence over the work-state indicator.
const pendingInput = useTaskPendingInput(task.primary_session_id, {
taskId: task.id,
taskPendingAction: task.task_pending_action,
primarySessionState: task.primary_session_state,
primarySessionPendingAction: task.primary_session_pending_action,
});
return (
<div
@@ -406,30 +409,13 @@ function TaskListRow({
}
}}
>
<div
className="flex min-w-0 items-center gap-2"
data-testid="tasks-list-row-content"
style={{ paddingLeft: `${level * 28}px` }}
>
{getTaskStateIcon(
task.state,
"h-4 w-4 shrink-0",
pendingInput.clarification,
task.foreground_activity,
pendingInput.permission,
)}
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
{task.title}
</span>
{isArchived && (
<Badge
variant="outline"
className="shrink-0 border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-500"
>
Archived
</Badge>
)}
</div>
<TaskListRowPrimaryContent
task={task}
level={level}
repositories={repositories}
parentTasks={parentTasks}
showTaskDetails={showTaskDetails}
/>
<div className="flex items-center justify-between gap-3 md:justify-end">
<span className="hidden text-xs text-muted-foreground sm:inline">
{formatRelativeTime(task.updated_at)}
@@ -453,6 +439,9 @@ function TaskListRow({
function TaskListSectionView({
section,
repositories,
parentTasks,
showTaskDetails,
deletingTaskId,
onArchive,
onUnarchive,
@@ -460,6 +449,9 @@ function TaskListSectionView({
onRowClick,
}: {
section: TaskListSection;
repositories: Repository[];
parentTasks: Task[];
showTaskDetails: boolean;
deletingTaskId: string | null;
onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
onUnarchive: (taskId: string) => Promise<void>;
@@ -481,6 +473,9 @@ function TaskListSectionView({
key={task.id}
task={task}
level={level}
repositories={repositories}
parentTasks={parentTasks}
showTaskDetails={showTaskDetails}
deletingTaskId={deletingTaskId}
onArchive={onArchive}
onUnarchive={onUnarchive}
+10
View File
@@ -18,6 +18,8 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { useDebounce } from "@/hooks/use-debounce";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useTaskListingView } from "@/hooks/use-task-listing-view";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { linkToTask } from "@/lib/links";
import { unarchiveToastPayload } from "@/lib/tasks/unarchive-feedback";
import { shouldSkipInitialTasksFetch } from "./tasks-page-fetch-policy";
@@ -485,9 +487,12 @@ function useTasksListPreferenceSync({
export function TasksPageClient(props: TasksPageClientProps) {
const s = useTasksPageSetup(props);
const { setView } = useTaskListingView();
const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
const { isMobile } = useResponsiveBreakpoint();
const showTaskDetails = useAppStore((state) => state.userSettings.tasksListShowDetails ?? false);
useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);
const { handleSortChange, handleGroupChange } = useTasksListPreferenceSync({
tasksListSort: s.tasksListSort,
setTasksListSort: s.setTasksListSort,
@@ -502,6 +507,10 @@ export function TasksPageClient(props: TasksPageClientProps) {
return () => setMobileSearchOpen(false);
}, [setMobileSearchOpen]);
useEffect(() => {
setView("list");
}, [setView]);
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<KanbanHeader
@@ -525,6 +534,7 @@ export function TasksPageClient(props: TasksPageClientProps) {
tasks={s.tasks}
workflows={s.workflows}
repositories={s.repositories}
showTaskDetails={showTaskDetails}
total={s.total}
pageCount={s.pageCount}
pagination={s.pagination}
@@ -18,6 +18,7 @@ import type { ComponentProps } from "react";
type KanbanDisplayDropdownProps = {
triggerSize?: ComponentProps<typeof Button>["size"];
currentPage?: "kanban" | "tasks";
};
function getRepositoryPlaceholder(
@@ -98,7 +99,10 @@ function RepositorySection({
);
}
export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDropdownProps) {
export function KanbanDisplayDropdown({
triggerSize = "icon",
currentPage = "kanban",
}: KanbanDisplayDropdownProps) {
const {
workflows,
activeWorkflowId,
@@ -107,9 +111,11 @@ export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDro
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
tasksListShowDetails,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
onToggleTasksListShowDetails,
} = useKanbanDisplaySettings();
const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all");
@@ -157,6 +163,24 @@ export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDro
navigates directly to the session.
</p>
</div>
{currentPage === "tasks" && (
<>
<DropdownMenuSeparator />
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">List rows</DropdownMenuLabel>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={tasksListShowDetails}
onCheckedChange={(checked) => onToggleTasksListShowDetails(checked === true)}
/>
<span className="text-sm text-foreground">Show task details</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
Add repository, pull request, session, parent, and review context to List rows.
</p>
</div>
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
+7 -12
View File
@@ -113,12 +113,6 @@ function ViewToggleGroup({
);
}
function getToggleValue(currentPage: string, kanbanViewMode: string | null): string {
if (currentPage === "tasks") return "list";
if (kanbanViewMode === "graph2") return "pipeline";
return "kanban";
}
function useIsHeaderNarrow(ref: RefObject<HTMLElement | null>): boolean {
const [isNarrow, setIsNarrow] = useState(false);
@@ -207,7 +201,7 @@ function TabletHeader({
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
<KanbanDisplayDropdown triggerSize="icon-lg" />
<KanbanDisplayDropdown triggerSize="icon-lg" currentPage={currentPage} />
<HealthIndicatorButton
hasIssues={showHealthIndicator}
onClick={onOpenHealthDialog}
@@ -292,7 +286,7 @@ function DesktopHeader({
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
<KanbanDisplayDropdown triggerSize="icon-lg" />
<KanbanDisplayDropdown triggerSize="icon-lg" currentPage={currentPage} />
<HealthIndicatorButton
hasIssues={showHealthIndicator}
onClick={onOpenHealthDialog}
@@ -312,13 +306,14 @@ function useHeaderViewChange(
const router = useRouter();
return (value: string) => {
if (value === "list") {
onViewModeChange("list");
if (currentPage !== "tasks") router.push(linkToTasks(workspaceId));
} else if (value === "kanban") {
onViewModeChange("kanban");
if (currentPage !== "kanban") router.push("/");
onViewModeChange("");
} else if (value === "pipeline") {
onViewModeChange("pipeline");
if (currentPage !== "kanban") router.push("/");
onViewModeChange("graph2");
}
};
}
@@ -334,11 +329,11 @@ export function KanbanHeader({
const { isMobile, isTablet } = useResponsiveBreakpoint();
const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen);
const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen);
const { kanbanViewMode, onViewModeChange, workspaces, activeWorkspaceId } =
const { effectiveTaskListingView, onViewModeChange, workspaces, activeWorkspaceId } =
useKanbanDisplaySettings();
const releaseNotes = useReleaseNotes();
const healthIndicator = useSystemHealthIndicator();
const toggleValue = getToggleValue(currentPage, kanbanViewMode);
const toggleValue = currentPage === "tasks" ? "list" : effectiveTaskListingView;
const handleViewChange = useHeaderViewChange(currentPage, workspaceId, onViewModeChange);
const title = getHeaderTitle(currentPage);
const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId);
@@ -53,16 +53,6 @@ function getRepositoryPlaceholder(loading: boolean, empty: boolean): string {
return "Select repository";
}
function getMobileViewValue(
currentPage: string,
kanbanViewMode: string | null,
isMobile: boolean,
): string {
if (currentPage === "tasks") return "list";
if (!isMobile && kanbanViewMode === "graph2") return "pipeline";
return "kanban";
}
type MobileDisplayOptionsProps = {
activeWorkflowId: string | null;
workflows: WorkflowsState["items"];
@@ -73,6 +63,9 @@ type MobileDisplayOptionsProps = {
onRepositoryChange: (value: string | "all") => void;
enablePreviewOnClick: boolean | undefined;
onTogglePreviewOnClick: ((checked: boolean) => void) | undefined;
tasksListShowDetails: boolean;
onToggleTasksListShowDetails: (checked: boolean) => void;
showTaskDetails: boolean;
showWorkflow: boolean;
};
@@ -85,7 +78,14 @@ function MobileDisplaySelects({
repositoriesLoading,
onRepositoryChange,
showWorkflow,
}: Omit<MobileDisplayOptionsProps, "enablePreviewOnClick" | "onTogglePreviewOnClick">) {
}: Omit<
MobileDisplayOptionsProps,
| "enablePreviewOnClick"
| "onTogglePreviewOnClick"
| "tasksListShowDetails"
| "onToggleTasksListShowDetails"
| "showTaskDetails"
>) {
return (
<>
{showWorkflow && (
@@ -137,7 +137,14 @@ function MobileDisplaySelects({
}
function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
const { enablePreviewOnClick, onTogglePreviewOnClick, ...selectProps } = props;
const {
enablePreviewOnClick,
onTogglePreviewOnClick,
tasksListShowDetails,
onToggleTasksListShowDetails,
showTaskDetails,
...selectProps
} = props;
return (
<div className="space-y-4">
<label className={mobileSectionTitleClass}>Display Options</label>
@@ -154,6 +161,21 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) {
<span className="text-sm">Open preview on click</span>
</label>
</div>
{showTaskDetails && (
<div className={mobileFieldClass}>
<label className={mobileFieldLabelClass}>List rows</label>
<label className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-0 text-sm font-medium">
<Checkbox
checked={tasksListShowDetails}
onCheckedChange={(checked) => onToggleTasksListShowDetails(checked === true)}
/>
<span>Show task details</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
Add repository, pull request, session, parent, and review context to List rows.
</p>
</div>
)}
</div>
);
}
@@ -387,28 +409,31 @@ export function MobileMenuSheet({
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
tasksListShowDetails,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
kanbanViewMode,
onToggleTasksListShowDetails,
effectiveTaskListingView,
onViewModeChange,
} = useKanbanDisplaySettings();
const repositoryValue = allRepositoriesSelected ? "all" : (selectedRepositoryId ?? "all");
const viewValue = getMobileViewValue(currentPage, kanbanViewMode, isMobile);
const viewValue = currentPage === "tasks" ? "list" : effectiveTaskListingView;
const handleViewChange = (value: string) => {
if (!value) return;
if (value === "list") {
onViewModeChange("list");
if (currentPage !== "tasks") router.push(linkToTasks(workspaceId));
onOpenChange(false);
} else if (value === "kanban") {
onViewModeChange("kanban");
if (currentPage !== "kanban") router.push("/");
if (!isMobile) onViewModeChange("");
onOpenChange(false);
} else if (value === "pipeline" && !isMobile) {
onViewModeChange("pipeline");
if (currentPage !== "kanban") router.push("/");
onViewModeChange("graph2");
onOpenChange(false);
}
};
@@ -437,6 +462,9 @@ export function MobileMenuSheet({
onRepositoryChange={onRepositoryChange}
enablePreviewOnClick={enablePreviewOnClick}
onTogglePreviewOnClick={onTogglePreviewOnClick}
tasksListShowDetails={tasksListShowDetails}
onToggleTasksListShowDetails={onToggleTasksListShowDetails}
showTaskDetails={currentPage === "tasks"}
showWorkflow={!isMobile || currentPage !== "kanban"}
/>
@@ -236,6 +236,7 @@ function mapTasksListSettingsFields(
return {
tasksListSort: parseTasksListSort(s.tasks_list_sort),
tasksListGroup: parseTasksListGroup(s.tasks_list_group),
tasksListShowDetails: s.tasks_list_show_details ?? false,
};
}
+2
View File
@@ -733,6 +733,7 @@ export class ApiClient {
terminal_font_family?: string;
terminal_font_size?: number;
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
tasks_list_show_details?: boolean;
[key: string]: unknown;
};
}> {
@@ -755,6 +756,7 @@ export class ApiClient {
sidebar_views?: unknown[];
saved_layouts?: unknown[];
kanban_view_mode?: string;
tasks_list_show_details?: boolean;
tasks_list_sort?: string;
tasks_list_group?: string;
task_create_last_used?: TaskCreateLastUsedApi;
@@ -14,6 +14,8 @@ import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
const TASK_LISTING_VIEW_STORAGE_KEY = "kandev.taskListing.view.v1";
test.describe("Quick Chat entry points on mobile", () => {
test("opens from the home header and closes with the touch control", async ({ testPage }) => {
await testPage.goto("/");
@@ -69,10 +71,14 @@ test.describe("Quick Chat entry points on mobile", () => {
await assertNoDocumentHorizontalOverflow(testPage);
});
test("returns to the selected workspace home from the Tasks header", async ({
test("preserves the selected workspace when Home restores List", async ({
testPage,
seedData,
}) => {
await testPage.addInitScript(
(key) => window.localStorage.setItem(key, JSON.stringify("list")),
TASK_LISTING_VIEW_STORAGE_KEY,
);
await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`);
await testPage.waitForLoadState("networkidle");
@@ -82,10 +88,11 @@ test.describe("Quick Chat entry points on mobile", () => {
await homeLink.click();
await expect(testPage).toHaveURL((url) => {
return url.pathname === "/" && url.searchParams.get("workspaceId") === seedData.workspaceId;
return (
url.pathname === "/tasks" && url.searchParams.get("workspace") === seedData.workspaceId
);
});
const homeHeader = testPage.getByRole("link", { name: "Kandev home" }).locator("..");
await expect(homeHeader.getByText("Home", { exact: true })).toHaveCount(0);
await expect(testPage.getByText("No tasks found.", { exact: true })).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage);
});
});
@@ -69,6 +69,32 @@ test.describe("Kanban workflow filter", () => {
});
});
test("keeps All Workflows selected for a one-workflow workspace after reopening and reload", async ({
testPage,
apiClient,
seedData,
}) => {
if (workflowBId) {
await apiClient.deleteWorkflow(workflowBId);
workflowBId = null;
}
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await selectWorkflowFilter(testPage, "All Workflows");
await testPage.getByTestId("display-button").click();
await expect(testPage.getByTestId("display-workflow-filter")).toContainText("All Workflows");
await closeDisplayDropdown(testPage);
await testPage.reload();
await testPage.getByTestId("display-button").click();
await expect(testPage.getByTestId("display-workflow-filter")).toContainText("All Workflows");
});
// Regression: c64e835 made resolveDesiredWorkflowId fall back to the first
// visible workflow whenever both the active id and persisted setting were
// null. The kanban page's useWorkflowSelection effect then silently
@@ -0,0 +1,77 @@
import { test, expect } from "../../fixtures/test-base";
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
const VIEW_STORAGE_KEY = "kandev.taskListing.view.v1";
const TASK_TITLE = "Mobile rich task";
const TASK_DESCRIPTION = "Mobile details must stay readable without page overflow.";
const SEEDED_REPOSITORY_LABEL = "E2E Repo";
test.describe("Mobile task listing display preferences", () => {
test("selects List from the drawer, restores Home, and navigates a rich row without overflow", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, TASK_TITLE, {
description: TASK_DESCRIPTION,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "kandev",
repo: "e2e-repo",
pr_number: 43,
pr_url: "https://github.com/kandev/e2e-repo/pull/43",
pr_title: "Mobile details",
head_branch: "mobile-details",
base_branch: "main",
author_login: "e2e",
state: "open",
checks_state: "success",
mergeable_state: "clean",
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
await mobile.mobileMenuButton.click();
const menu = testPage.getByRole("dialog", { name: "Menu" });
await menu.getByRole("radio", { name: "List", exact: true }).click();
await expect(testPage).toHaveURL(/\/tasks/);
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
await testPage.goto("/");
await expect(testPage).toHaveURL(/\/tasks/);
await testPage.getByRole("button", { name: "Open menu" }).click();
const tasksMenu = testPage.getByRole("dialog", { name: "Menu" });
await tasksMenu.getByText("Show task details", { exact: true }).click();
await testPage.keyboard.press("Escape");
await expect(tasksMenu).toHaveCount(0);
const row = testPage.getByTestId("tasks-list-row").filter({ hasText: TASK_TITLE });
await expect(row).toContainText(SEEDED_REPOSITORY_LABEL);
await expect(row).toContainText(TASK_DESCRIPTION);
await expect(row.getByTestId(`pr-task-icon-${task.id}`)).toBeVisible();
await expect(
testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).resolves.toBe(true);
await row.getByTestId("tasks-list-row-content").click();
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`));
});
test("uses Kanban on a phone without overwriting a saved Pipeline preference", async ({
testPage,
}) => {
await testPage.addInitScript(
([key, value]) => window.localStorage.setItem(key, value),
[VIEW_STORAGE_KEY, JSON.stringify("pipeline")],
);
await testPage.goto("/");
await expect(testPage.getByTestId("mobile-kanban-layout")).toBeVisible();
await expect(
testPage.evaluate((key) => window.localStorage.getItem(key), VIEW_STORAGE_KEY),
).resolves.toBe(JSON.stringify("pipeline"));
});
});
@@ -0,0 +1,83 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
const RICH_TASK_TITLE = "Portable rich task";
const RICH_TASK_DESCRIPTION = "Rich rows retain this useful task context.";
const SEEDED_REPOSITORY_LABEL = "E2E Repo";
test.describe("Task listing display preferences", () => {
test("restores Pipeline and List from the device-local view preference", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.createTask(seedData.workspaceId, "View restoration task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto("/");
const kanban = new KanbanPage(testPage);
await expect(kanban.board).toBeVisible();
await kanban.viewTogglePipeline.click();
await expect(kanban.viewTogglePipeline).toHaveAttribute("data-state", "on");
await testPage.reload();
await expect(kanban.viewTogglePipeline).toHaveAttribute("data-state", "on");
await kanban.viewToggleKanban.click();
await expect(kanban.viewToggleKanban).toHaveAttribute("data-state", "on");
await testPage.getByTestId("view-toggle-list").click();
await expect(testPage).toHaveURL(/\/tasks/);
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
await testPage.goto("/");
await expect(testPage).toHaveURL(/\/tasks/);
await expect(testPage.getByTestId("tasks-list")).toBeVisible();
});
test("persists rich List rows through the backend setting", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTask(seedData.workspaceId, RICH_TASK_TITLE, {
description: RICH_TASK_DESCRIPTION,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "kandev",
repo: "e2e-repo",
pr_number: 42,
pr_url: "https://github.com/kandev/e2e-repo/pull/42",
pr_title: "Portable details",
head_branch: "details",
base_branch: "main",
author_login: "e2e",
state: "open",
checks_state: "success",
mergeable_state: "clean",
});
await testPage.goto("/tasks");
const row = testPage.getByTestId("tasks-list-row").filter({ hasText: RICH_TASK_TITLE });
await expect(row).toBeVisible();
await expect(row).not.toContainText(RICH_TASK_DESCRIPTION);
await testPage.getByTestId("display-button").click();
await testPage.getByText("Show task details", { exact: true }).click();
await expect(row).toContainText(SEEDED_REPOSITORY_LABEL);
await expect(row).toContainText(RICH_TASK_DESCRIPTION);
await expect(row.getByTestId(`pr-task-icon-${task.id}`)).toBeVisible();
await testPage.reload();
await expect(row).toContainText(SEEDED_REPOSITORY_LABEL);
await expect(row).toContainText(RICH_TASK_DESCRIPTION);
await expect(row.getByTestId(`pr-task-icon-${task.id}`)).toBeVisible();
await expect
.poll(async () => (await apiClient.getUserSettings()).settings.tasks_list_show_details)
.toBe(true);
});
});
@@ -52,6 +52,7 @@ function makeUnloadedSettings(): UserSettingsState {
repositoryIds: [],
tasksListSort: "updated_desc",
tasksListGroup: "state",
tasksListShowDetails: false,
preferredShell: null,
shellOptions: [],
defaultEditorId: null,
+24 -7
View File
@@ -3,6 +3,7 @@
import { useCallback } from "react";
import { useAppStore } from "@/components/state-provider";
import { useUserDisplaySettings } from "@/hooks/use-user-display-settings";
import { useTaskListingView } from "@/hooks/use-task-listing-view";
import type { WorkflowsState } from "@/lib/state/slices";
type UserSettingsFields = {
@@ -20,13 +21,27 @@ function baseSettingsPayload(settings: UserSettingsFields): UserSettingsFields {
};
}
function taskListingViewFor(mode: string): "kanban" | "pipeline" | "list" {
if (mode === "graph2" || mode === "pipeline") return "pipeline";
if (mode === "list") return "list";
return "kanban";
}
function useViewModeChange() {
const { effectiveView, setView } = useTaskListingView();
const onViewModeChange = useCallback(
(mode: string) => setView(taskListingViewFor(mode)),
[setView],
);
return { effectiveView, onViewModeChange };
}
/**
* Custom hook that consolidates all kanban display settings and eliminates prop drilling.
* This hook provides access to workspaces, workflows, repositories, and preview settings,
* along with handlers for changing these settings.
*/
export function useKanbanDisplaySettings() {
// Access store directly
const workspaces = useAppStore((state) => state.workspaces.items);
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const workflows = useAppStore((state) => state.workflows.items);
@@ -46,7 +61,6 @@ export function useKanbanDisplaySettings() {
workflowId: activeWorkflowId,
});
// Get preview setting from store
const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick);
// Use pushState instead of router.push to avoid triggering SSR re-fetches.
@@ -108,12 +122,13 @@ export function useKanbanDisplaySettings() {
[commitSettings, userSettings],
);
const handleViewModeChange = useCallback(
(mode: string) => {
commitSettings({ ...baseSettingsPayload(userSettings), kanbanViewMode: mode || null });
const handleToggleTasksListShowDetails = useCallback(
(enabled: boolean) => {
commitSettings({ ...baseSettingsPayload(userSettings), tasksListShowDetails: enabled });
},
[commitSettings, userSettings],
);
const { effectiveView, onViewModeChange } = useViewModeChange();
return {
// Data
@@ -126,13 +141,15 @@ export function useKanbanDisplaySettings() {
allRepositoriesSelected,
selectedRepositoryId: userSettings.repositoryIds[0] ?? null,
enablePreviewOnClick,
kanbanViewMode: userSettings.kanbanViewMode,
tasksListShowDetails: userSettings.tasksListShowDetails ?? false,
effectiveTaskListingView: effectiveView,
// Handlers
onWorkspaceChange: handleWorkspaceChange,
onWorkflowChange: handleWorkflowChange,
onRepositoryChange: handleRepositoryChange,
onTogglePreviewOnClick: handleTogglePreviewOnClick,
onViewModeChange: handleViewModeChange,
onToggleTasksListShowDetails: handleToggleTasksListShowDetails,
onViewModeChange,
};
}
@@ -0,0 +1,72 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultState } from "@/lib/state/default-state";
import type { AppState } from "@/lib/state/store";
import {
TASK_LISTING_VIEW_CHANGE_EVENT,
TASK_LISTING_VIEW_STORAGE_KEY,
} from "@/lib/task-listing/view-preference";
const mocks = vi.hoisted(() => ({
isMobile: false,
state: null as AppState | null,
setUserSettings: vi.fn(),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: <T,>(selector: (state: AppState) => T) => selector(mocks.state!),
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: mocks.isMobile }),
}));
import { useTaskListingView } from "./use-task-listing-view";
function resetMocks() {
mocks.isMobile = false;
mocks.state = structuredClone(defaultState) as AppState;
mocks.state.setUserSettings = mocks.setUserSettings;
mocks.setUserSettings.mockReset();
window.localStorage.clear();
}
afterEach(resetMocks);
beforeEach(resetMocks);
describe("useTaskListingView", () => {
it("updates from local custom and storage events", () => {
const { result } = renderHook(() => useTaskListingView());
expect(result.current.preferredView).toBe("kanban");
act(() => {
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"pipeline"');
window.dispatchEvent(new CustomEvent(TASK_LISTING_VIEW_CHANGE_EVENT));
});
expect(result.current.preferredView).toBe("pipeline");
act(() => {
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"list"');
const storageEvent = new Event("storage");
Object.defineProperty(storageEvent, "key", { value: TASK_LISTING_VIEW_STORAGE_KEY });
window.dispatchEvent(storageEvent);
});
expect(result.current.preferredView).toBe("list");
});
it("syncs mobile Pipeline fallback as Kanban without overwriting the saved preference", async () => {
mocks.isMobile = true;
mocks.state!.userSettings.kanbanViewMode = "graph2";
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"pipeline"');
const { result } = renderHook(() => useTaskListingView());
expect(result.current.preferredView).toBe("pipeline");
expect(result.current.effectiveView).toBe("kanban");
await waitFor(() => {
expect(mocks.setUserSettings).toHaveBeenCalledWith(
expect.objectContaining({ kanbanViewMode: null, loaded: true }),
);
});
});
});
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
getEffectiveTaskListingView,
getStoredTaskListingView,
resolveTaskListingView,
setStoredTaskListingView,
TASK_LISTING_VIEW_CHANGE_EVENT,
type TaskListingView,
} from "@/lib/task-listing/view-preference";
function subscribe(callback: () => void) {
window.addEventListener(TASK_LISTING_VIEW_CHANGE_EVENT, callback);
window.addEventListener("storage", callback);
return () => {
window.removeEventListener(TASK_LISTING_VIEW_CHANGE_EVENT, callback);
window.removeEventListener("storage", callback);
};
}
export function useTaskListingView() {
const { isMobile } = useResponsiveBreakpoint();
const legacyKanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode);
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const preferredView = useSyncExternalStore(
subscribe,
() => resolveTaskListingView(getStoredTaskListingView(), legacyKanbanViewMode),
() => "kanban" as TaskListingView,
);
const effectiveView = getEffectiveTaskListingView(preferredView, isMobile);
useEffect(() => {
const kanbanViewMode = effectiveView === "pipeline" ? "graph2" : null;
if (userSettings.kanbanViewMode === kanbanViewMode) return;
setUserSettings({ ...userSettings, kanbanViewMode, loaded: true });
}, [effectiveView, setUserSettings, userSettings]);
const setView = useCallback((view: TaskListingView) => {
setStoredTaskListingView(view);
}, []);
return { preferredView, effectiveView, setView };
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { UserSettingsState } from "@/lib/state/slices/settings/types";
import { isSettingsUnchanged } from "./use-user-display-settings";
function settings(tasksListShowDetails: boolean): UserSettingsState {
return {
loaded: true,
workspaceId: "workspace-1",
workflowId: null,
repositoryIds: [],
tasksListShowDetails,
} as unknown as UserSettingsState;
}
describe("isSettingsUnchanged", () => {
it("detects a details-only preference change", () => {
expect(isSettingsUnchanged(settings(true), settings(false))).toBe(false);
});
});
+9 -5
View File
@@ -28,6 +28,7 @@ type CommitPayload = {
repositoryIds: string[];
preferredShell?: string | null;
enablePreviewOnClick?: boolean;
tasksListShowDetails?: boolean;
kanbanViewMode?: string | null;
};
@@ -135,23 +136,26 @@ function buildNormalizedSettings(next: CommitPayload, current: DisplaySettings):
return {
workspaceId: next.workspaceId,
workflowId: next.workflowId,
kanbanViewMode:
next.kanbanViewMode !== undefined ? next.kanbanViewMode : (current.kanbanViewMode ?? null),
kanbanViewMode: current.kanbanViewMode ?? null,
repositoryIds: Array.from(new Set(next.repositoryIds)).sort(),
preferredShell: next.preferredShell ?? current.preferredShell ?? null,
enablePreviewOnClick: next.enablePreviewOnClick ?? current.enablePreviewOnClick,
tasksListShowDetails: next.tasksListShowDetails ?? current.tasksListShowDetails ?? false,
...carryForwardSettings(current),
loaded: true,
};
}
function isSettingsUnchanged(normalized: DisplaySettings, current: DisplaySettings): boolean {
export function isSettingsUnchanged(
normalized: DisplaySettings,
current: DisplaySettings,
): boolean {
if (!current.loaded) return false;
return (
normalized.workspaceId === current.workspaceId &&
normalized.workflowId === current.workflowId &&
normalized.enablePreviewOnClick === current.enablePreviewOnClick &&
normalized.kanbanViewMode === current.kanbanViewMode &&
normalized.tasksListShowDetails === current.tasksListShowDetails &&
normalized.repositoryIds.length === current.repositoryIds.length &&
normalized.repositoryIds.every((id, index) => id === current.repositoryIds[index])
);
@@ -238,7 +242,7 @@ export function useUserDisplaySettings({
workflow_filter_id: normalized.workflowId ?? "",
repository_ids: normalized.repositoryIds,
enable_preview_on_click: normalized.enablePreviewOnClick,
kanban_view_mode: normalized.kanbanViewMode ?? "",
tasks_list_show_details: normalized.tasksListShowDetails,
};
persistSettingsPayload(payload);
},
+5 -5
View File
@@ -43,22 +43,22 @@ describe("resolveDesiredWorkflowId", () => {
expect(result).toBeNull();
});
it("auto-selects the only visible workflow when exactly one exists", () => {
it("preserves All Workflows when exactly one workflow is visible", () => {
const result = resolveDesiredWorkflowId({
activeWorkflowId: null,
settingsWorkflowId: null,
workspaceWorkflows: [workflow("wf-only")],
});
expect(result).toBe("wf-only");
expect(result).toBeNull();
});
it("ignores hidden workflows when resolving the fallback", () => {
it("ignores hidden workflows when preserving an empty filter", () => {
const result = resolveDesiredWorkflowId({
activeWorkflowId: null,
settingsWorkflowId: null,
workspaceWorkflows: [workflow("wf-1", { hidden: true }), workflow("wf-2")],
});
expect(result).toBe("wf-2");
expect(result).toBeNull();
});
it("does not honor an active id that is no longer visible", () => {
@@ -94,7 +94,7 @@ describe("resolveDesiredWorkflowId", () => {
settingsWorkflowId: "wf-hidden",
workspaceWorkflows: [workflow("wf-hidden", { hidden: true }), workflow("wf-visible")],
});
expect(result).toBe("wf-visible");
expect(result).toBeNull();
});
});
-3
View File
@@ -50,8 +50,6 @@ export function resolveBoardWorkflowSteps<TStep>({
*
* `null` is a valid "All Workflows" selection: when the user has explicitly
* cleared the filter, we must not silently fall back to the first workflow.
* Auto-selecting only happens when there is exactly one visible workflow —
* otherwise the user would never be able to keep "All Workflows" picked.
*/
export function resolveDesiredWorkflowId({
activeWorkflowId,
@@ -72,6 +70,5 @@ export function resolveDesiredWorkflowId({
) {
return settingsWorkflowId;
}
if (visibleWorkflows.length === 1) return visibleWorkflows[0].id;
return null;
}
+20 -3
View File
@@ -9,6 +9,7 @@ import {
import { workspaceId as toWorkspaceId } from "@/lib/types/ids";
const UPDATED_AT = "2026-01-01T00:00:00Z";
const DEFAULT_USER_ID = "default-user";
describe("buildCoreFields", () => {
it("normalizes the simplified metrics preference and defaults old rows to detailed", () => {
@@ -143,6 +144,22 @@ describe("buildTerminalFields via buildCoreFields", () => {
});
describe("mapUserSettingsResponse", () => {
it("maps the portable task-list details preference and defaults it to false", () => {
expect(mapUserSettingsResponse(null).tasksListShowDetails).toBe(false);
const result = mapUserSettingsResponse({
settings: {
user_id: DEFAULT_USER_ID,
workspace_id: toWorkspaceId(""),
repository_ids: [],
tasks_list_show_details: true,
updated_at: UPDATED_AT,
},
});
expect(result.tasksListShowDetails).toBe(true);
});
it("defaults portable app status bar order to empty arrays", () => {
expect(mapUserSettingsResponse(null).appStatusBarOrder).toEqual({
leftItemIds: [],
@@ -154,7 +171,7 @@ describe("mapUserSettingsResponse", () => {
const missing = mapUserSettingsResponse(null);
const unknown = mapUserSettingsResponse({
settings: {
user_id: "default-user",
user_id: DEFAULT_USER_ID,
workspace_id: toWorkspaceId(""),
repository_ids: [],
mcp_task_agent_profile_default: "unexpected",
@@ -173,7 +190,7 @@ describe("mapUserSettingsResponse", () => {
it("preserves an explicitly disabled archive confirmation preference", () => {
const result = mapUserSettingsResponse({
settings: {
user_id: "default-user",
user_id: DEFAULT_USER_ID,
workspace_id: toWorkspaceId(""),
repository_ids: [],
confirm_task_archive: false,
@@ -197,7 +214,7 @@ describe("mapUserSettingsResponse", () => {
it("maps sidebar active view and draft state", () => {
const result = mapUserSettingsResponse({
settings: {
user_id: "default-user",
user_id: DEFAULT_USER_ID,
workspace_id: toWorkspaceId(""),
repository_ids: [],
sidebar_views: [
+2
View File
@@ -113,6 +113,7 @@ function buildIdentityFields(s: UserSettingsData) {
repositoryIds: s.repository_ids ?? [],
tasksListSort: parseTasksListSort(s.tasks_list_sort),
tasksListGroup: parseTasksListGroup(s.tasks_list_group),
tasksListShowDetails: s.tasks_list_show_details ?? false,
preferredShell: s.preferred_shell || null,
defaultEditorId: s.default_editor_id || null,
defaultUtilityAgentId: s.default_utility_agent_id || null,
@@ -179,6 +180,7 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) {
repositoryIds: [] as string[],
tasksListSort: DEFAULT_TASKS_LIST_SORT,
tasksListGroup: DEFAULT_TASKS_LIST_GROUP,
tasksListShowDetails: false,
preferredShell: null,
shellOptions,
defaultEditorId: null,
@@ -28,6 +28,7 @@ export const defaultSettingsState: SettingsSliceState = {
repositoryIds: [],
tasksListSort: DEFAULT_TASKS_LIST_SORT,
tasksListGroup: DEFAULT_TASKS_LIST_GROUP,
tasksListShowDetails: false,
preferredShell: null,
shellOptions: [],
defaultEditorId: null,
@@ -147,6 +147,7 @@ export type UserSettingsState = {
repositoryIds: string[];
tasksListSort: TasksListSort;
tasksListGroup: TasksListGroup;
tasksListShowDetails: boolean;
preferredShell: string | null;
shellOptions: Array<{ value: string; label: string }>;
defaultEditorId: string | null;
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getEffectiveTaskListingView,
getStoredTaskListingView,
parseTaskListingView,
resolveTaskListingView,
setStoredTaskListingView,
shouldRestoreHomeTaskListingView,
TASK_LISTING_VIEW_CHANGE_EVENT,
TASK_LISTING_VIEW_STORAGE_KEY,
} from "./view-preference";
let originalLocalStorage: Storage | null = null;
afterEach(() => {
if (originalLocalStorage) {
Object.defineProperty(window, "localStorage", {
configurable: true,
value: originalLocalStorage,
});
originalLocalStorage = null;
}
window.localStorage.clear();
vi.restoreAllMocks();
});
describe("task listing view preference", () => {
it("accepts the versioned List preference", () => {
expect(parseTaskListingView('"list"')).toBe("list");
});
it("ignores malformed values", () => {
expect(parseTaskListingView("pipeline")).toBeNull();
});
it("uses the legacy Pipeline mode only when no device preference exists", () => {
expect(resolveTaskListingView(null, "graph2")).toBe("pipeline");
});
it("defaults malformed device preferences to Kanban instead of using the legacy mode", () => {
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, '"unsupported"');
expect(resolveTaskListingView(getStoredTaskListingView(), "graph2")).toBe("kanban");
});
it("defaults to Kanban when neither a device nor legacy preference exists", () => {
expect(resolveTaskListingView(null, null)).toBe("kanban");
});
it("prefers a device-local preference over the legacy mode", () => {
expect(resolveTaskListingView("kanban", "graph2")).toBe("kanban");
});
it("renders Pipeline as Kanban on phone without changing the preference", () => {
expect(getEffectiveTaskListingView("pipeline", true)).toBe("kanban");
});
it("writes the versioned local preference and notifies this document", () => {
const listener = vi.fn();
window.addEventListener(TASK_LISTING_VIEW_CHANGE_EVENT, listener);
setStoredTaskListingView("pipeline");
expect(window.localStorage.getItem(TASK_LISTING_VIEW_STORAGE_KEY)).toBe('"pipeline"');
expect(listener).toHaveBeenCalledOnce();
window.removeEventListener(TASK_LISTING_VIEW_CHANGE_EVENT, listener);
});
it("keeps the selected view for this interaction when storage rejects the write", () => {
originalLocalStorage = window.localStorage;
Object.defineProperty(window, "localStorage", {
configurable: true,
value: {
getItem: () => null,
setItem: () => {
throw new Error("storage blocked");
},
clear: () => {},
},
});
setStoredTaskListingView("pipeline");
expect(getStoredTaskListingView()).toBe("pipeline");
});
it("restores List only for the top-level Home listing", () => {
expect(shouldRestoreHomeTaskListingView("list", undefined, undefined)).toBe(true);
expect(shouldRestoreHomeTaskListingView("list", "task-1", undefined)).toBe(false);
expect(shouldRestoreHomeTaskListingView("list", undefined, "session-1")).toBe(false);
});
});
@@ -0,0 +1,72 @@
export const TASK_LISTING_VIEW_STORAGE_KEY = "kandev.taskListing.view.v1";
export const TASK_LISTING_VIEW_CHANGE_EVENT = "kandev.taskListing.view.change";
export type TaskListingView = "kanban" | "pipeline" | "list";
const DEFAULT_TASK_LISTING_VIEW: TaskListingView = "kanban";
let transientTaskListingView: TaskListingView | null = null;
export function parseTaskListingView(raw: string | null): TaskListingView | null {
if (!raw) return null;
try {
const value: unknown = JSON.parse(raw);
return value === "kanban" || value === "pipeline" || value === "list" ? value : null;
} catch {
return null;
}
}
export function resolveTaskListingView(
storedView: TaskListingView | null,
legacyKanbanViewMode: string | null | undefined,
): TaskListingView {
if (storedView) return storedView;
if (hasStoredTaskListingView()) return DEFAULT_TASK_LISTING_VIEW;
return legacyKanbanViewMode === "graph2" ? "pipeline" : DEFAULT_TASK_LISTING_VIEW;
}
export function getEffectiveTaskListingView(
preferredView: TaskListingView,
isMobile: boolean,
): TaskListingView {
return isMobile && preferredView === "pipeline" ? "kanban" : preferredView;
}
export function shouldRestoreHomeTaskListingView(
preferredView: TaskListingView,
initialTaskId: string | undefined,
initialSessionId: string | undefined,
): boolean {
return preferredView === "list" && !initialTaskId && !initialSessionId;
}
export function getStoredTaskListingView(): TaskListingView | null {
if (typeof window === "undefined") return null;
if (transientTaskListingView) return transientTaskListingView;
try {
return parseTaskListingView(window.localStorage.getItem(TASK_LISTING_VIEW_STORAGE_KEY));
} catch {
return null;
}
}
function hasStoredTaskListingView(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(TASK_LISTING_VIEW_STORAGE_KEY) !== null;
} catch {
return false;
}
}
export function setStoredTaskListingView(view: TaskListingView): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(TASK_LISTING_VIEW_STORAGE_KEY, JSON.stringify(view));
transientTaskListingView = null;
} catch {
// Storage is unavailable, but preserve the selection until this document closes.
transientTaskListingView = view;
}
window.dispatchEvent(new CustomEvent(TASK_LISTING_VIEW_CHANGE_EVENT));
}
+1
View File
@@ -370,6 +370,7 @@ export type UserSettingsUpdatedPayload = {
user_id: string;
workspace_id: string;
kanban_view_mode?: string;
tasks_list_show_details?: boolean;
workflow_filter_id?: string;
repository_ids: string[];
initial_setup_complete?: boolean;
+2
View File
@@ -53,6 +53,7 @@ export type UserSettings = {
repository_ids: string[];
tasks_list_sort?: string;
tasks_list_group?: string;
tasks_list_show_details?: boolean;
initial_setup_complete?: boolean;
preferred_shell?: string;
default_editor_id?: string;
@@ -102,6 +103,7 @@ export type UserSettingsUpdatePayload = {
repository_ids?: string[];
tasks_list_sort?: string;
tasks_list_group?: string;
tasks_list_show_details?: boolean;
preferred_shell?: string;
default_editor_id?: string;
enable_preview_on_click?: boolean;
+12
View File
@@ -25,6 +25,18 @@ function userSettingsMessage(
}
describe("user settings websocket handler", () => {
it("updates the List detail preference and preserves it when omitted", () => {
const store = makeStore();
registerUsersHandlers(store)["user.settings.updated"]?.(
userSettingsMessage({ tasks_list_show_details: true }),
);
expect(store.getState().userSettings.tasksListShowDetails).toBe(true);
registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({}));
expect(store.getState().userSettings.tasksListShowDetails).toBe(true);
});
it("normalizes the simplified metrics preference from live updates", () => {
const store = makeStore();
+6 -2
View File
@@ -29,7 +29,7 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
function buildUserSettingsState(state: AppState, payload: UserSettingsUpdatedPayload) {
return {
...state.userSettings,
...buildBehaviorSettings(payload),
...buildBehaviorSettings(state, payload),
...buildSidebarSettings(state, payload),
...buildLspSettings(payload),
...buildSyncedLocalSettings(state, payload),
@@ -74,7 +74,7 @@ function buildSyncedLocalSettings(state: AppState, payload: UserSettingsUpdatedP
};
}
function buildBehaviorSettings(payload: UserSettingsUpdatedPayload) {
function buildBehaviorSettings(state: AppState, payload: UserSettingsUpdatedPayload) {
return {
preferredShell: payload.preferred_shell || null,
defaultEditorId: payload.default_editor_id || null,
@@ -91,6 +91,10 @@ function buildBehaviorSettings(payload: UserSettingsUpdatedPayload) {
payload.terminal_link_behavior === "browser_panel"
? ("browser_panel" as const)
: ("new_tab" as const),
tasksListShowDetails:
payload.tasks_list_show_details === undefined
? state.userSettings.tasksListShowDetails
: payload.tasks_list_show_details,
};
}
+8 -1
View File
@@ -257,7 +257,14 @@ function PluginRoute({ path }: { path: string }) {
function KanbanRoute({ route }: { route: Extract<SpaRoute, { kind: "kanban" }> }) {
useKanbanRouteBootstrap(route);
return <PageClient initialTaskId={route.taskId} initialSessionId={route.sessionId} />;
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
return (
<PageClient
workspaceId={route.workspaceId ?? activeWorkspaceId ?? undefined}
initialTaskId={route.taskId}
initialSessionId={route.sessionId}
/>
);
}
function useKanbanRouteBootstrap(route: Extract<SpaRoute, { kind: "kanban" }>) {
@@ -22,6 +22,12 @@ vi.mock("@/app/github/github-page-client", () => ({
),
}));
vi.mock("@/app/page-client", () => ({
PageClient: ({ workspaceId }: { workspaceId?: string }) => (
<div data-testid="kanban-page" data-workspace-id={workspaceId ?? ""} />
),
}));
vi.mock("@/lib/api/domains/workspace-api", () => ({
listWorkspaces: mocks.listWorkspaces,
listRepositories: mocks.listRepositories,
@@ -48,6 +54,21 @@ afterEach(() => {
});
describe("SpaRoutes data-backed workspace context", () => {
it("passes the selected workspace to the kanban home client", () => {
mockGitHubWorkspaceBootstrap();
window.history.replaceState({}, "", `/?workspaceId=${SELECTED_WORKSPACE_ID}`);
render(
<StateProvider>
<SpaRoutes />
</StateProvider>,
);
expect(screen.getByTestId("kanban-page").getAttribute("data-workspace-id")).toBe(
SELECTED_WORKSPACE_ID,
);
});
it("keeps the currently active workspace when opening GitHub from another workspace", async () => {
mockGitHubWorkspaceBootstrap();
@@ -0,0 +1,214 @@
---
spec: docs/specs/ui/task-listing-display-preferences.md
created: 2026-07-25
status: done
---
# Implementation Plan: Task Listing Display Preferences
## Overview
First correct workflow-filter resolution and introduce a device-local view
preference with a compatibility fallback from the existing portable Kanban
mode. In parallel, extend backend user settings with the portable richer-row
boolean. Then wire that setting through frontend state and render optional
Kanban-like metadata in list rows before proving the integrated desktop and
mobile flows.
## Root cause
`resolveDesiredWorkflowId` in
`apps/web/lib/kanban/resolve-workflow.ts` treats a single visible workflow as a
reason to replace an explicit `null` selection with that workflow ID.
`KanbanBoard.useWorkflowSelection` runs the resolver after the display dropdown
stores **All Workflows** as `null`, so Kanban immediately selects the sole
workflow again. List does not run that effect, which is why the same selector
appears stable there.
## Backend
### Portable richer-row user setting
- `apps/backend/internal/user/models/models.go`: add
`TasksListShowDetails bool` to `UserSettings`.
- `apps/backend/internal/user/dto/dto.go`: expose
`tasks_list_show_details` in read and PATCH DTOs and map it in
`FromUserSettings`.
- `apps/backend/internal/user/service/service.go`: apply the optional boolean
patch and include it in `user.settings.updated` events.
- `apps/backend/internal/user/store/sqlite.go`: serialize and deserialize the
boolean in the existing JSON-backed user settings record. No SQL migration is
required.
- `apps/backend/internal/backendapp/boot_state_routes.go`: include
`tasksListShowDetails` in the SPA boot payload.
### Backend tests
- `apps/backend/internal/user/dto/dto_test.go`: prove read mapping and PATCH
omission/false semantics.
- `apps/backend/internal/user/service/service_test.go`: prove applying and
publishing both true and false values without changing omitted values.
- Add or update the closest `apps/backend/internal/user/store/*_test.go` and
`apps/backend/internal/backendapp/*_test.go` coverage if the existing
round-trip/boot-payload suites enumerate every field.
## Frontend
### Workflow-filter correction
- `apps/web/lib/kanban/resolve-workflow.ts`: stop interpreting a single visible
workflow as stronger than an explicit empty filter.
- `apps/web/lib/kanban/resolve-workflow.test.ts`: replace the one-workflow
auto-selection expectation with a regression that preserves `null`; retain
valid active/saved workflow fallback coverage.
- `apps/web/app/page.tsx`, `apps/web/app/tasks/page.tsx`, and
`apps/web/src/spa-routes.tsx`: update stale comments only where needed; all
call sites continue using the shared resolver.
### Device-local task-listing view
- Add `apps/web/lib/task-listing/view-preference.ts` and
`view-preference.test.ts` with the versioned localStorage key, strict enum
parsing, legacy `kanbanViewMode` fallback, mobile effective-view resolution,
and same-document change notification.
- Add `apps/web/hooks/use-task-listing-view.ts` to expose the stored preference,
synchronize the in-memory Kanban/Pipeline mode used by the board, and perform
route changes without backend writes.
- `apps/web/app/page-client.tsx`: when Home mounts, restore List by navigating to
`/tasks`; otherwise synchronize the saved Kanban/Pipeline mode before the
board becomes authoritative.
- `apps/web/app/tasks/tasks-page-client.tsx`: record List when `/tasks` is
explicitly shown.
- `apps/web/components/kanban/kanban-header.tsx` and
`apps/web/components/kanban/mobile-menu-sheet.tsx`: use the device-local hook
for selected state and view changes. Phone renders the effective Kanban
fallback for a saved Pipeline preference without writing over Pipeline.
- `apps/web/hooks/use-kanban-display-settings.ts` and
`apps/web/hooks/use-user-display-settings.ts`: stop view-toggle interactions
from PATCHing `kanban_view_mode`; retain the legacy backend field only as a
first-use compatibility input.
### Portable richer-row frontend setting
- `apps/web/lib/types/http-user-settings.ts`,
`apps/web/lib/types/backend.ts`, `apps/web/lib/state/slices/settings/types.ts`,
`apps/web/lib/state/slices/settings/settings-slice.ts`, and
`apps/web/lib/ssr/user-settings.ts`: map `tasks_list_show_details` to
`tasksListShowDetails`, defaulting to `false`.
- `apps/web/lib/ssr/user-settings.test.ts` and the closest settings-slice tests:
prove true mapping and the false default.
- `apps/web/hooks/use-user-display-settings.ts` and
`apps/web/hooks/use-kanban-display-settings.ts`: carry, optimistically update,
and PATCH the richer-row preference without dropping unrelated settings.
- `apps/web/components/kanban-display-dropdown.tsx`: accept the current page and
show a self-documenting **Show task details** checkbox only for List.
- `apps/web/components/kanban/mobile-menu-sheet.tsx`: show the same checkbox
only for List in the existing phone drawer/tablet sheet.
### Rich list rows
- `apps/web/app/tasks/tasks-page-client.tsx`: read the new setting, fetch
workspace PR summaries while details are enabled, and pass the option into
the list.
- `apps/web/app/tasks/tasks-list-view.tsx`: keep the compact row unchanged when
disabled; when enabled, render a taller but still single-column row hierarchy
with repository slug chips, truncated description, `PRTaskIcon`, session
count, parent context, and review-attention state. Extract a focused sibling
component/helper if needed to stay within file/function limits.
- Reuse `repositorySlug`, shared PR status UI, existing badges, and existing
task navigation/action handlers. Missing metadata renders nothing.
## Mobile design contract
- **Desktop outcome:** existing topbar toggles restore the device's last
Kanban/Pipeline/List view; List's display dropdown controls optional rich
rows.
- **Mobile entry point:** the shipped topbar menu opens
`MobileMenuSheet`; its existing View controls and Display Options section own
both choices.
- **Nearest shipped exemplars:** `mobile-menu-sheet.tsx` supplies the
safe-area-aware inset drawer and internal scroll owner;
`mobile-task-list-search.spec.ts` supplies the compact list interaction;
Kanban card content supplies metadata hierarchy.
- **Hierarchy and primary action:** task state and title remain first, repo/PR
context second, description third, attention badges last. The row body opens
the task; archive/delete remain explicit secondary controls.
- **Presentation:** temporary settings remain in the existing inset bottom
drawer. Rich task data stays inline in the primary List surface rather than
opening another overlay.
- **Geometry:** the drawer retains its `100dvh`, safe-area padding, and internal
scroll. The task-list `<main>` remains the only page-content scroll owner;
metadata truncates/wraps within the row and must not create document
horizontal overflow.
- **Shared versus responsive logic:** preference, filtering, metadata
resolution, and row actions are shared. Only spacing, truncation, and the
Pipeline effective fallback vary by viewport.
- **Mobile proof:** Playwright selects List from the drawer, reloads Home to
prove local restoration, enables details, verifies rich metadata and row
navigation, and asserts no horizontal document overflow.
## Tests
- **One-workflow All Workflows:** unit regression in
`resolve-workflow.test.ts`; browser regression in
`e2e/tests/kanban/workflow-filter.spec.ts`.
- **Device preference parsing/fallback:** table-driven Vitest cases in
`view-preference.test.ts` for valid, missing, malformed, legacy, and mobile
Pipeline values.
- **Portable details setting:** Go DTO/service/store tests plus frontend SSR and
settings-store mapping tests.
- **Rich metadata:** pure helper tests for repository/attention derivation where
logic is extracted; rendered behavior is covered through Playwright rather
than new React component tests.
## E2E Tests
- `apps/web/e2e/tests/kanban/workflow-filter.spec.ts`: with only the seeded
workflow, choose **All Workflows**, close/reopen display options, reload, and
verify it remains selected.
- `apps/web/e2e/tests/task/task-listing-view-preferences.spec.ts`: select
Pipeline and List through desktop controls, reload Home, and verify the last
device-local view; enable rich rows and verify repo, description, PR, and
portable-setting reload behavior.
- `apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts`: use the phone
drawer to choose List, reopen Home to prove restoration, enable rich rows,
assert metadata/row navigation, and assert no horizontal overflow. Also
preseed Pipeline and prove the phone fallback does not overwrite it.
## Implementation waves
Wave 1 (parallel):
- [x] [Task 01 — Device-local view and workflow fix](task-01-device-view-and-workflow-fix.md)
- [x] [Task 02 — Portable richer-row setting contract](task-02-portable-rich-row-setting.md)
Wave 2:
- [x] [Task 03 — Rich list-row UI](task-03-rich-list-row-ui.md)
Wave 3:
- [x] [Task 04 — Desktop and mobile integration coverage](task-04-integration-e2e.md)
## Verification
- `make -C apps/backend fmt`
- `cd apps/backend && go test ./internal/user/... ./internal/backendapp/...`
- `cd apps && pnpm --filter @kandev/web test -- --run lib/kanban/resolve-workflow.test.ts lib/task-listing/view-preference.test.ts lib/ssr/user-settings.test.ts`
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web lint`
- `cd apps/web && pnpm e2e:run tests/kanban/workflow-filter.spec.ts tests/task/task-listing-view-preferences.spec.ts tests/task/mobile-task-listing-display.spec.ts`
- After the implementation commit, delegate final change-aware `verify` with
the non-bypassed hook receipt.
## Risks
- Home restoration must avoid a `/` ↔ `/tasks` redirect loop and must not
hijack task-detail or explicitly opened external routes.
- Hydrated backend `kanban_view_mode` can briefly disagree with the local
preference; the local preference must become authoritative before the board
chooses Pipeline/Kanban.
- User-settings payload builders carry many unrelated fields; the new boolean
must not be dropped by optimistic updates or WS fallback writes.
- PR summary fetching should be enabled only when rich rows need it and must
reuse the existing workspace-level cache/store.
@@ -0,0 +1,58 @@
---
id: "01-device-view-and-workflow-fix"
title: "Device-local view and workflow fix"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/task-listing-display-preferences.md"
role: implementer
model_tier: balanced
---
# Task 01: Device-local view and workflow fix
## Acceptance
- **All Workflows** remains `null` with exactly one visible workflow and has a
failing-then-passing unit regression.
- Kanban/Pipeline/List changes write the versioned device-local preference and
no longer PATCH the backend view mode.
- Home restores List or the desktop Kanban/Pipeline preference; phone falls
back from Pipeline to Kanban without changing stored Pipeline.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- --run lib/kanban/resolve-workflow.test.ts lib/task-listing/view-preference.test.ts`
- `cd apps/web && pnpm run typecheck`
## Files likely touched
- `apps/web/lib/kanban/resolve-workflow.ts`
- `apps/web/lib/kanban/resolve-workflow.test.ts`
- `apps/web/lib/task-listing/view-preference.ts`
- `apps/web/lib/task-listing/view-preference.test.ts`
- `apps/web/hooks/use-task-listing-view.ts`
- `apps/web/hooks/use-kanban-display-settings.ts`
- `apps/web/hooks/use-user-display-settings.ts`
- `apps/web/app/page-client.tsx`
- `apps/web/app/tasks/tasks-page-client.tsx`
- `apps/web/components/kanban/kanban-header.tsx`
- `apps/web/components/kanban/mobile-menu-sheet.tsx`
- `apps/web/app/page.tsx`
- `apps/web/app/tasks/page.tsx`
- `apps/web/src/spa-routes.tsx`
## Inputs
- Spec: **What**, **Device-local view preference**, and device-view scenarios.
- Plan: **Root cause**, **Workflow-filter correction**, **Device-local
task-listing view**, and **Mobile design contract**.
- Existing pattern: `apps/web/hooks/use-global-view-mode.ts`.
## Output contract
Return a compact handoff with intent/acceptance, base/head SHA if committed,
changed files and entry points, spec sections, `localized + user-flow +
persistence` risk tags, exact command results, uncertainties, and the task-file
status update. Do not edit `plan.md`.
@@ -0,0 +1,62 @@
---
id: "02-portable-rich-row-setting"
title: "Portable richer-row setting contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/task-listing-display-preferences.md"
role: implementer
model_tier: balanced
---
# Task 02: Portable richer-row setting contract
## Acceptance
- User-settings GET, PATCH, event, persistence, and boot payload expose
`tasks_list_show_details` / `tasksListShowDetails`.
- The default is false; explicit true and false values round-trip; omission
leaves the current value unchanged.
- Backend and frontend mapping tests fail before and pass after the contract
change.
## Verification
- `make -C apps/backend fmt`
- `cd apps/backend && go test ./internal/user/... ./internal/backendapp/...`
- `cd apps && pnpm --filter @kandev/web test -- --run lib/ssr/user-settings.test.ts`
## Files likely touched
- `apps/backend/internal/user/models/models.go`
- `apps/backend/internal/user/dto/dto.go`
- `apps/backend/internal/user/dto/dto_test.go`
- `apps/backend/internal/user/service/service.go`
- `apps/backend/internal/user/service/service_test.go`
- `apps/backend/internal/user/store/sqlite.go`
- closest user-store round-trip test
- `apps/backend/internal/backendapp/boot_state_routes.go`
- closest boot-state route test
- `apps/web/lib/types/http-user-settings.ts`
- `apps/web/lib/types/backend.ts`
- `apps/web/lib/state/slices/settings/types.ts`
- `apps/web/lib/state/slices/settings/settings-slice.ts`
- `apps/web/lib/ssr/user-settings.ts`
- `apps/web/lib/ssr/user-settings.test.ts`
- closest settings-slice test
## Inputs
- Spec: **Portable richer-row preference**, **API surface**, **Failure modes**,
and **Persistence guarantees**.
- Plan: **Portable richer-row user setting** and **Portable richer-row frontend
setting** mappings only.
- ADR: `docs/decisions/0041-backend-owned-portable-user-settings.md`.
## Output contract
Return a compact handoff with intent/acceptance, base/head SHA if committed,
changed files and entry points, spec sections, `public-contract + persistence`
risk tags, exact command results, uncertainties, and the task-file status
update. Do not edit `plan.md`.
@@ -0,0 +1,60 @@
---
id: "03-rich-list-row-ui"
title: "Rich list-row UI"
status: done
wave: 2
depends_on:
- "02-portable-rich-row-setting"
plan: "plan.md"
spec: "../../specs/ui/task-listing-display-preferences.md"
role: implementer
model_tier: balanced
---
# Task 03: Rich list-row UI
## Acceptance
- Desktop dropdown and mobile menu show **Show task details** only in List,
with visible explanatory copy, defaulting off and PATCHing the portable
setting.
- Compact rows remain unchanged while rich rows render available repository,
description, PR, session, parent, and review metadata.
- Rich rows retain row navigation and independent secondary controls, fit phone
width, and use shared repository/PR display rules.
## Verification
- `cd apps && pnpm --filter @kandev/web test -- --run lib/ssr/user-settings.test.ts`
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web lint`
## Files likely touched
- `apps/web/hooks/use-user-display-settings.ts`
- `apps/web/hooks/use-kanban-display-settings.ts`
- `apps/web/components/kanban-display-dropdown.tsx`
- `apps/web/components/kanban/mobile-menu-sheet.tsx`
- `apps/web/components/kanban/kanban-header.tsx`
- `apps/web/app/tasks/tasks-page-client.tsx`
- `apps/web/app/tasks/tasks-list-view.tsx`
- optional focused sibling under `apps/web/app/tasks/`
- optional pure helper test beside the extracted helper
- `apps/web/hooks/domains/github/use-task-pr.ts` only if the existing hook
cannot be reused by passing `null` while details are disabled
## Inputs
- Spec: rich-row **What** bullets and rich-row/mobile scenarios.
- Plan: **Portable richer-row frontend setting**, **Rich list rows**, and the
complete **Mobile design contract**.
- Patterns: `apps/web/components/kanban-card-content.tsx`,
`apps/web/components/github/pr-task-icon.tsx`, and repository-label guidance
in `apps/web/AGENTS.md`.
## Output contract
Return a compact handoff with intent/acceptance, base/head SHA if committed,
changed files and entry points, spec sections, `user-flow + persistence` risk
tags, exact command results, uncertainties, and the task-file status update. Do
not edit `plan.md`.
@@ -0,0 +1,52 @@
---
id: "04-integration-e2e"
title: "Desktop and mobile integration coverage"
status: done
wave: 3
depends_on:
- "01-device-view-and-workflow-fix"
- "03-rich-list-row-ui"
plan: "plan.md"
spec: "../../specs/ui/task-listing-display-preferences.md"
role: test-engineer
model_tier: balanced
---
# Task 04: Desktop and mobile integration coverage
## Acceptance
- Playwright proves the one-workflow **All Workflows** regression and reload.
- Desktop Playwright proves device-local view restoration and portable rich-row
persistence with repo, description, and PR metadata.
- Mobile Playwright proves drawer reachability, List restoration, Pipeline
effective fallback without overwrite, rich-row navigation, and no document
horizontal overflow.
## Verification
- `cd apps/web && pnpm e2e:run tests/kanban/workflow-filter.spec.ts tests/task/task-listing-view-preferences.spec.ts tests/task/mobile-task-listing-display.spec.ts`
## Files likely touched
- `apps/web/e2e/tests/kanban/workflow-filter.spec.ts`
- `apps/web/e2e/tests/task/task-listing-view-preferences.spec.ts`
- `apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts`
- `apps/web/e2e/pages/kanban-page.ts` only for reusable stable selectors
- `apps/web/e2e/helpers/api-client.ts` for the typed settings field
- production files only for stable `data-testid` attributes required by these
scenarios
## Inputs
- All spec scenarios.
- Plan: **E2E Tests**, **Mobile design contract**, and completed Task 01/03
handoff capsules.
- E2E conventions in `.agents/skills/e2e/SKILL.md`.
## Output contract
Return a compact handoff with intent/acceptance, base/head SHA if committed,
changed files and entry points, spec sections, `user-flow + integration +
persistence` risk tags, exact command results and artifacts, uncertainties,
and the task-file status update. Do not edit `plan.md`.
+2 -1
View File
@@ -112,10 +112,11 @@ If the selected profile is unhealthy or incompatible with the executor, fix that
## Find and organize tasks
On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Phones offer **Kanban** and **List** only; a saved desktop Pipeline preference is kept but shown as Kanban on the phone.
On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Kandev remembers the last selected view in that browser on the current device. Phones offer **Kanban** and **List** only; a saved desktop Pipeline preference is kept but shown as Kanban on the phone.
- Search matches tasks without changing their state.
- The display menu filters by **Workflow** and **Repository** and can enable **Open preview on click**.
- 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.
- **Show archived** reveals archived tasks in List.
+1
View File
@@ -129,6 +129,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [task-layout-profiles](ui/task-layout-profiles.md) | draft |
| [agent-message-comments](ui/agent-message-comments.md) | shipped |
| [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped |
| [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped |
## system-page/ — operational diagnostics & maintenance UI
@@ -0,0 +1,143 @@
---
status: shipped
created: 2026-07-25
owner: Kandev
---
# Task Listing Display Preferences
## Why
People browse the same tasks in different ways depending on the device and the
amount of detail they need. A phone user may consistently prefer the compact
list, while a desktop user may prefer Kanban or Pipeline, and list users need an
optional richer row without losing the current compact default.
## What
- Selecting **All Workflows** remains selected in Kanban and List even when the
workspace has exactly one visible workflow.
- The selected workflow filter continues to use the existing portable user
setting and survives navigation and reloads.
- The most recently selected task-listing view is one of `kanban`, `pipeline`,
or `list` and is stored on the current device.
- Opening the Home task surface restores the device's last selected view.
- A missing, invalid, or unavailable device preference falls back to Kanban.
- Pipeline remains unavailable on phone-sized layouts. A phone temporarily
renders Kanban when Pipeline is the saved device preference without
overwriting that saved preference.
- List view adds a **Show task details** display option. It is disabled by
default and appears in both desktop and mobile display-options surfaces while
List is active.
- Enabling **Show task details** enriches each list row with the same useful
task context surfaced by Kanban cards when that data exists:
repository slug chips, a truncated description, pull-request status, session
count, parent-task context, and review-attention state.
- Compact rows retain their current title, task-state icon, updated time, and
actions when **Show task details** is disabled.
- The richer-row option is a portable user preference and applies on the user's
other signed-in Kandev devices.
- Rich rows preserve the existing primary row action: tapping or clicking the
row opens the task, while archive/delete and interactive metadata controls do
not trigger row navigation.
## Data model
### Device-local view preference
`localStorage["kandev.taskListing.view.v1"]` contains a JSON string with one of:
- `"kanban"`
- `"pipeline"`
- `"list"`
The browser value is the durable source of truth for this explicitly
device-local preference, consistent with
[ADR 0041](../../decisions/0041-backend-owned-portable-user-settings.md).
Existing users with no local value may use their current
`kanban_view_mode` once as a compatibility fallback before the local preference
becomes authoritative.
### Portable richer-row preference
The existing backend-owned user settings object adds:
| Field | Type | Default | Meaning |
|---|---|---:|---|
| `tasks_list_show_details` | boolean | `false` | Whether List renders enriched task rows. |
The frontend settings store exposes the same value as
`tasksListShowDetails`.
## API surface
The existing user-settings read response includes:
```json
{
"tasks_list_show_details": false
}
```
The existing user-settings update request accepts an optional
`tasks_list_show_details` boolean. Omitting it leaves the saved value unchanged.
No new endpoint is introduced.
## Failure modes
- If browser storage is blocked or full, changing views still navigates for the
current interaction, but a future app open falls back to Kanban.
- If an old or malformed device value is found, Kandev ignores it and uses
Kanban.
- If saving **Show task details** fails, the optimistic display remains for the
current page; the backend value wins after the next authoritative reload,
following existing user-settings behavior.
- Missing repository or pull-request data omits only that metadata; it does not
hide the task row or block navigation.
## Persistence guarantees
- The last selected task-listing view survives browser restarts on the same
device and does not sync to other devices or users.
- **Show task details** survives Kandev and browser restarts and syncs through
the authenticated user's backend settings.
- A phone's effective Kanban fallback for a saved Pipeline preference is never
persisted over the saved device preference.
## Scenarios
- **GIVEN** a workspace with one visible workflow, **WHEN** the user selects
**All Workflows** on Kanban, **THEN** the selector remains on **All
Workflows** and the choice survives a reload.
- **GIVEN** a workspace with multiple visible workflows, **WHEN** the user
selects **All Workflows** in either Kanban or List, **THEN** tasks from every
workflow remain visible after navigation and reload.
- **GIVEN** List was the last view selected on a phone, **WHEN** the user opens
Home again on that device, **THEN** List is selected and rendered.
- **GIVEN** Pipeline was the last view selected on a desktop, **WHEN** the user
opens Home again on that device, **THEN** Pipeline is selected and rendered.
- **GIVEN** Pipeline is saved and the same browser is currently phone-sized,
**WHEN** Home opens, **THEN** Kanban renders and the saved Pipeline value is
unchanged.
- **GIVEN** no valid device view preference, **WHEN** Home opens, **THEN**
Kanban renders.
- **GIVEN** **Show task details** is disabled, **WHEN** List renders tasks,
**THEN** rows remain in the current compact presentation.
- **GIVEN** **Show task details** is enabled, **WHEN** a task has repositories,
a description, pull requests, or review/session metadata, **THEN** the
available metadata is visible in its row without horizontal page overflow.
- **GIVEN** a rich row on a phone, **WHEN** the user taps its body, **THEN** the
task opens and all secondary row actions remain independently touchable.
- **GIVEN** the user enables **Show task details**, **WHEN** the page or app is
reloaded, **THEN** the option remains enabled from backend user settings.
## Out of scope
- Syncing the last selected Kanban/Pipeline/List view between devices.
- Adding a phone-native Pipeline visualization.
- User-configurable selection or ordering of individual rich-row metadata
fields.
- Changing existing sort, group, archive, pagination, or preview-panel
behavior.
- Removing the legacy `kanban_view_mode` API field in this change.