diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 391fe4f038..dc98d35c9a 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -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), diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index a8e8942150..dc9da476fc 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -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"}, diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 9716c7fc0e..7701a177aa 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -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, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index ab945265e0..71fb96362f 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -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, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 0fb623f399..c0d051e08f 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -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"}, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 6128652070..e086c70109 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -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"` diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 3d83709969..bdba9aa19b 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index dd00164488..ff5bd43e72 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -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 { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index e7c79a4194..119fb711a9 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 7bd90b78b9..3ea1557f24 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -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, diff --git a/apps/web/app/page-client.test.tsx b/apps/web/app/page-client.test.tsx new file mode 100644 index 0000000000..094c18948d --- /dev/null +++ b/apps/web/app/page-client.test.tsx @@ -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(); + + await waitFor(() => { + expect(replaceMock).toHaveBeenCalledWith("/tasks?workspace=workspace-1"); + }); + }); + + it("does not restore List while opening a task", async () => { + render(); + + await waitFor(() => { + expect(replaceMock).not.toHaveBeenCalled(); + }); + }); + + it("does not restore List while opening a session", async () => { + render(); + + await waitFor(() => { + expect(replaceMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/app/page-client.tsx b/apps/web/app/page-client.tsx index 07d659d755..fc35338b02 100644 --- a/apps/web/app/page-client.tsx +++ b/apps/web/app/page-client.tsx @@ -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 ( <> diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2f19cf0b0c..d5459ae356 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -139,6 +139,90 @@ async function loadSnapshotState( return state; } +async function loadWorkspaceState({ + activeWorkspaceId, + initialState, + settingsWorkflowId, + workflowIdParam, +}: { + activeWorkspaceId: string; + initialState: Partial; + 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, + workspaceId: string, + taskId?: string, + sessionId?: string, +) { + return ( + <> + + + + ); +} + +function renderUnresolvedPage(initialState: Partial) { + return ( + <> + + + + ); +} + 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 ( - <> - - - - ); + 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 ( - <> - - - - ); - } - - const snapshotState = await loadSnapshotState(workflowId, taskId, sessionId); - initialState = { ...initialState, ...snapshotState }; - - return ( - <> - - - + const snapshotState = await loadSnapshotState(workspaceState.workflowId, taskId, sessionId); + return renderPageClient( + { ...workspaceState.initialState, ...snapshotState }, + activeWorkspaceId, + taskId, + sessionId, ); } catch { return ; diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 87f6bb5a12..b17e7ea01a 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -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, diff --git a/apps/web/app/tasks/rich-task-list-row.tsx b/apps/web/app/tasks/rich-task-list-row.tsx new file mode 100644 index 0000000000..02fac12149 --- /dev/null +++ b/apps/web/app/tasks/rich-task-list-row.tsx @@ -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; + +function ArchivedBadge({ task }: { task: Task }) { + if (!task.archived_at) return null; + return ( + + Archived + + ); +} + +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, + )} + + {task.title} + + {showPullRequest && ( + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + + + )} + + + ); +} + +function RichMetadataBadges({ details }: { details: RichTaskRowDetails }) { + if (details.repositoryLabels.length === 0 && !details.sessionCount && !details.parentTitle) { + return null; + } + return ( +
+ {details.repositoryLabels.map((label) => ( + + {label} + + ))} + {details.parentTitle && ( + + + {details.parentTitle} + + )} + {details.sessionCount && ( + + {details.sessionCount} sessions + + )} +
+ ); +} + +function ReviewAttention({ attention }: { attention: RichTaskRowDetails["reviewAttention"] }) { + if (attention === "approval_required") { + return ( +
+ + Approval Required +
+ ); + } + if (attention !== "changes_requested") return null; + return ( + + Changes Requested + + ); +} + +function RichTaskContent({ + task, + details, + level, + pendingInput, +}: { + task: Task; + details: RichTaskRowDetails; + level: number; + pendingInput: PendingInput; +}) { + return ( +
+
+ +
+ + {details.description && ( +

+ {details.description} +

+ )} + +
+ ); +} + +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 ( + + ); + } + return ( +
+ +
+ ); +} diff --git a/apps/web/app/tasks/rich-task-row-details.test.ts b/apps/web/app/tasks/rich-task-row-details.test.ts new file mode 100644 index 0000000000..ae55b24108 --- /dev/null +++ b/apps/web/app/tasks/rich-task-row-details.test.ts @@ -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 { + 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 { + 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([]); + }); +}); diff --git a/apps/web/app/tasks/rich-task-row-details.ts b/apps/web/app/tasks/rich-task-row-details.ts new file mode 100644 index 0000000000..408de8d9f2 --- /dev/null +++ b/apps/web/app/tasks/rich-task-row-details.ts @@ -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(); + const seenRepositoryLabels = new Set(); + 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; +} diff --git a/apps/web/app/tasks/tasks-list-view.test.tsx b/apps/web/app/tasks/tasks-list-view.test.tsx index 925d2b1aff..e36a24769f 100644 --- a/apps/web/app/tasks/tasks-list-view.test.tsx +++ b/apps/web/app/tasks/tasks-list-view.test.tsx @@ -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 = {}) { +function renderList( + task: Task, + messagesBySession: Record = {}, + showTaskDetails = false, +) { return render( - + , ); @@ -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({ diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx index 7eac4bb19a..e48b45c3ad 100644 --- a/apps/web/app/tasks/tasks-list-view.tsx +++ b/apps/web/app/tasks/tasks-list-view.tsx @@ -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} /> ))} @@ -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; onUnarchive: (taskId: string) => Promise; @@ -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 (
-
- {getTaskStateIcon( - task.state, - "h-4 w-4 shrink-0", - pendingInput.clarification, - task.foreground_activity, - pendingInput.permission, - )} - - {task.title} - - {isArchived && ( - - Archived - - )} -
+
{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; onUnarchive: (taskId: string) => Promise; @@ -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} diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 84f51f0a91..64937fb7c1 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -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 (
["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.

+ {currentPage === "tasks" && ( + <> + +
+ List rows + +

+ Add repository, pull request, session, parent, and review context to List rows. +

+
+ + )}
diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index ca94858319..90beeee1ab 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -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): boolean { const [isNarrow, setIsNarrow] = useState(false); @@ -207,7 +201,7 @@ function TabletHeader({ - + - + { 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); diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index af7049e5eb..1c20ebdaf2 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -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) { +}: 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 (
@@ -154,6 +161,21 @@ function MobileDisplayOptions(props: MobileDisplayOptionsProps) { Open preview on click
+ {showTaskDetails && ( +
+ + +

+ Add repository, pull request, session, parent, and review context to List rows. +

+
+ )}
); } @@ -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"} /> diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 4f4d9ba47d..cf04845873 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -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, }; } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 47f14dd8ca..5ca5fe578a 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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; diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts index c6b979417d..69d1a4b524 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts @@ -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); }); }); diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts index 6813c2b425..731b0c9082 100644 --- a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts +++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts @@ -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 diff --git a/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts b/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts new file mode 100644 index 0000000000..698ced1db1 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts @@ -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")); + }); +}); diff --git a/apps/web/e2e/tests/task/task-listing-view-preferences.spec.ts b/apps/web/e2e/tests/task/task-listing-view-preferences.spec.ts new file mode 100644 index 0000000000..873a7fa13a --- /dev/null +++ b/apps/web/e2e/tests/task/task-listing-view-preferences.spec.ts @@ -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); + }); +}); diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 5b19805a2c..2d2dc6c0b6 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -52,6 +52,7 @@ function makeUnloadedSettings(): UserSettingsState { repositoryIds: [], tasksListSort: "updated_desc", tasksListGroup: "state", + tasksListShowDetails: false, preferredShell: null, shellOptions: [], defaultEditorId: null, diff --git a/apps/web/hooks/use-kanban-display-settings.ts b/apps/web/hooks/use-kanban-display-settings.ts index d2526ac660..6582cef584 100644 --- a/apps/web/hooks/use-kanban-display-settings.ts +++ b/apps/web/hooks/use-kanban-display-settings.ts @@ -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, }; } diff --git a/apps/web/hooks/use-task-listing-view.test.tsx b/apps/web/hooks/use-task-listing-view.test.tsx new file mode 100644 index 0000000000..fea56036f0 --- /dev/null +++ b/apps/web/hooks/use-task-listing-view.test.tsx @@ -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: (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 }), + ); + }); + }); +}); diff --git a/apps/web/hooks/use-task-listing-view.ts b/apps/web/hooks/use-task-listing-view.ts new file mode 100644 index 0000000000..a41be9453c --- /dev/null +++ b/apps/web/hooks/use-task-listing-view.ts @@ -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 }; +} diff --git a/apps/web/hooks/use-user-display-settings.test.ts b/apps/web/hooks/use-user-display-settings.test.ts new file mode 100644 index 0000000000..727bc8a2bc --- /dev/null +++ b/apps/web/hooks/use-user-display-settings.test.ts @@ -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); + }); +}); diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 3668a9caa3..af219d3154 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -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); }, diff --git a/apps/web/lib/kanban/resolve-workflow.test.ts b/apps/web/lib/kanban/resolve-workflow.test.ts index a6ba1fb22a..e440d5c7d1 100644 --- a/apps/web/lib/kanban/resolve-workflow.test.ts +++ b/apps/web/lib/kanban/resolve-workflow.test.ts @@ -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(); }); }); diff --git a/apps/web/lib/kanban/resolve-workflow.ts b/apps/web/lib/kanban/resolve-workflow.ts index 63e7e65d44..bd8020f712 100644 --- a/apps/web/lib/kanban/resolve-workflow.ts +++ b/apps/web/lib/kanban/resolve-workflow.ts @@ -50,8 +50,6 @@ export function resolveBoardWorkflowSteps({ * * `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; } diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 5ac89bb3ef..b5a8a40435 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -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: [ diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 2683f802a3..e9fc47b9b8 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -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, diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 2739db3c18..d1af60546a 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -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, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 71053fd3f6..365b0ea7c6 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -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; diff --git a/apps/web/lib/task-listing/view-preference.test.ts b/apps/web/lib/task-listing/view-preference.test.ts new file mode 100644 index 0000000000..681075ef35 --- /dev/null +++ b/apps/web/lib/task-listing/view-preference.test.ts @@ -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); + }); +}); diff --git a/apps/web/lib/task-listing/view-preference.ts b/apps/web/lib/task-listing/view-preference.ts new file mode 100644 index 0000000000..69a04986c8 --- /dev/null +++ b/apps/web/lib/task-listing/view-preference.ts @@ -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)); +} diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 556e3b99c6..ae91a30f43 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -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; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 32b9a69087..a36c54727f 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -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; diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 4b12024e76..aa20909231 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -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(); diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index 19e54e0d47..678aa03204 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -29,7 +29,7 @@ export function registerUsersHandlers(store: StoreApi): 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, }; } diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index e549d0299e..d17788c81a 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -257,7 +257,14 @@ function PluginRoute({ path }: { path: string }) { function KanbanRoute({ route }: { route: Extract }) { useKanbanRouteBootstrap(route); - return ; + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + return ( + + ); } function useKanbanRouteBootstrap(route: Extract) { diff --git a/apps/web/src/spa-routes.workspace.test.tsx b/apps/web/src/spa-routes.workspace.test.tsx index dd5490a056..abab395fd5 100644 --- a/apps/web/src/spa-routes.workspace.test.tsx +++ b/apps/web/src/spa-routes.workspace.test.tsx @@ -22,6 +22,12 @@ vi.mock("@/app/github/github-page-client", () => ({ ), })); +vi.mock("@/app/page-client", () => ({ + PageClient: ({ workspaceId }: { workspaceId?: string }) => ( +
+ ), +})); + 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( + + + , + ); + + 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(); diff --git a/docs/plans/task-listing-display-preferences/plan.md b/docs/plans/task-listing-display-preferences/plan.md new file mode 100644 index 0000000000..4a227f25c0 --- /dev/null +++ b/docs/plans/task-listing-display-preferences/plan.md @@ -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 `
` 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. diff --git a/docs/plans/task-listing-display-preferences/task-01-device-view-and-workflow-fix.md b/docs/plans/task-listing-display-preferences/task-01-device-view-and-workflow-fix.md new file mode 100644 index 0000000000..eb0258d2c7 --- /dev/null +++ b/docs/plans/task-listing-display-preferences/task-01-device-view-and-workflow-fix.md @@ -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`. diff --git a/docs/plans/task-listing-display-preferences/task-02-portable-rich-row-setting.md b/docs/plans/task-listing-display-preferences/task-02-portable-rich-row-setting.md new file mode 100644 index 0000000000..bb2e1f19dc --- /dev/null +++ b/docs/plans/task-listing-display-preferences/task-02-portable-rich-row-setting.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`. diff --git a/docs/plans/task-listing-display-preferences/task-03-rich-list-row-ui.md b/docs/plans/task-listing-display-preferences/task-03-rich-list-row-ui.md new file mode 100644 index 0000000000..3312982e48 --- /dev/null +++ b/docs/plans/task-listing-display-preferences/task-03-rich-list-row-ui.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`. diff --git a/docs/plans/task-listing-display-preferences/task-04-integration-e2e.md b/docs/plans/task-listing-display-preferences/task-04-integration-e2e.md new file mode 100644 index 0000000000..00922c96ae --- /dev/null +++ b/docs/plans/task-listing-display-preferences/task-04-integration-e2e.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`. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index a668e6c77e..7f7a490f38 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 18187e62b1..deeac10a2f 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 diff --git a/docs/specs/ui/task-listing-display-preferences.md b/docs/specs/ui/task-listing-display-preferences.md new file mode 100644 index 0000000000..71c0981aab --- /dev/null +++ b/docs/specs/ui/task-listing-display-preferences.md @@ -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.