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