feat: distinguish archived command-panel task results (#3298)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDistance, useDateLocale } from "@/lib/i18n/date-locale";
|
||||
import { IconArrowRight, IconLoader2 } from "@tabler/icons-react";
|
||||
import { IconArchive, IconArrowRight, IconLoader2 } from "@tabler/icons-react";
|
||||
import { CommandEmpty, CommandGroup, CommandItem, CommandShortcut } from "@kandev/ui/command";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types";
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
type CommandPanelLiveTask,
|
||||
} from "@/lib/commands/task-result-activity";
|
||||
|
||||
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
|
||||
|
||||
export const MODE_COMMANDS: CommandPanelMode = "commands";
|
||||
export const MODE_SEARCH_TASKS: CommandPanelMode = "search-tasks";
|
||||
export const MODE_SEARCH_FILES: CommandPanelMode = "search-files";
|
||||
@@ -116,7 +114,8 @@ function TaskResultItem({
|
||||
}: TaskResultItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateLocale();
|
||||
const isArchived = ARCHIVED_STATES.has(task.state);
|
||||
const isArchived = task.archived_at != null;
|
||||
const archivedLabel = t("tasks:archived");
|
||||
const activity = resolveTaskResultActivity(
|
||||
task,
|
||||
liveTasksById.get(task.id),
|
||||
@@ -139,23 +138,46 @@ function TaskResultItem({
|
||||
key={task.id}
|
||||
value={getTaskResultValue(task)}
|
||||
onSelect={() => onSelect(task)}
|
||||
className={isArchived ? "opacity-60" : ""}
|
||||
forceMount
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
<TaskStateIcon {...activity} accessibleLabel={t(getTaskStateIconLabelKey(activity))} />
|
||||
{isArchived ? (
|
||||
<IconArchive
|
||||
role="img"
|
||||
aria-label={archivedLabel}
|
||||
data-testid="task-state-archived"
|
||||
className="mt-[1px] h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<TaskStateIcon {...activity} accessibleLabel={t(getTaskStateIconLabelKey(activity))} />
|
||||
)}
|
||||
{/* The title identifies the row, so it takes the free space and the
|
||||
non-shrinking badge and metadata cannot squeeze it away. Metadata is
|
||||
secondary and steps aside entirely on a phone. */}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{task.title}</span>
|
||||
{step && (
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate font-medium${
|
||||
isArchived ? " text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{isArchived ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[0.6rem] shrink-0"
|
||||
style={stepHex ? { backgroundColor: stepHex + "22", color: stepHex } : undefined}
|
||||
variant="outline"
|
||||
className="text-[0.6rem] shrink-0 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
{step.name}
|
||||
{archivedLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
step && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[0.6rem] shrink-0"
|
||||
style={stepHex ? { backgroundColor: stepHex + "22", color: stepHex } : undefined}
|
||||
>
|
||||
{step.name}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
{details.length > 0 && (
|
||||
<span className="hidden shrink-0 truncate text-[0.6rem] text-muted-foreground sm:inline">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommandPanelLiveTask } from "@/lib/commands/task-result-activity";
|
||||
@@ -13,17 +13,31 @@ const LIVE_UPDATED_AT = "2026-08-24T09:01:00Z";
|
||||
vi.mock("@kandev/ui/command", () => ({
|
||||
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandGroup: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
||||
CommandItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
CommandItem: ({
|
||||
children,
|
||||
className,
|
||||
onSelect,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onSelect?: () => void;
|
||||
}) => (
|
||||
<div role="option" className={className} onClick={onSelect}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
CommandShortcut: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/badge", () => ({
|
||||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
Badge: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<span className={className}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CommandsListContent } from "./command-panel-results";
|
||||
|
||||
function task(id: string, title: string): Task {
|
||||
function task(id: string, title: string, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: taskId(id),
|
||||
workspace_id: workspaceId("workspace-1"),
|
||||
@@ -36,6 +50,7 @@ function task(id: string, title: string): Task {
|
||||
priority: "medium",
|
||||
created_at: "2026-08-24T09:00:00Z",
|
||||
updated_at: "2026-08-24T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +59,7 @@ function taskResults(
|
||||
liveTasksById = new Map<string, CommandPanelLiveTask>(),
|
||||
lastStepIdByWorkflowId = new Map<string, string>(),
|
||||
stepMap = new Map<string, { name: string; color: string }>(),
|
||||
onTaskSelect = vi.fn(),
|
||||
) {
|
||||
return (
|
||||
<CommandsListContent
|
||||
@@ -57,7 +73,7 @@ function taskResults(
|
||||
repoMap={new Map()}
|
||||
liveTasksById={liveTasksById}
|
||||
lastStepIdByWorkflowId={lastStepIdByWorkflowId}
|
||||
onTaskSelect={vi.fn()}
|
||||
onTaskSelect={onTaskSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -236,3 +252,54 @@ describe("command panel task activity icon edge cases", () => {
|
||||
expect(screen.getByTestId(RUNNING_ICON_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command panel archived task results", () => {
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.4,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5
|
||||
it("uses archived_at for the archive cues instead of terminal state", () => {
|
||||
const archivedInProgress = task("archived-in-progress", "Archived in progress", {
|
||||
state: "IN_PROGRESS",
|
||||
archived_at: "2026-08-24T09:05:00Z",
|
||||
});
|
||||
const unarchivedCompleted = task("unarchived-completed", "Unarchived completed", {
|
||||
state: "COMPLETED",
|
||||
});
|
||||
|
||||
render(
|
||||
taskResults(
|
||||
[archivedInProgress, unarchivedCompleted],
|
||||
new Map(),
|
||||
new Map(),
|
||||
new Map([["step-1", { name: "In Progress", color: "bg-blue-500" }]]),
|
||||
),
|
||||
);
|
||||
|
||||
const archivedBadge = screen.getByText("Archived", { exact: true });
|
||||
const archivedRow = archivedBadge.closest('[role="option"]');
|
||||
const archivedTitle = screen.getByText("Archived in progress", { exact: true });
|
||||
|
||||
expect(archivedRow).not.toBeNull();
|
||||
expect(archivedRow!.className).not.toContain("opacity-60");
|
||||
expect(archivedRow!.contains(screen.getByText("In Progress", { exact: true }))).toBe(false);
|
||||
expect(archivedTitle.className).toContain("text-muted-foreground");
|
||||
expect(archivedBadge.className).toContain("text-muted-foreground");
|
||||
expect(screen.getByTestId("task-state-archived")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Archived" })).toBeTruthy();
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7
|
||||
it("keeps archived result selection on the existing row action", () => {
|
||||
const archivedTask = task("archived-task", "Archived task", {
|
||||
state: "IN_PROGRESS",
|
||||
archived_at: "2026-08-24T09:05:00Z",
|
||||
});
|
||||
const onTaskSelect = vi.fn();
|
||||
|
||||
render(taskResults([archivedTask], new Map(), new Map(), new Map(), onTaskSelect));
|
||||
|
||||
fireEvent.click(screen.getByRole("option"));
|
||||
|
||||
expect(onTaskSelect).toHaveBeenCalledWith(archivedTask);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,6 +308,62 @@ test.describe("Command Panel", () => {
|
||||
await expect(dialog.getByText("Searchable E2E Task")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.6, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7
|
||||
test("archived task results show archive cues and follow active results", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const unarchivedCompleted = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Command panel unarchived completed",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
},
|
||||
);
|
||||
await apiClient.updateTaskState(unarchivedCompleted.id, "COMPLETED");
|
||||
|
||||
const archivedInProgress = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Command panel archived in progress",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
},
|
||||
);
|
||||
await apiClient.archiveTask(archivedInProgress.id);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
await openCommandPanel(testPage);
|
||||
const dialog = commandDialog(testPage);
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("combobox").fill("Command panel");
|
||||
|
||||
const taskResults = dialog.getByTestId("command-panel-task-preview");
|
||||
await expect(taskResults).toBeVisible({ timeout: 10_000 });
|
||||
const resultOptions = taskResults.getByRole("option");
|
||||
await expect(resultOptions).toHaveCount(2);
|
||||
await expect(resultOptions.nth(0)).toContainText(unarchivedCompleted.title);
|
||||
await expect(resultOptions.nth(1)).toContainText(archivedInProgress.title);
|
||||
|
||||
const archivedOption = resultOptions.nth(1);
|
||||
const startStep = seedData.steps.find((step) => step.id === seedData.startStepId)!;
|
||||
await expect(archivedOption.getByTestId("task-state-archived")).toBeVisible();
|
||||
await expect(archivedOption.getByRole("img", { name: "Archived" })).toBeVisible();
|
||||
await expect(archivedOption.getByText("Archived", { exact: true })).toBeVisible();
|
||||
await expect(archivedOption.getByText(startStep.name, { exact: true })).toHaveCount(0);
|
||||
await expect(resultOptions.nth(0).getByTestId("task-state-archived")).toHaveCount(0);
|
||||
await expect(resultOptions.nth(0).getByText("Archived", { exact: true })).toHaveCount(0);
|
||||
|
||||
await archivedOption.click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${archivedInProgress.id}$`));
|
||||
});
|
||||
|
||||
test("task activity icon distinguishes running and idle search results", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
/**
|
||||
@@ -65,3 +66,47 @@ test("@search the palette scope strip wraps below the field on a phone", async (
|
||||
const titleBox = (await taskRow.getByText("Mobile palette scopes").first().boundingBox())!;
|
||||
expect(titleBox.width).toBeGreaterThan(60);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3, AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.4,
|
||||
// AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.8
|
||||
test("@search archived task results keep their cues and title space on a phone", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const archivedInProgress = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile archived in progress",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
},
|
||||
);
|
||||
await apiClient.archiveTask(archivedInProgress.id);
|
||||
|
||||
await new KanbanPage(testPage).goto();
|
||||
await testPage.keyboard.press(`${MODIFIER}+k`);
|
||||
const dialog = testPage.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const input = dialog.getByRole("combobox");
|
||||
await input.fill("Mobile archived");
|
||||
const taskResults = dialog.getByTestId("command-panel-task-preview");
|
||||
await expect(taskResults).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const archivedOption = taskResults
|
||||
.getByRole("option")
|
||||
.filter({ hasText: archivedInProgress.title });
|
||||
await expect(archivedOption).toBeVisible();
|
||||
await expect(archivedOption.getByTestId("task-state-archived")).toBeVisible();
|
||||
await expect(archivedOption.getByRole("img", { name: "Archived" })).toBeVisible();
|
||||
await expect(archivedOption.getByText("Archived", { exact: true })).toBeVisible();
|
||||
|
||||
const titleBox = (await archivedOption.getByText(archivedInProgress.title).boundingBox())!;
|
||||
expect(titleBox.width).toBeGreaterThan(60);
|
||||
const overflows = await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth > window.innerWidth,
|
||||
);
|
||||
expect(overflows).toBe(false);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useInlineTaskSearchEffect } from "./use-command-panel-task-results";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { taskId, workflowId, workspaceId } from "@/lib/types/ids";
|
||||
|
||||
function task(id: string, title: string): Task {
|
||||
function task(id: string, title: string, overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: taskId(id),
|
||||
workspace_id: workspaceId("workspace-1"),
|
||||
@@ -23,24 +23,36 @@ function task(id: string, title: string): Task {
|
||||
priority: "medium",
|
||||
created_at: "2026-08-24T09:00:00Z",
|
||||
updated_at: "2026-08-24T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const STEPS = [{ id: "step-1", position: 0, show_in_command_panel: true }];
|
||||
|
||||
function harness(setTaskResults: (t: Task[]) => void, setIsSearching: (s: boolean) => void) {
|
||||
type HarnessProps = {
|
||||
mode: "commands" | "search-tasks";
|
||||
search?: string;
|
||||
};
|
||||
|
||||
function harness(
|
||||
setTaskResults: (t: Task[]) => void,
|
||||
setIsSearching: (s: boolean) => void,
|
||||
search = "",
|
||||
) {
|
||||
return renderHook(
|
||||
({ mode }: { mode: "commands" | "search-tasks" }) =>
|
||||
({ mode, search = "" }: HarnessProps) =>
|
||||
useInlineTaskSearchEffect({
|
||||
mode,
|
||||
search: "",
|
||||
search,
|
||||
open: true,
|
||||
workspaceId: "workspace-1",
|
||||
steps: STEPS,
|
||||
setTaskResults,
|
||||
setIsSearching,
|
||||
}),
|
||||
{ initialProps: { mode: "commands" } as { mode: "commands" | "search-tasks" } },
|
||||
{
|
||||
initialProps: { mode: "commands", search } as HarnessProps,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,4 +98,78 @@ describe("useInlineTaskSearchEffect", () => {
|
||||
rerender({ mode: "commands" });
|
||||
expect(setTaskResults).not.toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5
|
||||
it("uses archived_at instead of terminal state for the active task preview", async () => {
|
||||
const archivedInProgress = task("archived-in-progress", "Archived in progress", {
|
||||
state: "IN_PROGRESS",
|
||||
archived_at: "2026-08-24T09:05:00Z",
|
||||
});
|
||||
const unarchivedCompleted = task("unarchived-completed", "Unarchived completed", {
|
||||
state: "COMPLETED",
|
||||
});
|
||||
listTasksByWorkspace.mockResolvedValue({ tasks: [archivedInProgress, unarchivedCompleted] });
|
||||
const setTaskResults = vi.fn();
|
||||
|
||||
harness(setTaskResults, vi.fn());
|
||||
|
||||
await waitFor(() => expect(setTaskResults).toHaveBeenCalledWith([unarchivedCompleted]));
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.6
|
||||
it("ranks archived matches after non-archived matches by archived_at", async () => {
|
||||
const archivedInProgress = task("archived-in-progress", "Archived in progress", {
|
||||
state: "IN_PROGRESS",
|
||||
archived_at: "2026-08-24T09:05:00Z",
|
||||
});
|
||||
const unarchivedCompleted = task("unarchived-completed", "Unarchived completed", {
|
||||
state: "COMPLETED",
|
||||
});
|
||||
listTasksByWorkspace.mockResolvedValue({ tasks: [archivedInProgress, unarchivedCompleted] });
|
||||
const setTaskResults = vi.fn();
|
||||
|
||||
harness(setTaskResults, vi.fn(), "archived");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setTaskResults).toHaveBeenCalledWith([unarchivedCompleted, archivedInProgress]),
|
||||
);
|
||||
});
|
||||
|
||||
it("queries active matches before a bounded archived fallback page", async () => {
|
||||
const archivedTasks = Array.from({ length: 20 }, (_, index) =>
|
||||
task(`archived-${index}`, `Archived ${index}`, {
|
||||
archived_at: `2026-08-24T09:${String(index).padStart(2, "0")}:00Z`,
|
||||
}),
|
||||
);
|
||||
const unarchivedTask = task("unarchived-match", "Unarchived match", {
|
||||
state: "COMPLETED",
|
||||
});
|
||||
listTasksByWorkspace.mockImplementation(
|
||||
(_workspaceId: string, params: { onlyArchived?: boolean }) =>
|
||||
Promise.resolve(
|
||||
params.onlyArchived
|
||||
? { tasks: archivedTasks, total: 10_000 }
|
||||
: { tasks: [unarchivedTask], total: 1 },
|
||||
),
|
||||
);
|
||||
const setTaskResults = vi.fn();
|
||||
|
||||
harness(setTaskResults, vi.fn(), "match");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setTaskResults).toHaveBeenCalledWith([unarchivedTask, ...archivedTasks.slice(0, 4)]),
|
||||
);
|
||||
expect(listTasksByWorkspace).toHaveBeenCalledTimes(2);
|
||||
expect(listTasksByWorkspace.mock.calls[0][1]).toEqual({
|
||||
query: "match",
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
});
|
||||
expect(listTasksByWorkspace.mock.calls[1][1]).toEqual({
|
||||
query: "match",
|
||||
page: 1,
|
||||
pageSize: 4,
|
||||
onlyArchived: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { CommandPanelMode } from "@/lib/commands/types";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { MODE_COMMANDS, MODE_SEARCH_TASKS } from "@/components/command-panel-results";
|
||||
|
||||
const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]);
|
||||
/** Task rows previewed alongside the commands in the commands scope. */
|
||||
const TASK_PREVIEW_SIZE = 5;
|
||||
/** Task rows listed in the dedicated tasks scope. */
|
||||
@@ -45,7 +44,7 @@ type ActiveTaskQuery = {
|
||||
resultLimit: number;
|
||||
};
|
||||
|
||||
/** Active tasks for the idle palette: no backlog or done steps, workflow order. */
|
||||
/** Unarchived task preview for the idle palette, ordered by workflow step. */
|
||||
async function fetchActiveTasks(query: ActiveTaskQuery): Promise<Task[]> {
|
||||
const { workspaceId, signal, visibleStepIds, stepPositionMap, resultLimit } = query;
|
||||
// Deliberately over-fetched: the step and archived filters below run on the
|
||||
@@ -57,8 +56,7 @@ async function fetchActiveTasks(query: ActiveTaskQuery): Promise<Task[]> {
|
||||
{ init: { signal } },
|
||||
);
|
||||
const tasks = (res.tasks ?? []).filter(
|
||||
(t) =>
|
||||
(!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && !ARCHIVED_STATES.has(t.state),
|
||||
(t) => (!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && t.archived_at == null,
|
||||
);
|
||||
tasks.sort(
|
||||
(a, b) =>
|
||||
@@ -68,23 +66,33 @@ async function fetchActiveTasks(query: ActiveTaskQuery): Promise<Task[]> {
|
||||
return tasks.slice(0, resultLimit);
|
||||
}
|
||||
|
||||
/** Task search results, archived matches last. */
|
||||
/** Task search results, with archived matches fetched only as a fallback. */
|
||||
async function fetchMatchingTasks(
|
||||
workspaceId: string,
|
||||
query: string,
|
||||
resultLimit: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<Task[]> {
|
||||
const res = await listTasksByWorkspace(
|
||||
const activeResponse = await listTasksByWorkspace(
|
||||
workspaceId,
|
||||
{ query, page: 1, pageSize: resultLimit, includeArchived: true },
|
||||
{ query, page: 1, pageSize: resultLimit },
|
||||
{ init: { signal } },
|
||||
);
|
||||
const tasks = res.tasks ?? [];
|
||||
tasks.sort(
|
||||
(a, b) => (ARCHIVED_STATES.has(a.state) ? 1 : 0) - (ARCHIVED_STATES.has(b.state) ? 1 : 0),
|
||||
const activeTasks = (activeResponse.tasks ?? []).filter((task) => task.archived_at == null);
|
||||
if (activeTasks.length >= resultLimit) return activeTasks.slice(0, resultLimit);
|
||||
|
||||
const archivedResponse = await listTasksByWorkspace(
|
||||
workspaceId,
|
||||
{
|
||||
query,
|
||||
page: 1,
|
||||
pageSize: resultLimit - activeTasks.length,
|
||||
onlyArchived: true,
|
||||
},
|
||||
{ init: { signal } },
|
||||
);
|
||||
return tasks;
|
||||
const archivedTasks = (archivedResponse.tasks ?? []).filter((task) => task.archived_at != null);
|
||||
return [...activeTasks, ...archivedTasks].slice(0, resultLimit);
|
||||
}
|
||||
|
||||
export function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) {
|
||||
@@ -109,7 +117,7 @@ export function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
|
||||
// No search: load active tasks (excluding backlog + done steps)
|
||||
// No search: load unarchived tasks in the visible workflow steps.
|
||||
if (!search.trim()) {
|
||||
if (!open || !workspaceId) {
|
||||
setTaskResults([]);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
created: 2026-09-02
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/command-panel-archived-task-results.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Command Panel Archived Task Results
|
||||
|
||||
## Overview
|
||||
|
||||
Use `archived_at` for command-panel archive classification. Then give archived task results a distinct label, icon, and muted color.
|
||||
|
||||
One work order owns this vertical UI change and its focused desktop and phone evidence.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Classify and order archived task results by `archived_at`.
|
||||
- Replace the activity icon with an archive icon for archived results.
|
||||
- Replace the workflow-step badge with an **Archived** badge for archived results.
|
||||
- Use muted semantic colors without weakening selection feedback.
|
||||
- Add hook, component, desktop, and phone regression coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend, API, archive-state, or task-detail changes.
|
||||
- Search matching semantics or changes to the shared task-list API contract.
|
||||
- Command-panel geometry, scrolling, or touch-target changes.
|
||||
- Changes to task-state icons on other surfaces.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Archive classification and ordering
|
||||
|
||||
Update `apps/web/hooks/use-command-panel-task-results.ts`. Use `task.archived_at == null` for the unarchived preview and `task.archived_at != null` for search-result grouping.
|
||||
|
||||
Request page one of the default unarchived search results with the display limit. If fewer rows are returned, request only archived matches with the remaining limit and concatenate the two groups. Non-archived matches remain first, backend order stays intact within each group, and the search performs at most two requests.
|
||||
|
||||
Add hook cases for an archived in-progress task and an unarchived terminal task.
|
||||
|
||||
### Result-row presentation
|
||||
|
||||
Update `TaskResultItem` in `apps/web/components/command-panel-results.tsx`. Derive archive presentation from `task.archived_at`.
|
||||
|
||||
For an archived result, show `IconArchive` with a localized accessible label. Replace the workflow-step badge with an outline **Archived** badge.
|
||||
|
||||
Use muted semantic text and border classes. Do not apply opacity to the command item because it also mutes selection feedback.
|
||||
|
||||
Keep `TaskStateIcon`, workflow-step color, and current activity resolution for non-archived results. Keep the current selection callback for every result.
|
||||
|
||||
### Localization
|
||||
|
||||
Reuse `tasks:archived`. The key exists in all supported locale catalogs, so the implementation does not add user-facing copy.
|
||||
|
||||
### Mobile parity
|
||||
|
||||
Desktop and phone use the same existing result row. The nearest mobile example is `apps/web/e2e/tests/search/mobile-command-palette-scopes.spec.ts`.
|
||||
|
||||
This change replaces one icon and one badge. It does not change composition, navigation, scrolling, safe areas, or touch targets.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| `AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1` to `.4` | Component tests cover the badge, icon, accessible label, and muted semantic classes. |
|
||||
| `AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5` and `.6` | Hook tests prove `archived_at` classification and ordering, including bounded active and archived requests. |
|
||||
| `AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7` | A component test invokes the existing selection callback for an archived result. |
|
||||
| `AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.8` | Desktop and phone Playwright tests cover visible cues, title space, and overflow. |
|
||||
|
||||
## E2E tests
|
||||
|
||||
Extend `apps/web/e2e/tests/command-panel.spec.ts` with active and archived matches. Assert the non-archived result comes first and the archived result shows its label and icon.
|
||||
|
||||
Extend `apps/web/e2e/tests/search/mobile-command-palette-scopes.spec.ts`. Assert the same archive cues and no document-level horizontal overflow.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Distinguish archived task results](task-01-distinguish-archived-task-results.md) - complete; focused unit, desktop, and phone evidence passes
|
||||
|
||||
## Verification results
|
||||
|
||||
- GREEN focused unit suite: `cd apps/web && pnpm exec vitest run hooks/use-command-panel-task-results.test.ts components/command-panel-task-activity.test.tsx` - 2 files, 16 tests passed.
|
||||
- GREEN typecheck: `cd apps/web && pnpm run typecheck` - passed.
|
||||
- GREEN targeted ESLint: `cd apps/web && pnpm exec eslint --max-warnings 0 hooks/use-command-panel-task-results.ts components/command-panel-results.tsx hooks/use-command-panel-task-results.test.ts components/command-panel-task-activity.test.tsx` - passed.
|
||||
- GREEN i18n checks: `cd apps/web && pnpm run i18n:check` - all catalogs and copy guards passed.
|
||||
- GREEN desktop E2E: `cd apps/web && pnpm e2e:run tests/command-panel.spec.ts -- --grep "archived task result" --retries=0` - 1 test passed.
|
||||
- GREEN phone E2E: `cd apps/web && pnpm e2e:run --project mobile-chrome tests/search/mobile-command-palette-scopes.spec.ts -- --grep "archived task result" --retries=0` - 1 test passed.
|
||||
- GREEN specification lint: `python3 scripts/lint-spec-files.py --all` - passed.
|
||||
- GREEN whitespace check: `git diff --check -- docs/specs docs/plans apps/web` - passed.
|
||||
- GREEN rendered evidence: disposable managed E2E capture produced validated 1440x900 desktop and 393x852 phone PNGs; the temporary capture spec was removed and the assets remain ignored for PR publication.
|
||||
- Review remediation: search results now use a bounded active-then-archived fallback through the existing task-list API. A regression covers a large archived total without issuing more than two requests.
|
||||
|
||||
## Risks
|
||||
|
||||
- The task-list API returns one archive mode per request. The hook must query unarchived matches before using an archived fallback so archive ordering is correct without an unbounded request waterfall.
|
||||
- Row opacity can hide the selected state. The implementation must use semantic muted colors instead.
|
||||
- The archived badge must replace the workflow-step badge so phone title space does not decrease.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: "01-distinguish-archived-task-results"
|
||||
title: "Distinguish archived task results"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.4
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.6
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7
|
||||
- AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.8
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/command-panel-archived-task-results.md
|
||||
---
|
||||
|
||||
# Task 01: Distinguish Archived Task Results
|
||||
|
||||
## Summary
|
||||
|
||||
Use authoritative archive state for command-panel search ordering and result presentation. Show archived status before the user opens a read-only task.
|
||||
|
||||
## In scope
|
||||
|
||||
- Replace terminal-state archive inference with `archived_at`.
|
||||
- Fetch unarchived search results first, then request only the remaining archived matches.
|
||||
- Add the archived label, archive icon, accessible label, and muted semantic colors.
|
||||
- Preserve non-archived activity icons, workflow badges, result selection, and navigation.
|
||||
- Add focused hook, component, desktop, and phone tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backend, archive-state, and task-detail changes.
|
||||
- Search matching semantics or changes to the shared task-list API contract.
|
||||
- Layout, scroll, or touch-target changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Archived results use an **Archived** badge, archive icon, accessible label, and muted semantic colors.
|
||||
- Non-archived terminal tasks keep normal task presentation, and archived matches sort after non-archived matches with at most one active request and one archived fallback request.
|
||||
- Desktop and phone results remain readable, selectable, and free of document-level horizontal overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(
|
||||
cd apps/web
|
||||
pnpm exec vitest run hooks/use-command-panel-task-results.test.ts components/command-panel-task-activity.test.tsx
|
||||
pnpm run typecheck
|
||||
pnpm exec eslint --max-warnings 0 hooks/use-command-panel-task-results.ts components/command-panel-results.tsx hooks/use-command-panel-task-results.test.ts components/command-panel-task-activity.test.tsx
|
||||
pnpm run i18n:check
|
||||
pnpm e2e:run tests/command-panel.spec.ts -- --grep "archived task result"
|
||||
pnpm e2e:run --project mobile-chrome tests/search/mobile-command-palette-scopes.spec.ts -- --grep "archived task result"
|
||||
)
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check -- docs/specs docs/plans apps/web
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/hooks/use-command-panel-task-results.ts`
|
||||
- `apps/web/hooks/use-command-panel-task-results.test.ts`
|
||||
- `apps/web/components/command-panel-results.tsx`
|
||||
- `apps/web/components/command-panel-task-activity.test.tsx`
|
||||
- `apps/web/e2e/tests/command-panel.spec.ts`
|
||||
- `apps/web/e2e/tests/search/mobile-command-palette-scopes.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. The task-list response and shared task-state icon already provide the required inputs.
|
||||
|
||||
## Risks
|
||||
|
||||
- A partial change can preserve incorrect archive ordering while fixing only the row appearance.
|
||||
- Opacity on the complete row can make keyboard selection less clear.
|
||||
- A second badge can reduce phone title space. The archived badge must replace the workflow-step badge.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001`.
|
||||
- `docs/specs/tasks/system-design/command-panel-archived-task-results.md`.
|
||||
- Existing command-panel search, activity-icon, and mobile-scope tests.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the hook and component regressions initially failed because terminal state was still the archive source and archived rows had no dedicated presentation.
|
||||
- GREEN: `archived_at` now owns archive filtering, ordering, and row presentation. Archived rows use the archive icon, localized Archived label, replacement badge, muted semantic colors, and the existing selection callback.
|
||||
- GREEN focused unit suite: 2 files, 16 tests passed.
|
||||
- GREEN desktop E2E: the active result precedes the archived result, archive cues are visible, and selection navigates to the archived task detail.
|
||||
- GREEN phone E2E: the same cues remain visible, title space remains usable, and document-level horizontal overflow is absent.
|
||||
- GREEN typecheck, targeted ESLint, i18n checks, specification lint, and diff whitespace checks passed.
|
||||
- Rendered evidence: disposable managed capture produced validated 1440x900 desktop and 393x852 phone PNGs; the capture spec was removed before staging.
|
||||
- Review remediation: the hook now queries active results before a bounded archived fallback; a regression covers a large archived total without extra pages. The mobile E2E fixture only seeds the archived task queried by that test.
|
||||
@@ -42,6 +42,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Attach Workspace Sources](requirements/attach-workspace-sources.md)
|
||||
- [Task Autopilot Mode](requirements/autopilot-mode.md)
|
||||
- [Blocked Task Escalation](requirements/blocked-task-escalation.md)
|
||||
- [Command-panel archived task results](requirements/command-panel-archived-task-results.md)
|
||||
- [Active clarification lifecycle scenarios](requirements/clarification-active-lifecycle-scenarios.md)
|
||||
- [Active clarification lifecycle](requirements/clarification-active-lifecycle.md)
|
||||
- [Task Documents](requirements/documents.md)
|
||||
@@ -119,6 +120,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md)
|
||||
- [Environment-Owned Git Status](system-design/environment-owned-git-status.md)
|
||||
- [Attach Workspace Sources](system-design/attach-workspace-sources.md)
|
||||
- [Command-panel archived task results](system-design/command-panel-archived-task-results.md)
|
||||
- [Quick Chat Agent Titles](system-design/quick-chat-agent-titles.md)
|
||||
- [Quick Chat Session Resumption](system-design/quick-chat-session-resumption.md)
|
||||
- [Active clarification lifecycle](system-design/clarification-active-lifecycle.md)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
status: active
|
||||
system: tasks
|
||||
created: 2026-09-02
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Command Panel Archived Task Results Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Task search includes archived tasks. Users must identify an archived task before
|
||||
they select it and open its read-only detail.
|
||||
|
||||
The Tasks system owns this archive-state-derived result contract. UI
|
||||
implementations render its desktop and phone outcomes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001: Archived task result presentation
|
||||
|
||||
**Intent:** Make archived task results distinct before selection without changing their navigation behavior.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.1:** When `archived_at` is present, the task result shall show an **Archived** label instead of its workflow-step badge.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.2:** When `archived_at` is present, the result shall show an archive icon instead of its task-activity icon.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.3:** The archive icon shall have an accessible **Archived** description and shall remain part of the single result action.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.4:** An archived result shall use muted semantic colors. Keyboard focus and the selected state shall remain clear.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.5:** When `archived_at` is absent, a terminal workflow state shall not cause archived presentation.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.6:** Task search shall rank archived matches after non-archived matches by using `archived_at` as the archive source.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.7:** Selecting an archived result shall keep the current task-detail navigation behavior.
|
||||
- **AC-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001.8:** Desktop and phone results shall show the same archive cues without reducing title readability or causing horizontal overflow.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to task archive state or task-detail behavior.
|
||||
- Changes to search matching, result limits, or ranking within each archive group.
|
||||
- Changes to the command-panel layout, scrolling, or touch targets.
|
||||
- New task or session API fields.
|
||||
|
||||
## System design
|
||||
|
||||
- [Command Panel Archived Task Results System Design](../system-design/command-panel-archived-task-results.md)
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001
|
||||
---
|
||||
|
||||
# Command Panel Archived Task Results System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design defines the task-system contract for archive state in command-panel
|
||||
task results. It does not change archive state or task navigation.
|
||||
|
||||
The workspace task-list response already includes `archived_at`. No backend,
|
||||
WebSocket, or persistence change is necessary.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design sections |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-COMMAND-PANEL-ARCHIVED-TASKS-001` | [Archive classification](#archive-classification), [Result-row presentation](#result-row-presentation), [Responsive behavior](#responsive-behavior), [Tests](#tests) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `useInlineTaskSearchEffect` queries unarchived matches first and requests only the remaining archived matches when needed.
|
||||
- `TaskResultItem` derives archive presentation from the HTTP task result.
|
||||
- The existing task-list `Archived` translation supplies the visible and accessible label.
|
||||
- The existing command item remains the only interactive element in the result row.
|
||||
|
||||
## Archive classification
|
||||
|
||||
`Task.archived_at` is the only archive source for command-panel task results.
|
||||
Terminal workflow states do not mean that a task is archived.
|
||||
|
||||
`fetchMatchingTasks` first requests page one with the default archive filter and
|
||||
the requested display limit. If fewer non-archived matches are returned, it
|
||||
requests page one in `onlyArchived` mode with the remaining limit. It
|
||||
concatenates the two responses, preserving backend order within each group.
|
||||
This keeps the search bounded to at most two requests and ensures an active
|
||||
match remains visible when archived matches are newer.
|
||||
|
||||
The empty-query task preview continues to request active tasks only. Its
|
||||
defensive filter also uses `archived_at` and does not exclude terminal states.
|
||||
|
||||
## Result-row presentation
|
||||
|
||||
When `archived_at` is present, `TaskResultItem` changes three visual cues:
|
||||
|
||||
- An `IconArchive` glyph replaces `TaskStateIcon` in the leading icon slot.
|
||||
- An outline **Archived** badge replaces the workflow-step badge.
|
||||
- Semantic muted-foreground classes replace active task colors for the icon, title, badge, and metadata.
|
||||
|
||||
The archive glyph uses `role="img"` and the localized **Archived** label. It
|
||||
also has a stable test identifier.
|
||||
|
||||
The command item keeps its selected background and focus behavior. Muted
|
||||
classes do not use row opacity because opacity also weakens selection feedback.
|
||||
|
||||
Non-archived results keep the shared activity icon and workflow-step badge.
|
||||
Selection continues to call the existing task navigation handler.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Desktop and phone layouts continue to use the same command-panel result row.
|
||||
The archived badge replaces one existing badge, so the row gains no new width.
|
||||
|
||||
The title remains the flexible, truncated element. Metadata remains hidden on a
|
||||
phone, and the command panel remains the scroll owner.
|
||||
|
||||
The nearest phone example is the task row in
|
||||
`mobile-command-palette-scopes.spec.ts`. This change affects content and color
|
||||
only.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
If `archived_at` is absent, the result uses its normal activity icon and
|
||||
workflow-step badge. Unknown optional task fields do not remove the result.
|
||||
|
||||
If an archived task is selected, the existing task route shows the archived
|
||||
detail and its current recovery action.
|
||||
|
||||
## Tests
|
||||
|
||||
Hook tests cover archive ordering, bounded active-then-archived requests, and
|
||||
prove that terminal workflow state does not imply archive state.
|
||||
|
||||
Component tests cover the archive icon, accessible label, badge replacement,
|
||||
muted classes, and unchanged selection callback.
|
||||
|
||||
Desktop Playwright coverage searches for active and archived matches. It proves
|
||||
their order and the archive cues before selection.
|
||||
|
||||
Phone Playwright coverage proves the same cues, readable title space, and no
|
||||
document-level horizontal overflow.
|
||||
|
||||
## Related specifications
|
||||
|
||||
- [Task Archive Confirmation](archive-confirmation.md)
|
||||
- [Task Activity Icons](../../ui/system-design/command-panel-task-activity-icons.md)
|
||||
- [Task Workspace Content Search](../../ui/requirements/task-workspace-content-search.md)
|
||||
Reference in New Issue
Block a user