feat: show tasks created from a task by project
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
42a4f5b15b
commit
bcbb2961c1
|
|
@ -247,6 +247,8 @@ See `doc/project-repositories.md` for the API and UI contract.
|
|||
- `created_by_user_id` uuid fk `users.id` null
|
||||
- identifier fields: `issue_number`, `identifier`
|
||||
- origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint`
|
||||
- Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped.
|
||||
- The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks.
|
||||
- `request_depth` int not null default 0
|
||||
- `work_mode` text not null default `standard`; supported values:
|
||||
- `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task.
|
||||
|
|
|
|||
|
|
@ -176,6 +176,11 @@ When a task originates from a cross-team request, track the **depth** as an inte
|
|||
|
||||
#### Billing Codes
|
||||
|
||||
Task detail keeps hierarchy separate from creation provenance: the Tasks tab shows
|
||||
all subtasks and, independently, work created from the current task grouped by
|
||||
project or No project. A created subtask may appear in both sections. Creation
|
||||
provenance follows the originating run equally for legacy and native runners.
|
||||
|
||||
Tasks carry a **billing code** so that token spend during execution can be attributed upstream to the requesting task/agent. When Agent A asks Agent B to do work, the cost of B's work is tracked against A's request. This enables cost attribution across the org.
|
||||
|
||||
### Open Questions
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("tasks created from an issue", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const sourceId = randomUUID();
|
||||
const otherSourceId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const legacyRunId = randomUUID();
|
||||
const nativeRunId = randomUUID();
|
||||
const taskIdRunId = randomUUID();
|
||||
const taskKeyRunId = randomUUID();
|
||||
const unrelatedRunId = randomUUID();
|
||||
const foreignRunId = randomUUID();
|
||||
const missingContextRunId = randomUUID();
|
||||
const foreignSourceId = randomUUID();
|
||||
const expected = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-created-from-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(companies).values([
|
||||
{ id: companyId, name: "Origin", issuePrefix: "ORG", defaultResponsibleUserId: "board-user" },
|
||||
{ id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user" },
|
||||
]);
|
||||
const foreignAgentId = randomUUID();
|
||||
await db.insert(agents).values([
|
||||
{ id: agentId, companyId, name: "Coder", role: "engineer", adapterType: "codex_local" },
|
||||
{ id: foreignAgentId, companyId: otherCompanyId, name: "Other coder", role: "engineer", adapterType: "codex_local" },
|
||||
]);
|
||||
await db.insert(issues).values([
|
||||
{ id: sourceId, companyId, title: "Source", identifier: "ORG-1", issueNumber: 1 },
|
||||
{ id: otherSourceId, companyId, title: "Other source", identifier: "ORG-2", issueNumber: 2 },
|
||||
{ id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1" },
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{ id: legacyRunId, companyId, agentId, contextSnapshot: { issueId: sourceId }, responsibleUserId: "board-user" },
|
||||
{ id: nativeRunId, companyId, agentId, runtimeMode: "native", nativeIssueId: sourceId, contextSnapshot: { issueId: otherSourceId } },
|
||||
{ id: taskIdRunId, companyId, agentId, contextSnapshot: { taskId: sourceId } },
|
||||
{ id: taskKeyRunId, companyId, agentId, contextSnapshot: { taskKey: "ORG-1" } },
|
||||
{ id: unrelatedRunId, companyId, agentId, contextSnapshot: { issueId: otherSourceId, taskKey: "ORG-1" } },
|
||||
{ id: foreignRunId, companyId: otherCompanyId, agentId: foreignAgentId, contextSnapshot: { issueId: sourceId } },
|
||||
{ id: missingContextRunId, companyId, agentId, contextSnapshot: {} },
|
||||
]);
|
||||
const projectId = randomUUID();
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Cross-project" });
|
||||
const rows = [
|
||||
{ title: "Legacy top-level", originRunId: legacyRunId, projectId },
|
||||
{ title: "Native child", originRunId: nativeRunId, parentId: sourceId },
|
||||
{ title: "Different parent", originRunId: taskIdRunId, parentId: otherSourceId },
|
||||
{ title: "No project, completed", originRunId: taskKeyRunId, status: "done" },
|
||||
].map((row) => ({ id: randomUUID(), companyId, createdByAgentId: agentId, ...row }));
|
||||
rows.forEach((row) => expected.add(row.id));
|
||||
const historicalId = randomUUID();
|
||||
const commentedId = randomUUID();
|
||||
expected.add(historicalId);
|
||||
await db.insert(issues).values([
|
||||
...rows,
|
||||
{ id: historicalId, companyId, title: "Historical child helper", parentId: otherSourceId },
|
||||
{ id: commentedId, companyId, title: "Only commented on by source" },
|
||||
{ companyId, title: "Manual child", parentId: sourceId },
|
||||
{ companyId, title: "Same agent, unrelated run", createdByAgentId: agentId, originRunId: unrelatedRunId },
|
||||
{ companyId, title: "Hidden", originRunId: legacyRunId, hiddenAt: new Date() },
|
||||
{ companyId, title: "No context", originRunId: missingContextRunId },
|
||||
{ companyId, title: "Wrong run company", originRunId: foreignRunId },
|
||||
{ companyId: otherCompanyId, title: "Wrong issue company", originRunId: legacyRunId },
|
||||
]);
|
||||
await db.insert(activityLog).values([
|
||||
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.child_created", entityType: "issue", entityId: historicalId },
|
||||
{ companyId, actorType: "agent", actorId: agentId, runId: legacyRunId, action: "issue.comment_added", entityType: "issue", entityId: commentedId },
|
||||
]);
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
|
||||
function app() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true };
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns both runners' created tasks, regardless of parent, project or completion", async () => {
|
||||
for (const view of [undefined, "compact"]) {
|
||||
const result = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: sourceId, ...(view ? { view } : {}) });
|
||||
expect(result.status, JSON.stringify(result.body)).toBe(200);
|
||||
expect(new Set(result.body.map((row: { id: string }) => row.id))).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not include manual children in creation provenance", async () => {
|
||||
const children = await issueService(db).list(companyId, { parentId: sourceId });
|
||||
expect(children.map((row) => row.title)).toContain("Manual child");
|
||||
expect(children.map((row) => row.title)).toContain("Native child");
|
||||
});
|
||||
|
||||
it("rejects malformed filters and never matches a source from another company", async () => {
|
||||
const invalid = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: "not-an-id" });
|
||||
expect(invalid.status).toBe(422);
|
||||
const foreign = await request(app()).get(`/api/companies/${companyId}/issues`).query({ createdFromIssueId: foreignSourceId });
|
||||
expect(foreign.status).toBe(200);
|
||||
expect(foreign.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("saves legacy child-helper creation provenance from the actor run", async () => {
|
||||
const created = await issueService(db).createChild(otherSourceId, {
|
||||
title: "Created while working on source, under another parent",
|
||||
actorRunId: legacyRunId,
|
||||
createdByAgentId: agentId,
|
||||
});
|
||||
expect(created.issue.originRunId).toBe(legacyRunId);
|
||||
const matches = await issueService(db).list(companyId, { createdFromIssueId: sourceId });
|
||||
expect(matches.map((row) => row.id)).toContain(created.issue.id);
|
||||
});
|
||||
});
|
||||
|
|
@ -7920,6 +7920,7 @@ export function issueRoutes(
|
|||
parentId: (req.query.parentId ?? req.query.parentIssueId) as
|
||||
string | undefined,
|
||||
descendantOf: req.query.descendantOf as string | undefined,
|
||||
createdFromIssueId: req.query.createdFromIssueId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
originKind: req.query.originKind as string | undefined,
|
||||
originKindPrefix: req.query.originKindPrefix as string | undefined,
|
||||
|
|
@ -8143,6 +8144,7 @@ export function issueRoutes(
|
|||
parentId: (req.query.parentId ?? req.query.parentIssueId) as
|
||||
string | undefined,
|
||||
descendantOf: req.query.descendantOf as string | undefined,
|
||||
createdFromIssueId: req.query.createdFromIssueId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
originKind: req.query.originKind as string | undefined,
|
||||
originKindPrefix: req.query.originKindPrefix as string | undefined,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { activityLog, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { isUuidLike } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
/** Creation provenance is independent of the task's current parent or project. */
|
||||
export function createdFromIssueCondition(companyId: string, sourceIssueId: string) {
|
||||
if (!isUuidLike(sourceIssueId)) {
|
||||
throw unprocessable("createdFromIssueId must be a UUID");
|
||||
}
|
||||
const source = alias(issues, "creation_source_issue");
|
||||
// Resolve source runs independently of the candidate tasks so the database
|
||||
// can reuse this set, rather than scan all company runs for each task.
|
||||
const originatingRuns = sql`
|
||||
SELECT ${heartbeatRuns.id}::text FROM ${heartbeatRuns}
|
||||
INNER JOIN ${issues} AS ${source} ON ${source.id} = ${sourceIssueId}
|
||||
AND ${source.companyId} = ${companyId}
|
||||
WHERE ${heartbeatRuns.companyId} = ${companyId}
|
||||
AND coalesce(
|
||||
${heartbeatRuns.nativeIssueId}::text,
|
||||
nullif(${heartbeatRuns.contextSnapshot}->>'issueId', ''),
|
||||
nullif(${heartbeatRuns.contextSnapshot}->>'taskId', ''),
|
||||
nullif(${heartbeatRuns.contextSnapshot}->>'taskKey', '')
|
||||
) IN (${source.id}::text, ${source.identifier})
|
||||
`;
|
||||
return sql<boolean>`
|
||||
${issues.id} <> ${sourceIssueId}
|
||||
AND (
|
||||
${issues.originRunId} IN (${originatingRuns})
|
||||
OR (${issues.originRunId} IS NULL AND EXISTS (
|
||||
SELECT 1 FROM ${activityLog}
|
||||
WHERE ${activityLog.companyId} = ${companyId}
|
||||
AND ${activityLog.entityType} = 'issue'
|
||||
AND ${activityLog.entityId} = ${issues.id}::text
|
||||
AND ${activityLog.action} IN ('issue.created', 'issue.child_created')
|
||||
AND ${activityLog.runId}::text IN (${originatingRuns})
|
||||
))
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { createdFromIssueCondition } from "./issue-creation-origin.js";
|
||||
import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
|
@ -1748,6 +1749,7 @@ export interface IssueFilters {
|
|||
executionWorkspaceId?: string;
|
||||
parentId?: string;
|
||||
descendantOf?: string;
|
||||
createdFromIssueId?: string;
|
||||
labelId?: string;
|
||||
originKind?: string;
|
||||
originKindPrefix?: string;
|
||||
|
|
@ -6240,6 +6242,9 @@ async function blockedInboxIssueConditions(
|
|||
const contextUserId =
|
||||
unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
|
||||
|
||||
if (filters?.createdFromIssueId) {
|
||||
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
|
||||
}
|
||||
if (filters?.descendantOf) {
|
||||
conditions.push(sql<boolean>`
|
||||
${issues.id} IN (
|
||||
|
|
@ -7928,6 +7933,9 @@ export function issueService(db: Db) {
|
|||
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
|
||||
)
|
||||
`;
|
||||
if (filters?.createdFromIssueId) {
|
||||
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
|
||||
}
|
||||
if (filters?.descendantOf) {
|
||||
conditions.push(sql<boolean>`
|
||||
${issues.id} IN (
|
||||
|
|
@ -10154,6 +10162,7 @@ export function issueService(db: Db) {
|
|||
|
||||
const values = {
|
||||
...issueData,
|
||||
originRunId: issueData.originRunId ?? actorRunId ?? null,
|
||||
responsibleUserId,
|
||||
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ describe("issuesApi.list", () => {
|
|||
mockApi.patch.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("fetches all pages of tasks created from the source without filtering parentage", async () => {
|
||||
const firstPage = Array.from({ length: 500 }, (_, index) => ({ id: `task-${index}` }));
|
||||
mockApi.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([{ id: "last-task" }]);
|
||||
const result = await issuesApi.listAll("company-1", { createdFromIssueId: "source-1" });
|
||||
expect(result).toHaveLength(501);
|
||||
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&offset=0");
|
||||
expect(mockApi.get).toHaveBeenNthCalledWith(2, "/companies/company-1/issues?createdFromIssueId=source-1&limit=500&offset=500");
|
||||
});
|
||||
|
||||
it("passes parentId through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", {
|
||||
parentId: "issue-parent-1",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export type IssueListFilters = {
|
|||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
descendantOf?: string;
|
||||
createdFromIssueId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
|
|
@ -135,6 +136,7 @@ function issueListSearchParams(filters?: IssueListFilters) {
|
|||
params.set("originKindPrefix", filters.originKindPrefix);
|
||||
if (filters?.originId) params.set("originId", filters.originId);
|
||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||
if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId);
|
||||
if (filters?.includeRoutineExecutions)
|
||||
params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
|
|
@ -155,6 +157,16 @@ function issueListSearchParams(filters?: IssueListFilters) {
|
|||
}
|
||||
|
||||
export const issuesApi = {
|
||||
/** Fetch every page for bounded task-detail relations, not just the default first page. */
|
||||
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset">, options?: RequestOptions): Promise<Issue[]> => {
|
||||
const pageSize = 500;
|
||||
const tasks = new Map<string, Issue>();
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const page = await issuesApi.list(companyId, { ...filters, limit: pageSize, offset }, options);
|
||||
for (const task of page) tasks.set(task.id, task);
|
||||
if (page.length < pageSize) return [...tasks.values()];
|
||||
}
|
||||
},
|
||||
list: (
|
||||
companyId: string,
|
||||
filters?: IssueListFilters,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function resolveTaskDetailSubtaskState(items: Issue[]) {
|
|||
};
|
||||
}
|
||||
|
||||
function SharedSubtaskList({
|
||||
export function TaskDetailTaskList({
|
||||
items,
|
||||
ariaLabel,
|
||||
issueLinkState,
|
||||
|
|
@ -166,7 +166,7 @@ export function TaskDetailSubtasksPanel({
|
|||
<h3 id="task-next-action-heading" className="text-xs font-medium text-muted-foreground">
|
||||
{nextAction.status === "blocked" ? "Blocked subtask" : "Next action"}
|
||||
</h3>
|
||||
<SharedSubtaskList
|
||||
<TaskDetailTaskList
|
||||
items={[nextAction]}
|
||||
ariaLabel="Next subtask action"
|
||||
issueLinkState={issueLinkState}
|
||||
|
|
@ -185,7 +185,7 @@ export function TaskDetailSubtasksPanel({
|
|||
<h3 id="task-other-subtasks-heading" className="text-xs font-medium text-muted-foreground">
|
||||
{nextAction ? "Other subtasks" : "Subtasks"}
|
||||
</h3>
|
||||
<SharedSubtaskList
|
||||
<TaskDetailTaskList
|
||||
items={remainingItems}
|
||||
ariaLabel={nextAction ? "Other subtasks" : "Subtasks"}
|
||||
issueLinkState={issueLinkState}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskDetailTasksPanel } from "./TaskDetailTasksPanel";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => <a href={to} {...props}>{children}</a> }));
|
||||
vi.mock("@/components/IssueRow", () => ({ IssueRow: ({ issue }: { issue: Issue }) => <span data-task-id={issue.id}>{issue.title}</span> }));
|
||||
const task = (id: string, overrides: Partial<Issue> = {}) => ({ id, title: id, status: "todo", projectId: null, ...overrides }) as Issue;
|
||||
const project = { id: "project-1", name: "Board UI", urlKey: "board-ui" } as Project;
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
afterEach(() => { act(() => root?.unmount()); container?.remove(); });
|
||||
function render(props: React.ComponentProps<typeof TaskDetailTasksPanel>) {
|
||||
container = document.createElement("div"); document.body.append(container);
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<TaskDetailTasksPanel {...props} />));
|
||||
}
|
||||
|
||||
describe("TaskDetailTasksPanel", () => {
|
||||
it("keeps subtask membership separate from creation membership, including overlap", () => {
|
||||
const manual = task("manual-child");
|
||||
const overlap = task("created-child", { projectId: project.id });
|
||||
const followup = task("other-parent", { parentId: "elsewhere", projectId: project.id });
|
||||
render({ subtasks: [manual, overlap], createdTasks: [overlap, followup], projects: [project] });
|
||||
expect(container.querySelectorAll('[data-task-id="created-child"]')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('[data-task-id="manual-child"]')).toHaveLength(1);
|
||||
const group = container.querySelector('section[aria-label="Board UI"]')!;
|
||||
expect(group.textContent).toContain("other-parent");
|
||||
expect(group.textContent).not.toContain("manual-child");
|
||||
expect(container.querySelectorAll('[role="progressbar"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides absent subtasks and puts unowned tasks under No project without a link or progress", () => {
|
||||
render({ subtasks: [], createdTasks: [task("unowned", { project: project })], projects: [project] });
|
||||
expect(container.textContent).not.toContain("Subtasks");
|
||||
expect(container.querySelector('section[aria-label="No project"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll('a, [role="progressbar"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sorts unfinished before completed work, deduplicates within groups, and folds independently", () => {
|
||||
const done = task("done", { status: "done", projectId: project.id });
|
||||
const unfinished = task("unfinished", { projectId: project.id });
|
||||
render({ subtasks: [task("child")], createdTasks: [done, unfinished, done], projects: [project] });
|
||||
const group = container.querySelector('section[aria-label="Board UI"]')!;
|
||||
expect([...group.querySelectorAll('[data-task-id]')].map((row) => row.getAttribute("data-task-id"))).toEqual(["unfinished", "done"]);
|
||||
expect(group.querySelector('a')?.getAttribute("href")).toBe("/projects/board-ui/issues");
|
||||
act(() => (group.querySelector('button') as HTMLButtonElement).click());
|
||||
expect(group.querySelector('[data-task-id]')).toBeNull();
|
||||
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces query failures without hiding available subtasks", () => {
|
||||
const retry = vi.fn();
|
||||
render({ subtasks: [task("child")], createdTasks: [], projects: [], hasError: true, onRetry: retry });
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load all tasks");
|
||||
act(() => [...container.querySelectorAll('button')].find((button) => button.textContent === "Retry")!.click());
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('[data-task-id="child"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
import { ArrowUpRight, ChevronRight } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { projectRouteRef } from "@/lib/utils";
|
||||
import { issueStatusOrder } from "@/lib/issue-filters";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel";
|
||||
|
||||
function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) {
|
||||
return (
|
||||
<Collapsible defaultOpen asChild>
|
||||
<section aria-label={name}>
|
||||
<div className="group/header flex items-center gap-1 rounded-md hover:bg-accent/50">
|
||||
<h2>
|
||||
<CollapsibleTrigger className="group flex items-center gap-1 rounded-md py-1 text-left text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span>{name}</span>
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center opacity-0 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-standard) group-hover/header:opacity-100 group-focus-within/header:opacity-100">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) group-data-[state=open]:rotate-90" />
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
</h2>
|
||||
{projectPath && (
|
||||
<Link to={projectPath} aria-label={`Go to ${name} project`} title={`Go to ${name} project`} className="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-standard) hover:bg-accent hover:text-foreground group-hover/header:opacity-100 group-focus-within/header:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ArrowUpRight aria-hidden className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">{children}</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TaskDetailTasksPanelProps {
|
||||
subtasks: Issue[];
|
||||
createdTasks: Issue[];
|
||||
projects: Project[];
|
||||
isLoading?: boolean;
|
||||
hasError?: boolean;
|
||||
onRetry?: () => void;
|
||||
issueLinkState?: unknown;
|
||||
}
|
||||
|
||||
export function TaskDetailTasksPanel({ subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) {
|
||||
const sortedSubtasks = sortTasks(subtasks);
|
||||
const groups = new Map<string, { name: string; path?: string; tasks: Issue[] }>();
|
||||
for (const item of sortTasks(createdTasks)) {
|
||||
const key = item.projectId ?? "no-project";
|
||||
const project = item.projectId
|
||||
? projects.find((candidate) => candidate.id === item.projectId) ?? item.project
|
||||
: null;
|
||||
const group = groups.get(key) ?? {
|
||||
name: project?.name ?? (item.projectId ? "Project" : "No project"),
|
||||
path: item.projectId ? `/projects/${projectRouteRef(project ?? { id: item.projectId })}/issues` : undefined,
|
||||
tasks: [],
|
||||
};
|
||||
group.tasks.push(item);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return (
|
||||
<section className="flex flex-col gap-6" aria-label="Related tasks">
|
||||
{sortedSubtasks.length > 0 && (
|
||||
<TaskGroup name="Subtasks">
|
||||
<TaskDetailSubtasksPanel items={sortedSubtasks} issueLinkState={issueLinkState} />
|
||||
</TaskGroup>
|
||||
)}
|
||||
{[...groups.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name)).map(([id, group]) => (
|
||||
<TaskGroup key={id} name={group.name} projectPath={group.path}>
|
||||
<TaskDetailTaskList items={group.tasks} ariaLabel={`${group.name} tasks`} issueLinkState={issueLinkState} />
|
||||
</TaskGroup>
|
||||
))}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p>}
|
||||
{hasError && (
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
<span>Could not load all tasks.</span>
|
||||
{onRetry && <Button variant="ghost" size="sm" onClick={onRetry}>Retry</Button>}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !hasError && subtasks.length === 0 && createdTasks.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No tasks yet.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortTasks(items: Issue[]) {
|
||||
return [...new Map(items.map((item) => [item.id, item])).values()].sort((a, b) =>
|
||||
issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status),
|
||||
);
|
||||
}
|
||||
|
|
@ -195,6 +195,36 @@ describe("TaskSidePanel", () => {
|
|||
expect(container.textContent).toContain("Subtasks content: 2");
|
||||
});
|
||||
|
||||
it("adds related tasks without children and preserves the selected plan", async () => {
|
||||
fixture.plan = issueDocument("plan", "Plan");
|
||||
fixture.documents = [fixture.plan];
|
||||
await render(panel({
|
||||
showSubtasksTab: true,
|
||||
tasksTab: { count: 0, content: <div>No tasks yet</div> },
|
||||
}));
|
||||
expect(container.querySelector('[data-side-panel-tab-target="subtasks"]')).toBeNull();
|
||||
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
|
||||
|
||||
await render(panel({
|
||||
showSubtasksTab: true,
|
||||
tasksTab: { count: 1, content: <div>Cross-project follow-up</div> },
|
||||
}));
|
||||
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toContain("Plan");
|
||||
const tasks = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="subtasks"]');
|
||||
expect(tasks?.textContent?.trim()).toBe("Tasks");
|
||||
await act(async () => tasks?.click());
|
||||
expect(container.textContent).toContain("Cross-project follow-up");
|
||||
expect(container.textContent).not.toContain("Subtasks content");
|
||||
});
|
||||
|
||||
it("exposes failed task loading even when no task count is available", async () => {
|
||||
await render(panel({ showSubtasksTab: true, tasksTab: { count: 0, hasError: true, content: <div role="alert">Could not load all tasks.</div> } }));
|
||||
const tasks = container.querySelector<HTMLButtonElement>('[data-side-panel-tab-target="subtasks"]');
|
||||
expect(tasks?.textContent?.trim()).toBe("Tasks");
|
||||
await act(async () => tasks?.click());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not load all tasks");
|
||||
});
|
||||
|
||||
it("inserts Subtasks after Properties when child tasks load later", async () => {
|
||||
await render(panel({ childIssues: [], showSubtasksTab: true, streamlinedTabs: true }));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open a new tab"]')?.click());
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ export interface TaskSidePanelProps {
|
|||
onRequestClose?: () => void;
|
||||
streamlinedTabs?: boolean;
|
||||
showSubtasksTab?: boolean;
|
||||
/** Optional related-work projection; the host still owns tab layout and state. */
|
||||
tasksTab?: { count: number; content: ReactNode; hasError?: boolean };
|
||||
}
|
||||
|
||||
const EMPTY_ISSUE_DOCUMENTS: IssueDocument[] = [];
|
||||
|
|
@ -223,6 +225,7 @@ export function TaskSidePanel({
|
|||
onRequestClose,
|
||||
streamlinedTabs = false,
|
||||
showSubtasksTab = false,
|
||||
tasksTab,
|
||||
}: TaskSidePanelProps) {
|
||||
const handleScroll = useScrollbarWhileScrolling();
|
||||
const viewer = useTaskSidePanelFileRouting();
|
||||
|
|
@ -232,7 +235,9 @@ export function TaskSidePanel({
|
|||
const restoredRef = useRef(
|
||||
readTaskSidePanelState(accountScope, issue.companyId, issue.id, fileTabsEnabled),
|
||||
);
|
||||
const initialSubtasksAvailableRef = useRef(showSubtasksTab && childIssues.length > 0);
|
||||
const taskCount = tasksTab?.count ?? childIssues.length;
|
||||
const taskLabel = tasksTab ? "Tasks" : "Subtasks";
|
||||
const initialSubtasksAvailableRef = useRef(showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true));
|
||||
const subtasksDismissedRef = useRef(
|
||||
restoredRef.current?.userInteracted === true
|
||||
&& restoredRef.current.state.tabs.length === 0,
|
||||
|
|
@ -271,7 +276,7 @@ export function TaskSidePanel({
|
|||
}, [accountScope, issue.companyId, issue.id, launcherOpen]);
|
||||
const controller = useSidePanelTabs<TaskSidePanelTabPayload>({ initialState, onStateChange: persist });
|
||||
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
|
||||
const subtasksAvailable = showSubtasksTab && childIssues.length > 0;
|
||||
const subtasksAvailable = showSubtasksTab && (taskCount > 0 || tasksTab?.hasError === true);
|
||||
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -452,18 +457,18 @@ export function TaskSidePanel({
|
|||
return {
|
||||
id: tab.id,
|
||||
type: tab.type,
|
||||
label: document ? documentDisplayTitle(document) : tab.label,
|
||||
ariaLabel: tab.payload.kind === "subtasks" ? "Subtasks" : tab.ariaLabel,
|
||||
label: tab.payload.kind === "subtasks" && tasksTab ? "Tasks" : document ? documentDisplayTitle(document) : tab.label,
|
||||
ariaLabel: tab.payload.kind === "subtasks" ? taskLabel : tab.ariaLabel,
|
||||
closable: true,
|
||||
contentMode: tab.contentMode,
|
||||
icon: tabIcon(tab),
|
||||
};
|
||||
}), [controller.tabs, documentByKey]);
|
||||
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab]);
|
||||
|
||||
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
|
||||
const primary: SidePanelLauncherItem[] = [
|
||||
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
|
||||
...(subtasksAvailable ? [{ id: "subtasks", label: "Subtasks", description: `${childIssues.length} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
|
||||
...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
|
||||
{ id: "artifacts", label: "Artifacts", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
|
||||
];
|
||||
if (fileTabsEnabled) {
|
||||
|
|
@ -513,7 +518,7 @@ export function TaskSidePanel({
|
|||
});
|
||||
}
|
||||
return sections;
|
||||
}, [childIssues.length, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
|
||||
}, [taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
|
||||
|
||||
function selectLauncherItem(item: SidePanelLauncherItem) {
|
||||
markInteracted();
|
||||
|
|
@ -610,7 +615,7 @@ export function TaskSidePanel({
|
|||
/>
|
||||
);
|
||||
} else if (activeTab.payload.kind === "subtasks") {
|
||||
content = (
|
||||
content = tasksTab?.content ?? (
|
||||
<TaskDetailSubtasksPanel
|
||||
items={childIssues}
|
||||
onAddSubtask={onAddSubIssue}
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ export const queryKeys = {
|
|||
] as const,
|
||||
listByParent: (companyId: string, parentId: string) =>
|
||||
["issues", companyId, "parent", parentId] as const,
|
||||
listCreatedFromIssue: (companyId: string, issueId: string) =>
|
||||
["issues", companyId, "created-from", issueId] as const,
|
||||
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
|
||||
["issues", companyId, "descendants", rootIssueId] as const,
|
||||
listByExecutionWorkspace: (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
|
||||
import { RepositoryEditor } from "@/components/RepositoryEditor";
|
||||
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
|
||||
|
|
@ -2170,6 +2171,26 @@ export function DesignGuide() {
|
|||
<EnvironmentVariablesEditorShowcase />
|
||||
</Section>
|
||||
|
||||
<Section title="Tasks created from a task">
|
||||
<SubSection title="Subtasks and created work are independent">
|
||||
<div className="max-w-xl">
|
||||
<TaskDetailTasksPanel
|
||||
subtasks={[DESIGN_GUIDE_TASK]}
|
||||
createdTasks={[
|
||||
{ ...DESIGN_GUIDE_TASK, projectId: "design-board", project: { id: "design-board", name: "Board UI" } as Issue["project"] },
|
||||
{ ...DESIGN_GUIDE_TASK, id: "design-followup", identifier: "PAP-428", title: "Write release notes", status: "todo", projectId: null },
|
||||
]}
|
||||
projects={[]}
|
||||
/>
|
||||
</div>
|
||||
</SubSection>
|
||||
<SubSection title="Empty, loading and failed">
|
||||
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} />
|
||||
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} isLoading />
|
||||
<TaskDetailTasksPanel subtasks={[]} createdTasks={[]} projects={[]} hasError onRetry={() => {}} />
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
<Section title="Execution recovery">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recovery runs in the background. Task lists keep their ordinary status without
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { ApiError } from "../api/client";
|
|||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
listAll: vi.fn(),
|
||||
listAcceptedPlanDecompositions: vi.fn(),
|
||||
listComments: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
|
|
@ -1305,6 +1306,7 @@ describe("IssueDetail", () => {
|
|||
} as Response);
|
||||
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listAll.mockImplementation((...args) => mockIssuesApi.list(...args));
|
||||
mockIssuesApi.listComments.mockResolvedValue([]);
|
||||
mockIssuesApi.listAttachments.mockResolvedValue([]);
|
||||
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
|
||||
|
|
@ -2138,6 +2140,32 @@ describe("IssueDetail", () => {
|
|||
expect(panel?.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("loads subtask membership and created work independently and refreshes on issue activity", async () => {
|
||||
const source = createIssue();
|
||||
const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" });
|
||||
const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" });
|
||||
mockIssuesApi.get.mockResolvedValue(source);
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
|
||||
Promise.resolve(filters?.descendantOf === source.id ? [child] : filters?.createdFromIssueId === source.id ? [created] : []),
|
||||
);
|
||||
await act(async () => { root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>); });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
const taskProjection = () => mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.tasksTab;
|
||||
expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]);
|
||||
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]);
|
||||
expect(taskProjection()?.count).toBe(2);
|
||||
|
||||
const next = createIssue({ id: "new-created-task", parentId: source.id });
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
|
||||
Promise.resolve(filters?.descendantOf === source.id ? [child, next] : filters?.createdFromIssueId === source.id ? [created, next] : []),
|
||||
);
|
||||
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); });
|
||||
await flushReact();
|
||||
expect(taskProjection()?.count).toBe(3);
|
||||
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id);
|
||||
});
|
||||
|
||||
it("moves subtask data into the properties panel instead of the chat center pane", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
|
||||
import {
|
||||
memo,
|
||||
|
|
@ -205,7 +206,7 @@ import {
|
|||
IssueProperties,
|
||||
type IssuePropertiesDocumentDeepLink,
|
||||
} from "../components/IssueProperties";
|
||||
import { TaskSidePanel } from "../components/task-side-panel";
|
||||
import { TaskSidePanel, type TaskSidePanelProps } from "../components/task-side-panel";
|
||||
import { SidePanelToggleButton } from "../components/side-panel";
|
||||
import {
|
||||
TaskTreeControlDialog,
|
||||
|
|
@ -2819,7 +2820,7 @@ function IssueDetailActivityTab({
|
|||
);
|
||||
}
|
||||
|
||||
export function IssueDetail() {
|
||||
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) {
|
||||
const { issueId, companyPrefix } = useParams<{
|
||||
issueId: string;
|
||||
companyPrefix: string;
|
||||
|
|
@ -3176,13 +3177,18 @@ export function IssueDetail() {
|
|||
[issueId, location.state, location.search],
|
||||
);
|
||||
|
||||
const { data: rawChildIssuesData, isLoading: childIssuesLoading } = useQuery({
|
||||
const {
|
||||
data: rawChildIssuesData,
|
||||
isLoading: childIssuesLoading,
|
||||
isError: childIssuesError,
|
||||
refetch: refetchChildIssues,
|
||||
} = useQuery({
|
||||
queryKey:
|
||||
issue?.id && resolvedCompanyId
|
||||
? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id)
|
||||
: ["issues", "parent", "pending"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(resolvedCompanyId!, {
|
||||
issuesApi.listAll(resolvedCompanyId!, {
|
||||
descendantOf: issue!.id,
|
||||
includeBlockedBy: true,
|
||||
}),
|
||||
|
|
@ -3192,6 +3198,18 @@ export function IssueDetail() {
|
|||
),
|
||||
});
|
||||
const rawChildIssues: Issue[] = rawChildIssuesData ?? EMPTY_ISSUES;
|
||||
const createdTasksQuery = useQuery({
|
||||
queryKey: queryKeys.issues.listCreatedFromIssue(
|
||||
resolvedCompanyId ?? "pending",
|
||||
issue?.id ?? "pending",
|
||||
),
|
||||
queryFn: () => issuesApi.listAll(resolvedCompanyId!, {
|
||||
createdFromIssueId: issue!.id,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: streamlinedTaskDetailEnabled && !!resolvedCompanyId && !!issue?.id && !tasksTab,
|
||||
});
|
||||
|
||||
const {
|
||||
data: rawSiblingIssuesData,
|
||||
isLoading: siblingIssuesLoading,
|
||||
|
|
@ -3443,6 +3461,41 @@ export function IssueDetail() {
|
|||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
}, [issue?.id, rawChildIssues]);
|
||||
const resolvedTasksTab = useMemo(() => {
|
||||
if (tasksTab) return tasksTab;
|
||||
if (!streamlinedTaskDetailEnabled) return undefined;
|
||||
const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES;
|
||||
const hasError = createdTasksQuery.isError || childIssuesError;
|
||||
return {
|
||||
count: new Set([...childIssues, ...createdTasks].map((task) => task.id)).size,
|
||||
hasError,
|
||||
content: (
|
||||
<TaskDetailTasksPanel
|
||||
subtasks={childIssues}
|
||||
createdTasks={createdTasks}
|
||||
projects={projects ?? []}
|
||||
isLoading={createdTasksQuery.isLoading || childIssuesLoading}
|
||||
hasError={hasError}
|
||||
onRetry={() => {
|
||||
void createdTasksQuery.refetch();
|
||||
void refetchChildIssues();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}, [
|
||||
tasksTab,
|
||||
streamlinedTaskDetailEnabled,
|
||||
childIssues,
|
||||
childIssuesLoading,
|
||||
childIssuesError,
|
||||
refetchChildIssues,
|
||||
projects,
|
||||
createdTasksQuery.data,
|
||||
createdTasksQuery.isError,
|
||||
createdTasksQuery.isLoading,
|
||||
createdTasksQuery.refetch,
|
||||
]);
|
||||
const liveIssueIds = useMemo(
|
||||
() =>
|
||||
collectLiveIssueIds(
|
||||
|
|
@ -5571,6 +5624,7 @@ export function IssueDetail() {
|
|||
fileTabsEnabled={fileViewerEnabled}
|
||||
streamlinedTabs={streamlinedTaskDetailEnabled}
|
||||
showSubtasksTab={streamlinedTaskDetailEnabled}
|
||||
tasksTab={resolvedTasksTab}
|
||||
/>
|
||||
</IssueGalleryContext.Provider>,
|
||||
{ contentMode: "full-bleed" },
|
||||
|
|
@ -5607,6 +5661,7 @@ export function IssueDetail() {
|
|||
taskChatShellEnabled,
|
||||
currentUserId,
|
||||
fileViewerEnabled,
|
||||
resolvedTasksTab,
|
||||
]);
|
||||
|
||||
const goToInboxShortcutArmedRef = useRef(false);
|
||||
|
|
@ -8062,6 +8117,7 @@ export function IssueDetail() {
|
|||
fileTabsEnabled={fileViewerEnabled}
|
||||
streamlinedTabs={streamlinedTaskDetailEnabled}
|
||||
showSubtasksTab={streamlinedTaskDetailEnabled}
|
||||
tasksTab={resolvedTasksTab}
|
||||
documentDeepLink={
|
||||
documentDeepLink?.issueId === issue.id
|
||||
? documentDeepLink
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Issue, IssueComment } from "@paperclipai/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn, projectRouteRef } from "@/lib/utils";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { IssueDetail } from "@/pages/IssueDetail";
|
||||
import { ProjectDetail } from "@/pages/ProjectDetail";
|
||||
import { Navigate, Route, Routes, useParams } from "@/lib/router";
|
||||
import { seedIssueDetailCache } from "@/lib/issueDetailCache";
|
||||
import { taskPanelPropertiesTab, taskPanelSubtasksTab, taskPanelDocumentTab, taskPanelArtifactsTab, writeTaskSidePanelState } from "@/lib/task-side-panel-state";
|
||||
import { createIssue, storybookAgents, storybookCompanies, storybookAuthSession, storybookProjects, storybookIssueDocuments } from "../../fixtures/paperclipData";
|
||||
|
||||
// PAP-1953's phase topology is already documented in sub-issues-workflow.stories.
|
||||
// These are review fixtures, not a claim about the live task's current state.
|
||||
export const sourceTask = createIssue({
|
||||
id: "origin-story-source", identifier: "PAP-1953", issueNumber: 1953,
|
||||
title: "Ship the next phase of the board UI",
|
||||
status: "in_review",
|
||||
executionWorkspaceId: null, currentExecutionWorkspace: null, projectWorkspaceId: null,
|
||||
description: "Finish the board UI rollout. Verify each phase and capture any follow-up work in the project where it belongs.",
|
||||
checkoutRunId: null, executionRunId: null, executionLockedAt: null,
|
||||
labelIds: [], labels: [], workProducts: [],
|
||||
createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z"),
|
||||
});
|
||||
|
||||
const runSources = new Map([
|
||||
["origin-run-legacy", sourceTask.id],
|
||||
["origin-run-native", sourceTask.id],
|
||||
["origin-run-unrelated", "another-source"],
|
||||
]);
|
||||
|
||||
function task(number: number, title: string, status: Issue["status"], parentId: string | null, originRunId = "origin-run-native", projectIndex = 0): Issue {
|
||||
const project = storybookProjects[projectIndex]!;
|
||||
return createIssue({
|
||||
id: `origin-story-${number}`, identifier: `PAP-${number}`, issueNumber: number,
|
||||
title, status, parentId, originRunId,
|
||||
projectId: project.id, project, projectWorkspaceId: null,
|
||||
executionWorkspaceId: null, currentExecutionWorkspace: null,
|
||||
createdByAgentId: "agent-codex", createdByUserId: null,
|
||||
assigneeAgentId: number % 2 === 0 ? "agent-codex" : "agent-qa",
|
||||
checkoutRunId: null, executionRunId: null, executionLockedAt: null,
|
||||
labelIds: [], labels: [], blockedBy: [], blocks: [],
|
||||
createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z"),
|
||||
lastActivityAt: new Date("2026-09-11T14:00:00Z"), lastExternalCommentAt: null, myLastTouchAt: null, isUnreadForMe: false,
|
||||
completedAt: status === "done" ? new Date("2026-09-11T14:00:00Z") : null,
|
||||
cancelledAt: status === "cancelled" ? new Date("2026-09-11T14:00:00Z") : null,
|
||||
});
|
||||
}
|
||||
|
||||
export const taskCandidates = [
|
||||
task(1954, "Scoping review", "done", sourceTask.id, "origin-run-legacy"),
|
||||
task(1964, "Phase 5 — UI polish", "in_progress", sourceTask.id),
|
||||
task(2189, "Keep task list filters when switching projects", "todo", null, "origin-run-legacy", 1),
|
||||
task(1965, "Phase 6 — release verification", "blocked", sourceTask.id),
|
||||
task(2190, "Document the new task navigation", "in_review", "docs-parent", "origin-run-native", 1),
|
||||
task(1963, "Phase 4 — API surface", "done", sourceTask.id, "origin-run-legacy"),
|
||||
task(2191, "Replace the legacy filter popover", "cancelled", null),
|
||||
task(2192, "Investigate an unrelated runner timeout", "todo", null, "origin-run-unrelated"),
|
||||
{ ...task(2193, "Follow up on release notes", "todo", null), projectId: null, project: null },
|
||||
task(2194, "Review accessibility", "todo", sourceTask.id, "origin-run-unrelated"),
|
||||
];
|
||||
|
||||
// Model the proposed API projection explicitly. Parentage and creation are
|
||||
// independent. Deduplicate children created by the source run; never include
|
||||
// unrelated tasks merely because the same agent created them.
|
||||
export function tasksForSource(candidates: Issue[]): Issue[] {
|
||||
return [...new Map(candidates.filter((item) =>
|
||||
item.companyId === sourceTask.companyId && !item.hiddenAt && item.id !== sourceTask.id &&
|
||||
(item.parentId === sourceTask.id || (item.originRunId && runSources.get(item.originRunId) === sourceTask.id)),
|
||||
).map((item) => [item.id, item])).values()];
|
||||
}
|
||||
|
||||
export type Scenario = "mixed" | "subtasks" | "other" | "completed" | "empty" | "arrival";
|
||||
export function scenarioTasks(scenario: Scenario) {
|
||||
const items = tasksForSource(taskCandidates);
|
||||
if (scenario === "empty" || scenario === "arrival") return [];
|
||||
if (scenario === "subtasks") return items.filter((item) => item.parentId === sourceTask.id);
|
||||
if (scenario === "other") return items.filter((item) => item.parentId !== sourceTask.id);
|
||||
if (scenario === "completed") return items.map((item) => ({ ...item, status: item.status === "cancelled" ? "cancelled" as const : "done" as const }));
|
||||
return items;
|
||||
}
|
||||
|
||||
export function SeedData({ children }: { children: React.ReactNode }) {
|
||||
const client = useQueryClient();
|
||||
const [ready] = useState(() => {
|
||||
client.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false });
|
||||
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
|
||||
client.setQueryData(queryKeys.agents.list(sourceTask.companyId), storybookAgents);
|
||||
client.setQueryData(queryKeys.projects.list(sourceTask.companyId), storybookProjects);
|
||||
client.setQueryData(queryKeys.issues.list(sourceTask.companyId), [sourceTask, ...taskCandidates]);
|
||||
client.setQueryData(queryKeys.issues.labels(sourceTask.companyId), []);
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, { enableStreamlinedUi: true, enableIsolatedWorkspaces: false });
|
||||
client.setQueryData(queryKeys.issues.documents(sourceTask.id), []);
|
||||
client.setQueryData(queryKeys.issues.runs(sourceTask.id), []);
|
||||
client.setQueryData(queryKeys.issues.liveRuns(sourceTask.id), []);
|
||||
client.setQueryData(queryKeys.issues.activeRun(sourceTask.id), null);
|
||||
return true;
|
||||
});
|
||||
return ready ? children : null;
|
||||
}
|
||||
|
||||
export function TasksPanel({ items }: { items: Issue[] }) {
|
||||
return <TaskDetailTasksPanel
|
||||
subtasks={items.filter((item) => item.parentId === sourceTask.id)}
|
||||
createdTasks={items.filter((item) => item.originRunId && runSources.get(item.originRunId) === sourceTask.id)}
|
||||
projects={storybookProjects}
|
||||
/>;
|
||||
}
|
||||
|
||||
const plan = "## Rollout plan\n\n1. Complete scoping and the API surface.\n2. Finish UI polish and verify the release.\n3. Track filter persistence and navigation documentation in their owning projects.\n\n### Acceptance\n\nThe board can inspect every task created during this work, including tasks outside this hierarchy.";
|
||||
|
||||
const baseComments: IssueComment[] = [
|
||||
{ id: "origin-comment-1", companyId: sourceTask.companyId, issueId: sourceTask.id, authorAgentId: null, authorUserId: "user-board", authorType: "user", body: sourceTask.description!, presentation: null, metadata: null, createdAt: new Date("2026-09-11T13:00:00Z"), updatedAt: new Date("2026-09-11T13:00:00Z") },
|
||||
{ id: "origin-comment-2", companyId: sourceTask.companyId, issueId: sourceTask.id, authorAgentId: "agent-codex", authorUserId: null, authorType: "agent", body: "Scoping and the API surface are complete. UI polish is in progress; release verification is waiting on it.\n\nI also found two follow-ups: filter persistence and navigation docs. I created those in their owning projects so we can track them without changing the rollout hierarchy.", presentation: null, metadata: null, createdAt: new Date("2026-09-11T14:00:00Z"), updatedAt: new Date("2026-09-11T14:00:00Z") },
|
||||
];
|
||||
|
||||
|
||||
const planDocument = { ...storybookIssueDocuments[0]!, issueId: sourceTask.id, body: plan };
|
||||
sourceTask.planDocument = planDocument;
|
||||
sourceTask.documentSummaries = [planDocument];
|
||||
|
||||
/** Only replaces data. Every full-page pixel is rendered by the production route. */
|
||||
function TaskPageData({ children, scenario }: { children: React.ReactNode; scenario: Scenario }) {
|
||||
const client = useQueryClient();
|
||||
const [fixture] = useState(() => {
|
||||
const rows = [sourceTask, ...taskCandidates];
|
||||
const comments = scenario === "arrival" ? [] : [baseComments[1]!];
|
||||
for (const row of rows) {
|
||||
seedIssueDetailCache(client, row);
|
||||
for (const ref of [row.id, row.identifier!]) {
|
||||
client.setQueryData(queryKeys.issues.comments(ref), { pages: [row.id === sourceTask.id ? [...comments].reverse() : []], pageParams: [null] });
|
||||
client.setQueryData(queryKeys.issues.documents(ref), row.id === sourceTask.id ? [planDocument] : []);
|
||||
client.setQueryData([...queryKeys.issues.documents(ref), "plan"], row.id === sourceTask.id ? planDocument : null);
|
||||
client.setQueryData(queryKeys.issues.liveRuns(ref), []);
|
||||
client.setQueryData(queryKeys.issues.activeRun(ref), null);
|
||||
}
|
||||
}
|
||||
client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" });
|
||||
client.setQueryData(queryKeys.instance.generalSettings, { keyboardShortcuts: true });
|
||||
client.setQueryData(queryKeys.access.currentBoardAccess, { companyIds: [] });
|
||||
client.setQueryData(queryKeys.issues.listCreatedFromIssue(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.originRunId && runSources.get(row.originRunId) === sourceTask.id));
|
||||
client.setQueryData(queryKeys.issues.listByDescendantRoot(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.parentId === sourceTask.id));
|
||||
const userId = storybookAuthSession.user.id;
|
||||
writeTaskSidePanelState(userId, sourceTask.companyId, sourceTask.id, {
|
||||
state: {
|
||||
tabs: [taskPanelPropertiesTab(), ...(scenario !== "arrival" ? [taskPanelSubtasksTab()] : []), taskPanelDocumentTab("plan", "Plan"), taskPanelArtifactsTab()],
|
||||
activeTabId: scenario === "arrival" ? "document:plan" : "subtasks",
|
||||
},
|
||||
userInteracted: true, autoPlanHandled: true, launcherOpen: false, updatedAt: Date.now(),
|
||||
});
|
||||
const originalFetch = window.fetch;
|
||||
const fetchFixture: typeof fetch = async (input, init) => {
|
||||
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const url = new URL(raw, window.location.origin);
|
||||
const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
||||
if (url.pathname === "/api/health") return Response.json({ status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" });
|
||||
if (url.pathname === "/api/instance/settings/general") return Response.json({ keyboardShortcuts: true });
|
||||
const projectMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/);
|
||||
if (projectMatch && method === "GET") {
|
||||
const project = storybookProjects.find((item) => item.id === projectMatch[1] || projectRouteRef(item) === projectMatch[1]);
|
||||
return project ? Response.json(project) : Response.json({ error: "Project is outside this story" }, { status: 404 });
|
||||
}
|
||||
const match = url.pathname.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/);
|
||||
if (match) {
|
||||
const row = rows.find((item) => item.id === match[1] || item.identifier === match[1]);
|
||||
if (!row) return Response.json({ error: "Task is outside this story" }, { status: 404 });
|
||||
const resource = match[2] ?? "";
|
||||
if (!resource) {
|
||||
if (method === "PATCH") Object.assign(row, JSON.parse(String(init?.body ?? "{}")));
|
||||
return Response.json(row);
|
||||
}
|
||||
if (resource === "comments") {
|
||||
if (method === "POST") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
const comment = { ...baseComments[0]!, ...body, id: `story-comment-${comments.length}`, issueId: row.id, createdAt: new Date(), updatedAt: new Date() };
|
||||
comments.push(comment);
|
||||
return Response.json(comment);
|
||||
}
|
||||
return Response.json(row.id === sourceTask.id ? [...comments].reverse() : []);
|
||||
}
|
||||
if (resource === "documents/plan") return row.id === sourceTask.id ? Response.json(planDocument) : Response.json({ error: "No plan" }, { status: 404 });
|
||||
if (resource === "documents") return Response.json(row.id === sourceTask.id ? [planDocument] : []);
|
||||
if (resource === "active-run") return Response.json(null);
|
||||
if (resource === "read") return Response.json({ ok: true });
|
||||
if (["interactions", "attachments", "work-products", "live-runs", "runs", "feedback-votes", "activity", "approvals", "references"].includes(resource)) return Response.json([]);
|
||||
return Response.json({ error: `Not simulated: ${resource}` }, { status: 404 });
|
||||
}
|
||||
if (url.pathname === `/api/companies/${sourceTask.companyId}/issues`) {
|
||||
const parent = url.searchParams.get("descendantOf") ?? url.searchParams.get("parentId");
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
const createdFrom = url.searchParams.get("createdFromIssueId");
|
||||
const filtered = rows.filter((row) => (!parent || row.parentId === parent) && (!projectId || row.projectId === projectId) && (!createdFrom || (row.originRunId && runSources.get(row.originRunId) === createdFrom)));
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
const limit = Number(url.searchParams.get("limit") ?? filtered.length);
|
||||
return Response.json(filtered.slice(offset, offset + limit));
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
window.fetch = fetchFixture;
|
||||
return { restore: () => { if (window.fetch === fetchFixture) window.fetch = originalFetch; } };
|
||||
});
|
||||
useEffect(() => fixture.restore, [fixture]);
|
||||
return children;
|
||||
}
|
||||
|
||||
function TaskRoute({ tasksTab }: { tasksTab?: React.ComponentProps<typeof IssueDetail>["tasksTab"] }) {
|
||||
const { issueId } = useParams();
|
||||
return <IssueDetail tasksTab={issueId === sourceTask.identifier ? tasksTab : undefined} />;
|
||||
}
|
||||
|
||||
export function OriginatingTasksReview({ scenario = "mixed", fullPage = true, narrow = false, baseline = false }: { scenario?: Scenario; fullPage?: boolean; narrow?: boolean; baseline?: boolean }) {
|
||||
const [items, setItems] = useState(() => scenarioTasks(scenario));
|
||||
const tasksTab = useMemo(() => ({
|
||||
count: items.length,
|
||||
content: <TasksPanel items={items} />,
|
||||
}), [items]);
|
||||
if (!fullPage) return <div className={cn("min-h-screen bg-background p-4 text-foreground", narrow ? "max-w-md" : "max-w-3xl")}>{tasksTab.content}</div>;
|
||||
return (
|
||||
<TaskPageData scenario={scenario}>
|
||||
{scenario === "arrival" && <div className="flex items-center gap-3 border-b border-border p-2 text-xs"><span>Story control</span><Button size="sm" variant="outline" disabled={items.length > 0} onClick={() => setItems(scenarioTasks("other").slice(0, 1))}>Simulate task creation</Button></div>}
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="issues/:issueId" element={<TaskRoute tasksTab={baseline || scenario === "mixed" ? undefined : tasksTab} />} />
|
||||
<Route path="projects/:projectId/*" element={<ProjectDetail />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to={`/PAP/issues/${sourceTask.identifier}`} replace />} />
|
||||
</Routes>
|
||||
</TaskPageData>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Tasks created from a task
|
||||
|
||||
Run `pnpm --filter @paperclipai/ui exec storybook dev -p 6017 -c storybook/.storybook --no-open`.
|
||||
Open **UX Labs → Tasks Created From a Task → Full Task Page**.
|
||||
|
||||
The stories render production `Layout`, `IssueDetail`, `TaskSidePanel` and
|
||||
`TaskDetailTasksPanel` with illustrative fixtures based on PAP-1953. They use no
|
||||
replica shell or custom CSS. Task and project links navigate to real page
|
||||
components backed by local fixtures.
|
||||
|
||||
## Membership
|
||||
|
||||
- Subtasks includes the existing subtask tree, regardless of creator or run.
|
||||
- Project and No project groups contain tasks created by runs originating from
|
||||
the current task, regardless of current parentage. Created subtasks appear in
|
||||
both sections. Deduplication applies within each section, not between them.
|
||||
- Fixtures cover both legacy and native runs, a subtask created elsewhere, an
|
||||
unrelated task by the same agent, and a created task without a project.
|
||||
- Unfinished tasks sort before done and cancelled tasks. Only Subtasks shows a
|
||||
progress bar. Empty Subtasks sections are omitted.
|
||||
- Sections fold independently. Carets follow the heading text; project links sit
|
||||
at the far right. Controls fade on hover or focus using reduced-motion-aware
|
||||
tokens. There are no project icons, tab counts, search or collection controls.
|
||||
- First Task Appears demonstrates the tab arriving without replacing the Plan tab.
|
||||
|
||||
## Production integration
|
||||
|
||||
`GET /api/companies/:companyId/issues?createdFromIssueId=<uuid>` projects creation
|
||||
provenance through `issues.originRunId` and the originating heartbeat run. Native
|
||||
runs use `nativeIssueId`; legacy/historical runs fall back to the persisted
|
||||
`issueId`, `taskId`, or `taskKey` context. The source, run and results must belong
|
||||
to the requested company. When the origin run is absent, recorded issue creation activity can recover its
|
||||
run. Comments and shared creators are never used to infer attribution.
|
||||
The normal issue creation service persists the actor run when no explicit origin
|
||||
run was provided, including the legacy child helper. Native creation already
|
||||
provides its run. No schema migration or runner-mode distinction is needed.
|
||||
|
||||
The real issue page queries this projection alongside its existing subtask query.
|
||||
Existing company issue-list invalidation refreshes both on task activity. Errors
|
||||
remain visible with a Retry action. Historical rows without either an origin run or attributed creation activity
|
||||
cannot be attributed and are not included in project groups.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { OriginatingTasksReview, SeedData } from "../prototypes/originating-tasks/OriginatingTasks";
|
||||
|
||||
const meta = {
|
||||
title: "UX Labs/Tasks Created From a Task",
|
||||
component: OriginatingTasksReview,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: { description: { component: "UX prototype based on the existing PAP-1953 phase-topology fixture, with illustrative cross-project follow-ups. Renders the actual Layout and IssueDetail route, with the production TaskSidePanel. No replica page shell or CSS overrides. Full Task Page uses the production query wiring with fixture API responses. Both legacy and native run origins are included. Subtasks includes all subtasks; created work is grouped independently by project, so a created subtask appears in both sections. An unrelated task by the same agent is deliberately excluded. Subtasks reuse TaskDetailSubtasksPanel and its progress bar. All created work uses the same task rows, grouped by project or No project, without progress bars. Unfinished work precedes finished work. No counts on the Tasks tab, extra header, search, controls, or relationship tabs. Task navigation, local chat and side-panel tabs remain interactive. The story uses the production Tasks panel with fixture data." } },
|
||||
},
|
||||
args: { scenario: "mixed", fullPage: true, narrow: false },
|
||||
decorators: [(Story, context) => <SeedData key={context.id}><PluginLauncherProvider><Story /></PluginLauncherProvider></SeedData>],
|
||||
} satisfies Meta<typeof OriginatingTasksReview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullTaskPage: Story = {};
|
||||
export const ImplementedTaskPage: Story = { args: { baseline: true } };
|
||||
export const FirstTaskAppears: Story = { args: { scenario: "arrival" } };
|
||||
export const MixedTasksPanel: Story = { args: { fullPage: false } };
|
||||
export const SubtasksOnly: Story = { args: { fullPage: false, scenario: "subtasks" } };
|
||||
export const OtherProjectsOnly: Story = { args: { fullPage: false, scenario: "other" } };
|
||||
export const AllFinished: Story = { args: { fullPage: false, scenario: "completed" } };
|
||||
export const Empty: Story = { args: { fullPage: false, scenario: "empty" } };
|
||||
export const NarrowPanel: Story = { args: { fullPage: false, narrow: true }, globals: { viewport: { value: "mobile" } } };
|
||||
export const FullTaskPageLight: Story = { globals: { theme: "light" } };
|
||||
Loading…
Reference in New Issue