feat(ui): link routines to a filtered task list (#18317)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Routines create tasks that people need to inspect and manage.
> - The routines list sends its View all runs link to Audit.
> - Tasks already supports routine origin filters in its API, but the
page has no control for them.
> - This pull request adds a Routine tasks checkbox and links to that
selection.
> - People can inspect routine work in the normal task list, search, and
board views.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The routines list link and the Tasks filters.

**Current behavior**

View all runs opens Audit. People cannot select routine-created tasks in
the Tasks filter menu.

**Proposed behavior**

View all tasks opens `/issues?view=all&originKind=routine_execution`.
Routine tasks is selected. People can toggle or clear it from Tasks.

**Reason and benefit**

People can use normal task controls to inspect work created by routines.
The filter includes manually started routine runs. It excludes manual
tasks and other automatic work.

**Breaking changes**

The routines list link changes its label and destination. No API,
schema, authorization, or routine execution contract changes.

## What Changed

- Add Routine tasks under Visibility in the current, streamlined, and
legacy Tasks filters. Keep Live runs only in the same group and use Show
agent-created tasks with proper capitalization where that control is
available.
- Keep the selection in the URL through reload, search, task navigation,
and checkbox changes.
- Apply the existing API origin filter before pagination. Include
routine tasks even when other automatic tasks are hidden.
- Keep source filtering in the shared task filter module and collection
owners. Reuse the existing company-scoped issue-list contract. Hide the
live-run control in the design sandbox, which does not load live-run
IDs.
- Update regression coverage, product docs, feature/access maps, and the
toolbar preview.

## Verification

- All 58 final-head checks are complete with no failures for
`f46c2e409b20fa372d166f4721601a0471894fb0`, including successful `ci /
verify` and `ci / e2e` gates. Greptile and Paperclip Review rate this
commit 5/5 with no actionable findings or unresolved review threads. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/41078093110).
- Local checks pass: full repo typecheck/build for the feature, UI
typecheck/build after the final refinements, token gates, feature-map
checks, and Storybook catalog generation.
- Regression coverage exercises both real list implementations, filter
grouping and selection, search and all board columns, URL/pagination
state, and mixed pending-decision batches. The Visibility update passed
all 89 relevant tests across separate runs. The final sandbox correction
passes all 10 filter-menu tests, including three cases that reproduced
the bug before the fix. The earlier broad UI run passed 10,043 tests
across 845 files.
- Browser proof used the actual local app with disposable PostgreSQL
data: three tasks created through the routine-run API and one manual
task. Followed View all tasks; verified the selected Routine tasks
checkbox under Visibility, toggling, reload, search, and board view.
Checked current, streamlined, and legacy filter presentations.
Screenshots show the link, routine-only results, and requested
capitalization/grouping.
- Failure accounting: the initial broad UI run found two preference
expectations that needed the new false default; those were corrected
before the full UI pass. Scrolling tests hit five-second timeouts in
combined runs and passed in isolated reruns without timeout changes. A
duplicate full local UI run was stopped after the earlier final-commit
CI UI lanes passed, with no failures reported in that stopped run.
- Full local `pnpm test:run` could not complete because native
PostgreSQL initialization exhausted the host semaphore pool. A direct
initdb probe confirmed `could not create semaphores: No space left on
device`. Browser verification used a disposable container; no host
settings or other workspace processes were changed.
- Review found that the newly exposed live-run control could empty the
design sandbox, which has no live-run IDs. The sandbox now disables that
unsupported control; its regression test failed before the fix and
passes after it.

## Risks

- Low risk. The change uses the existing routine origin and read
permissions. A routine task remains automatic work, including when a
person starts it manually.
- The routine checkbox is exposed only on the Tasks collection. Other
filter consumers retain their controls and normalize the new field to
false.
- Routine histories and Audit run links inside routine details keep
their current behavior.

## Model Used

- OpenAI GPT-6, through Codex, assisted with implementation and
verification. The exact runtime model ID and context window are not
exposed in this session. Capabilities used: repository editing, command
execution, tests, and browser interaction.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-30 10:28:58 -05:00
1 parent 5c807d11fa
commit b31ec0a2dc
33 files changed
+370 -78

No files matched your search

+8 -1
View File
@@ -134,8 +134,15 @@ choice stays selected while task data refreshes.
### Task collection creation filters
The task Filters menu also offers **Routine tasks**. It selects tasks with
`originKind=routine_execution`, including manually triggered routine runs, even
when other automatic tasks are hidden. The routines list's **View all tasks**
link opens `/issues?view=all&originKind=routine_execution` with that checkbox
selected. The URL retains the selection across reloads and task navigation.
Search and pagination apply the routine origin filter on the server.
Tasks and project lists hide agent-created and system-created tasks by default.
The Filters menu's **show agent-created tasks** checkbox includes routine runs,
The Filters menu's **Show agent-created tasks** checkbox includes routine runs,
recovery/watchdog tasks, chat-channel tasks, and other automation. Channel tasks
remain bot-created for this filter even when a human starts the conversation. The choice is saved per company
and collection; Clear filters restores the hidden default. Nested lists preserve
+8 -1
View File
@@ -2573,8 +2573,15 @@ its upstream authors; operators can pause updates to review changes manually.
### Task collection creation visibility (2026-10-13)
The task Filters menu also offers **Routine tasks**. It selects tasks with
`originKind=routine_execution`, including manually triggered routine runs, even
when other automatic tasks are hidden. The routines list's **View all tasks**
link opens `/issues?view=all&originKind=routine_execution` with that checkbox
selected. The URL retains the selection across reloads and task navigation.
Search and pagination apply the routine origin filter on the server.
Tasks and project collections default to hiding agent/system-created tasks and
provide a saved **show agent-created tasks** filter. Classify creation using
provide a saved **Show agent-created tasks** filter. Classify creation using
creator identity and origin; assignee/responsible users do not imply human
authorship. Routine executions remain automated even when manually triggered.
Creator-less ordinary tasks are system-created. All chat-channel tasks count as
+3 -1
View File
@@ -118,7 +118,9 @@ API: `not-applicable` — list/board, grouping/sort/columns/hierarchy/expanded r
Agent access: `not-applicable` — no agent layout API; permitted underlying task data is separate.
Policy: IssuesList loads/saves browser preferences by company/collection, with separate agent-created visibility. Contextual ancestors/descendants do not become unrelated independent results. No shared server view preference inferred.
Policy: IssuesList loads/saves browser preferences by company/collection, with separate agent-created visibility. The Tasks page also owns a routine-only
checkbox and `originKind=routine_execution` URL state; it uses the existing
company-scoped task-list origin filter before pagination. Contextual ancestors/descendants do not become unrelated independent results. No shared server view preference inferred.
Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [IssuesList.tsx](../../ui/src/components/IssuesList.tsx), [Issues.tsx](../../ui/src/pages/Issues.tsx), [Issues.test.tsx](../../ui/src/pages/Issues.test.tsx). No live execution claimed.
+1 -1
View File
@@ -77,7 +77,7 @@ The source regressions also create an addressed request through the service for
human assignee. They check unread lists, flags, and counts, plus exclusion of
historical questions and closed requests while preserving authorization requests.
Manual: Seed GitHub, Slack, scheduled routine, and manual routine tasks. Attribution, imported comments, and reading alone keep them outside Mine. Direct board/API comments, human assignment, and open addressed requests admit them. Resolve the request and check that another valid reason is needed. Inbox discovery and Blocked hide unrelated automation by default; the **show agent-created tasks** checkbox reveals it without subscribing the user. Reload and verify membership, unread badges, archive/resurfacing, and preference persistence.
Manual: Seed GitHub, Slack, scheduled routine, and manual routine tasks. Attribution, imported comments, and reading alone keep them outside Mine. Direct board/API comments, human assignment, and open addressed requests admit them. Resolve the request and check that another valid reason is needed. Inbox discovery and Blocked hide unrelated automation by default; the **Show agent-created tasks** checkbox reveals it without subscribing the user. Reload and verify membership, unread badges, archive/resurfacing, and preference persistence.
Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation.
+3 -1
View File
@@ -29,7 +29,9 @@ Open `/routines`, create one, and inspect `/routines/:routineId` and its edit se
### `trigger-history`
Use the routine's trigger controls and Runs/Recent Runs/Audit links; lists also
offer Run now. **History** records configuration versions, not executions.
offer Run now. **View all tasks** opens the Tasks list with **Routine tasks**
selected, using `/issues?view=all&originKind=routine_execution`. Uncheck the
filter to return to other tasks. **History** records configuration versions, not executions.
## Driving it with Playwright and the Paperclip CLI
+3 -1
View File
@@ -48,6 +48,8 @@ Use New task from the global launcher, task list, or project task view.
### `task-list`
Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect.
Filters → **Routine tasks** selects tasks created by routine runs. The
`/issues?view=all&originKind=routine_execution` link opens that selection.
### `task-detail`
@@ -95,7 +97,7 @@ Automated: [task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search
Task and inbox rows omit the system interruption labels **Thinking**, **Trying again soon**, and **Retry available**. [Shared row tests](../../ui/src/components/IssueRow.test.tsx) cover working, scheduled-retry, and failed interruptions in both row presentations, including suppression of replacement recovery chips. Task status and other relevant badges remain visible; retry controls remain inside the task conversation.
Manual: Seed human-created, agent-created, scheduled routine, human-initiated chat-channel, and creator-less tasks. Confirm automation is hidden by default, **show agent-created tasks** reveals it, reload preserves the choice, and Clear filters hides it again. Verify list and board search with automation newer than the human results, and expand contextual ancestors/subtasks without promoting unrelated automated roots. Repeat in project task lists. Agent profiles and task-detail subtasks retain complete history.
Manual: Seed human-created, agent-created, scheduled routine, human-initiated chat-channel, and creator-less tasks. Confirm automation is hidden by default, **Show agent-created tasks** reveals it, reload preserves the choice, and Clear filters hides it again. Verify list and board search with automation newer than the human results, and expand contextual ancestors/subtasks without promoting unrelated automated roots. Repeat in project task lists. Agent profiles and task-detail subtasks retain complete history.
Manual: Filter by status and assignee, change grouping/sort, open a task, and return. Verify the correct records and selected view survive navigation and reload. Include an empty result and a failed request.
+13 -6
View File
@@ -9,7 +9,7 @@ import { StatusIcon } from "./StatusIcon";
import { TruncatedText } from "./TruncatedText";
import { issueFilterLabel, issueStatusOrder, searchIssueFilterOptions, toggleIssueFilterValue } from "@/lib/issue-filters";
type PanelProps = Pick<ComponentProps<typeof IssueFiltersPopover>, "state" | "onChange" | "activeFilterCount" | "agents" | "projects" | "labels" | "creators" | "currentUserId"> & { onClear: () => void };
type PanelProps = Pick<ComponentProps<typeof IssueFiltersPopover>, "state" | "onChange" | "activeFilterCount" | "agents" | "projects" | "labels" | "creators" | "currentUserId" | "enableRoutineTasksFilter" | "enableAgentCreatedVisibilityFilter" | "enableLiveRunsFilter"> & { onClear: () => void };
function FilterGroup({ title, searchLabel, query, onQueryChange, children, optionsKey }: { title: string; searchLabel?: string; query?: string; onQueryChange?: (value: string) => void; children: ReactNode; optionsKey: string }) {
const headingId = useId();
@@ -28,8 +28,8 @@ function FilterRow({ label, checked, onToggle, icon }: { label: string; checked:
return <label className="task-filter-option" data-marquee-surface><Checkbox aria-label={label} checked={checked} onCheckedChange={onToggle} />{icon ? <span className="task-filter-icon" aria-hidden>{icon}</span> : null}<TruncatedText className="task-filter-option-label">{label}</TruncatedText></label>;
}
/** Proposed five-group IA; the collection retains ownership of filter state. */
export function IssueFilterPanel({ state, onChange, activeFilterCount, agents = [], projects = [], labels = [], creators = [], currentUserId, onClear }: PanelProps) {
/** The collection retains ownership of filter state. */
export function IssueFilterPanel({ state, onChange, activeFilterCount, agents = [], projects = [], labels = [], creators = [], currentUserId, onClear, enableRoutineTasksFilter, enableAgentCreatedVisibilityFilter, enableLiveRunsFilter = true }: PanelProps) {
const [queries, setQueries] = useState({ projects: "", creators: "", assignees: "", labels: "" });
const search = (key: keyof typeof queries) => (value: string) => setQueries(current => ({ ...current, [key]: value }));
const visibleProjects = searchIssueFilterOptions(projects, queries.projects, option => option.name);
@@ -40,9 +40,16 @@ export function IssueFilterPanel({ state, onChange, activeFilterCount, agents =
return <>
<FlyoutHeader className="task-filters-header"><span>Filters</span>{activeFilterCount ? <Button variant="ghost" onClick={() => { onClear(); setQueries({ projects: "", creators: "", assignees: "", labels: "" }); }}>Clear</Button> : null}</FlyoutHeader>
<div className="task-filters-grid">
<FilterGroup title="Status" optionsKey="statuses">
{issueStatusOrder.map(status => <FilterRow key={status} label={issueFilterLabel(status)} checked={state.statuses.includes(status)} onToggle={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={<StatusIcon status={status} />} />)}
</FilterGroup>
<div className="task-filter-group">
<FilterGroup title="Status" optionsKey="statuses">
{issueStatusOrder.map(status => <FilterRow key={status} label={issueFilterLabel(status)} checked={state.statuses.includes(status)} onToggle={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={<StatusIcon status={status} />} />)}
</FilterGroup>
{enableLiveRunsFilter || enableAgentCreatedVisibilityFilter || enableRoutineTasksFilter ? <FilterGroup title="Visibility" optionsKey="visibility">
{enableLiveRunsFilter ? <FilterRow label="Live runs only" checked={state.liveOnly === true} onToggle={() => onChange({ liveOnly: !state.liveOnly })} /> : null}
{enableAgentCreatedVisibilityFilter ? <FilterRow label="Show agent-created tasks" checked={state.showAgentCreatedTasks} onToggle={() => onChange({ showAgentCreatedTasks: !state.showAgentCreatedTasks })} /> : null}
{enableRoutineTasksFilter ? <FilterRow label="Routine tasks" checked={state.routineTasksOnly === true} onToggle={() => onChange({ routineTasksOnly: !state.routineTasksOnly })} /> : null}
</FilterGroup> : null}
</div>
<FilterGroup title="Project" optionsKey="projects" searchLabel="projects" query={queries.projects} onQueryChange={search("projects")}>
{visibleProjects.map(project => <FilterRow key={project.id} label={project.name} checked={state.projects.includes(project.id)} onToggle={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })} />)}
{!visibleProjects.length ? <p className="task-filter-empty">{queries.projects ? "No projects match." : "No projects available."}</p> : null}
+34 -3
View File
@@ -29,7 +29,7 @@ vi.mock("@/components/ui/button", () => ({
}));
vi.mock("@/components/ui/checkbox", () => ({
Checkbox: ({ checked }: { checked?: boolean }) => <input type="checkbox" checked={checked} readOnly />,
Checkbox: ({ checked, onCheckedChange, ...props }: { checked?: boolean; onCheckedChange?: (checked: boolean) => void }) => <input {...props} type="checkbox" checked={checked} onChange={event => onCheckedChange?.(event.target.checked)} />,
}));
vi.mock("./StatusIcon", () => ({
@@ -52,6 +52,37 @@ describe("IssueFiltersPopover", () => {
document.body.innerHTML = "";
});
it.each(["legacy", "streamlined", "refreshed"] as const)("groups the routine filter under Visibility in %s", presentation => {
const root = createRoot(container);
const onChange = vi.fn();
act(() => root.render(<IssueFiltersPopover enableAgentCreatedVisibilityFilter enableRoutineTasksFilter presentation={presentation} state={{ ...defaultIssueFilterState, routineTasksOnly: true }} onChange={onChange} activeFilterCount={1} />));
const visibility = container.querySelector('[data-filter-options="visibility"]');
expect(Array.from(visibility?.querySelectorAll("label") ?? []).map(label => label.textContent)).toEqual([
"Live runs only", "Show agent-created tasks", "Routine tasks",
]);
expect(container.textContent).toContain("Visibility");
expect(container.textContent).not.toContain("Source");
const row = Array.from(visibility!.querySelectorAll("label")).find(label => label.textContent === "Routine tasks");
expect(row?.querySelector("input")?.checked).toBe(true);
act(() => row?.querySelector("input")?.click());
expect(onChange).toHaveBeenCalledWith({ routineTasksOnly: false });
act(() => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Clear")?.click());
expect(onChange).toHaveBeenCalledWith(defaultIssueFilterState);
act(() => root.unmount());
});
it.each(["legacy", "streamlined", "refreshed"] as const)("hides unsupported live filters in %s", presentation => {
const root = createRoot(container);
const props = { presentation, enableLiveRunsFilter: false, state: defaultIssueFilterState, onChange: vi.fn(), activeFilterCount: 0 };
act(() => root.render(<IssueFiltersPopover {...props} />));
expect(container.textContent).not.toContain("Live runs only");
expect(container.textContent).not.toContain("Visibility");
act(() => root.render(<IssueFiltersPopover {...props} enableRoutineTasksFilter />));
const visibility = container.querySelector('[data-filter-options="visibility"]');
expect(Array.from(visibility?.querySelectorAll("label") ?? []).map(label => label.textContent)).toEqual(["Routine tasks"]);
act(() => root.unmount());
});
it("uses a scrollable popover and a three-column desktop grid", () => {
const root = createRoot(container);
@@ -86,8 +117,8 @@ describe("IssueFiltersPopover", () => {
);
expect(layoutGrid?.className).toContain("grid-cols-1");
expect(popoverContent?.textContent).toContain("Live runs only");
const creationToggle = Array.from(popoverContent?.querySelectorAll("label") ?? []).find((label) => label.textContent?.includes("show agent-created tasks"));
expect(creationToggle?.textContent).toBe("show agent-created tasks");
const creationToggle = Array.from(popoverContent?.querySelectorAll("label") ?? []).find((label) => label.textContent?.includes("Show agent-created tasks"));
expect(creationToggle?.textContent).toBe("Show agent-created tasks");
expect(creationToggle?.querySelector("input")?.checked).toBe(false);
});
+17 -6
View File
@@ -110,7 +110,9 @@ export function IssueFiltersPopover({
labels,
currentUserId,
enableExternalObjectFilters = true,
enableLiveRunsFilter = true,
enableAgentCreatedVisibilityFilter = false,
enableRoutineTasksFilter = false,
buttonVariant = "ghost",
iconOnly = false,
creators,
@@ -125,7 +127,9 @@ export function IssueFiltersPopover({
labels?: LabelOption[];
currentUserId?: string | null;
enableExternalObjectFilters?: boolean;
enableLiveRunsFilter?: boolean;
enableAgentCreatedVisibilityFilter?: boolean;
enableRoutineTasksFilter?: boolean;
buttonVariant?: "ghost" | "outline";
iconOnly?: boolean;
creators?: CreatorOption[];
@@ -210,7 +214,7 @@ export function IssueFiltersPopover({
? "w-(--sz-calc-10) max-h-(--sz-calc-9) overflow-y-auto overscroll-contain p-0"
: "w-(--sz-calc-10) p-0"}
>
{refreshed ? <IssueFilterPanel state={state} onChange={onChange} activeFilterCount={activeFilterCount} agents={agents} projects={projects} labels={labels} creators={creators} currentUserId={currentUserId} onClear={clearFilters} /> : <div className="space-y-3 p-3">
{refreshed ? <IssueFilterPanel enableLiveRunsFilter={enableLiveRunsFilter} enableAgentCreatedVisibilityFilter={enableAgentCreatedVisibilityFilter} enableRoutineTasksFilter={enableRoutineTasksFilter} state={state} onChange={onChange} activeFilterCount={activeFilterCount} agents={agents} projects={projects} labels={labels} creators={creators} currentUserId={currentUserId} onClear={clearFilters} /> : <div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filters</span>
{activeFilterCount > 0 ? (
@@ -516,25 +520,32 @@ export function IssueFiltersPopover({
</div>
) : null}
<div className="space-y-1">
{enableLiveRunsFilter || enableAgentCreatedVisibilityFilter || enableRoutineTasksFilter ? <div className="space-y-1" data-filter-options="visibility">
<span className="text-xs text-muted-foreground">Visibility</span>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
{enableLiveRunsFilter ? <label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.liveOnly}
onCheckedChange={(checked) => onChange({ liveOnly: checked === true })}
/>
<span className="text-sm">Live runs only</span>
</label>
</label> : null}
{enableAgentCreatedVisibilityFilter ? (
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.showAgentCreatedTasks}
onCheckedChange={(checked) => onChange({ showAgentCreatedTasks: checked === true })}
/>
<span className="text-sm">show agent-created tasks</span>
<span className="text-sm">Show agent-created tasks</span>
</label>
) : null}
</div>
{enableRoutineTasksFilter ? <label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.routineTasksOnly === true}
onCheckedChange={(checked) => onChange({ routineTasksOnly: checked === true })}
/>
<span className="text-sm">Routine tasks</span>
</label> : null}
</div> : null}
</div>
</div>
</div>}
+42 -3
View File
@@ -2114,6 +2114,45 @@ describe("IssuesList", () => {
});
});
it.each([true, false])("filters the real list, exposes the checkbox, and scopes search and board reads (streamlined=%s)", async (streamlined) => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined });
const routine = createIssue({ id: "routine-result", title: "Routine report", originKind: "routine_execution" });
const manual = createIssue({ id: "manual-result", title: "Manual report", originKind: "manual", createdByUserId: "person" });
mockIssuesApi.list.mockResolvedValue([routine, manual]);
const { root, queryClient } = renderWithQueryClient(<IssuesList
issues={[routine, manual]} agents={[]} projects={[]} viewStateKey="paperclip:test-issues"
routineTasksOnly enableAgentCreatedVisibilityFilter initialSearch="report"
onUpdateIssue={() => undefined}
/>, container);
try {
await waitForAssertion(() => {
expect(container.textContent).toContain("Routine report");
expect(container.textContent).not.toContain("Manual report");
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
q: "report", originKind: "routine_execution", includeRoutineExecutions: true, includeAgentCreatedTasks: true,
}), expect.anything());
});
const filterButton = () => Array.from(document.body.querySelectorAll("button")).find(button =>
button.title.startsWith("Filters:") || button.textContent?.includes("Filters:"));
await act(() => filterButton()!.click());
await waitForAssertion(() => {
const row = Array.from(document.body.querySelectorAll("label")).find(label => label.textContent === "Routine tasks");
expect(row?.querySelector('[role="checkbox"]')?.getAttribute("aria-checked")).toBe("true");
});
await act(() => filterButton()!.click());
await act(() => container.querySelector<HTMLButtonElement>('[aria-label="Board view"]')!.click());
await waitForAssertion(() => {
const columns = mockIssuesApi.list.mock.calls.filter(([, filters]) => filters.status);
expect(columns).toHaveLength(7);
for (const [, filters] of columns) expect(filters).toMatchObject({
q: "report", originKind: "routine_execution", includeRoutineExecutions: true, includeAgentCreatedTasks: true,
});
});
} finally {
act(() => root.unmount()); queryClient.clear();
}
});
it.each([true, false])("keeps visibility controls and fetching preferences synchronized (streamlined=%s)", async (streamlined) => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: streamlined });
function PreferenceProbe() {
@@ -2161,14 +2200,14 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("show agent-created tasks"),
(label) => label.textContent?.includes("Show agent-created tasks"),
);
expect(toggle).not.toBeUndefined();
});
await act(async () => {
const toggle = Array.from(document.body.querySelectorAll("label")).find(
(label) => label.textContent?.includes("show agent-created tasks"),
(label) => label.textContent?.includes("Show agent-created tasks"),
);
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
@@ -2187,7 +2226,7 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Routine issue");
expect(container.querySelector("[data-testid='creation-preference']")?.textContent).toBe("false");
const label = Array.from(document.body.querySelectorAll("label")).find((element) => element.textContent?.includes("show agent-created tasks"));
const label = Array.from(document.body.querySelectorAll("label")).find((element) => element.textContent?.includes("Show agent-created tasks"));
const checkbox = label?.querySelector("[role='checkbox'], input[type='checkbox']");
expect(checkbox).not.toBeNull();
expect(checkbox instanceof HTMLInputElement ? checkbox.checked : checkbox?.getAttribute("aria-checked") === "true").toBe(false);
+19 -10
View File
@@ -34,6 +34,7 @@ import {
import { groupBy } from "../lib/groupBy";
import {
applyIssueFilters,
issueCreationRequestFilters,
countActiveIssueFilters,
defaultIssueFilterState,
issueFilterLabel,
@@ -487,6 +488,8 @@ interface IssuesListProps {
* Used by the Tasks view presets (PAP-670).
*/
initialStatuses?: string[];
routineTasksOnly?: boolean;
onRoutineTasksOnlyChange?: (checked: boolean) => void;
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
@@ -732,6 +735,8 @@ function StreamlinedIssuesList({
initialAssignees,
initialWorkspaces,
initialStatuses,
routineTasksOnly,
onRoutineTasksOnlyChange,
initialSearch,
searchFilters,
searchWithinLoadedIssues = false,
@@ -818,7 +823,7 @@ function StreamlinedIssuesList({
}
const initialPreferences = initialPreferencesRef.current;
const [viewState, setViewState] = useState<IssueViewState>(() =>
const [savedViewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(
initialPreferences,
initialAssignees,
@@ -827,6 +832,8 @@ function StreamlinedIssuesList({
initialStatuses,
),
);
// The Tasks URL owns this filter; other collections keep their saved state.
const viewState = useMemo(() => routineTasksOnly === undefined ? savedViewState : { ...savedViewState, routineTasksOnly }, [savedViewState, routineTasksOnly]);
const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey);
useEffect(() => {
if (!enableAgentCreatedVisibilityFilter) return;
@@ -882,6 +889,7 @@ function StreamlinedIssuesList({
]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
if (patch.routineTasksOnly !== undefined) onRoutineTasksOnlyChange?.(patch.routineTasksOnly);
setViewState((prev) => {
const next = { ...prev, ...patch };
saveTaskCollectionPreferences(preferenceLocation, {
@@ -896,6 +904,7 @@ function StreamlinedIssuesList({
preferenceLocation.legacyColumnsStorageKey,
preferenceLocation.legacyViewStorageKey,
visibleIssueColumns,
onRoutineTasksOnlyChange,
]);
useEffect(() => {
@@ -917,14 +926,14 @@ function StreamlinedIssuesList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issues]);
const creationRequestFilters = issueCreationRequestFilters(viewState, enableAgentCreatedVisibilityFilter);
const { data: searchedIssues = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
creationRequestFilters,
enableAgentCreatedVisibilityFilter && viewState.viewMode === "list" ? "index" : "compact",
ISSUE_SEARCH_RESULT_LIMIT,
enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined,
],
queryFn: ({ signal }) =>
issuesApi.listCompact(selectedCompanyId!, {
@@ -933,7 +942,7 @@ function StreamlinedIssuesList({
projectId,
limit: ISSUE_SEARCH_RESULT_LIMIT,
...searchFilters,
...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}),
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues,
placeholderData: (previousData) => previousData,
@@ -947,10 +956,9 @@ function StreamlinedIssuesList({
normalizedIssueSearch,
projectId ?? "__all-projects__",
searchFilters ?? {},
creationRequestFilters,
"compact",
ISSUE_BOARD_COLUMN_RESULT_LIMIT,
enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined,
],
queryFn: ({ signal }: { signal: AbortSignal }) =>
issuesApi.listCompact(selectedCompanyId!, {
@@ -959,7 +967,7 @@ function StreamlinedIssuesList({
projectId,
status,
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}),
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues,
placeholderData: (previousData: Issue[] | undefined) => previousData,
@@ -1307,7 +1315,7 @@ function StreamlinedIssuesList({
|| viewState.boardColdLaneMode !== "auto"
|| viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list";
const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && !viewState.routineTasksOnly && viewState.nestingEnabled && viewState.viewMode === "list";
const hierarchy = useIssueHierarchyContext(
selectedCompanyId, filtered.slice(0, renderedIssueRowLimit), viewState.collapsedParents,
hierarchyEnabled,
@@ -1410,10 +1418,10 @@ function StreamlinedIssuesList({
]);
const groupedContent = useMemo(() => baseGroupedContent.map((group) => ({
...group,
items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list"
items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && !viewState.routineTasksOnly && viewState.nestingEnabled && viewState.viewMode === "list"
? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues])
: group.items,
})), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.nestingEnabled, viewState.viewMode]);
})), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.routineTasksOnly, viewState.nestingEnabled, viewState.viewMode]);
const visibleListRowCount = groupedContent.reduce((total, group) => total + group.items.length, 0);
@@ -1956,6 +1964,7 @@ function StreamlinedIssuesList({
/>
<IssueFiltersPopover
enableRoutineTasksFilter={routineTasksOnly !== undefined}
state={viewState}
onChange={updateView}
buttonVariant="outline"
+19 -11
View File
@@ -33,6 +33,7 @@ import {
import { groupBy } from "../lib/groupBy";
import {
applyIssueFilters,
issueCreationRequestFilters,
countActiveIssueFilters,
defaultIssueFilterState,
issueFilterLabel,
@@ -465,6 +466,8 @@ interface IssuesListProps {
issueLinkState?: unknown;
initialAssignees?: string[];
initialWorkspaces?: string[];
routineTasksOnly?: boolean;
onRoutineTasksOnlyChange?: (checked: boolean) => void;
initialSearch?: string;
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
@@ -681,6 +684,8 @@ export function IssuesList({
issueLinkState,
initialAssignees,
initialWorkspaces,
routineTasksOnly,
onRoutineTasksOnlyChange,
initialSearch,
searchFilters,
searchWithinLoadedIssues = false,
@@ -752,9 +757,11 @@ export function IssuesList({
const initialAssigneesKey = initialAssignees?.join("|") ?? "";
const initialWorkspacesKey = initialWorkspaces?.join("|") ?? "";
const [viewState, setViewState] = useState<IssueViewState>(() =>
const [savedViewState, setViewState] = useState<IssueViewState>(() =>
getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField),
);
// The Tasks URL owns this filter; other collections keep their saved state.
const viewState = useMemo(() => routineTasksOnly === undefined ? savedViewState : { ...savedViewState, routineTasksOnly }, [savedViewState, routineTasksOnly]);
const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey);
useEffect(() => {
if (!enableAgentCreatedVisibilityFilter) return;
@@ -795,6 +802,7 @@ export function IssuesList({
}, [scopedKey]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
if (patch.routineTasksOnly !== undefined) onRoutineTasksOnlyChange?.(patch.routineTasksOnly);
setViewState((prev) => {
const next = { ...prev, ...patch };
saveTaskCollectionPreferences({
@@ -803,7 +811,7 @@ export function IssuesList({
}, { viewState: next, columns: visibleIssueColumns });
return next;
});
}, [scopedKey, selectedCompanyId, viewStateKey, visibleIssueColumns]);
}, [scopedKey, selectedCompanyId, viewStateKey, visibleIssueColumns, onRoutineTasksOnlyChange]);
useEffect(() => {
if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return;
@@ -824,14 +832,14 @@ export function IssuesList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [issues]);
const creationRequestFilters = issueCreationRequestFilters(viewState, enableAgentCreatedVisibilityFilter);
const { data: searchedIssues = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
creationRequestFilters,
"compact",
ISSUE_SEARCH_RESULT_LIMIT,
enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined,
],
queryFn: ({ signal }) =>
issuesApi.listCompact(selectedCompanyId!, {
@@ -839,7 +847,7 @@ export function IssuesList({
projectId,
limit: ISSUE_SEARCH_RESULT_LIMIT,
...searchFilters,
...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}),
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues,
placeholderData: (previousData) => previousData,
@@ -853,10 +861,9 @@ export function IssuesList({
normalizedIssueSearch,
projectId ?? "__all-projects__",
searchFilters ?? {},
creationRequestFilters,
"compact",
ISSUE_BOARD_COLUMN_RESULT_LIMIT,
enableAgentCreatedVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
enableAgentCreatedVisibilityFilter ? viewState.showAgentCreatedTasks : undefined,
],
queryFn: ({ signal }: { signal: AbortSignal }) =>
issuesApi.listCompact(selectedCompanyId!, {
@@ -865,7 +872,7 @@ export function IssuesList({
projectId,
status,
limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
...(enableAgentCreatedVisibilityFilter ? { includeRoutineExecutions: true, includeAgentCreatedTasks: viewState.showAgentCreatedTasks } : {}),
...creationRequestFilters,
}, { signal }).then((rows) => rows as Issue[]),
enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues,
placeholderData: (previousData: Issue[] | undefined) => previousData,
@@ -1206,7 +1213,7 @@ export function IssuesList({
|| viewState.boardColdLaneMode !== "auto"
|| viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list";
const hierarchyEnabled = enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && !viewState.routineTasksOnly && viewState.nestingEnabled && viewState.viewMode === "list";
const hierarchy = useIssueHierarchyContext(
selectedCompanyId, filtered.slice(0, renderedIssueRowLimit), viewState.collapsedParents,
hierarchyEnabled,
@@ -1306,10 +1313,10 @@ export function IssuesList({
]);
const groupedContent = useMemo(() => baseGroupedContent.map((group) => ({
...group,
items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && viewState.nestingEnabled && viewState.viewMode === "list"
items: enableAgentCreatedVisibilityFilter && !viewState.showAgentCreatedTasks && !viewState.routineTasksOnly && viewState.nestingEnabled && viewState.viewMode === "list"
? includeIssueHierarchyContext(group.items, [...issues, ...hierarchy.issues])
: group.items,
})), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.nestingEnabled, viewState.viewMode]);
})), [baseGroupedContent, hierarchy.issues, issues, enableAgentCreatedVisibilityFilter, viewState.showAgentCreatedTasks, viewState.routineTasksOnly, viewState.nestingEnabled, viewState.viewMode]);
const visibleListRowCount = groupedContent.reduce((total, group) => total + group.items.length, 0);
@@ -1822,6 +1829,7 @@ export function IssuesList({
/>
<IssueFiltersPopover
enableRoutineTasksFilter={routineTasksOnly !== undefined}
presentation="legacy"
state={viewState}
onChange={updateView}
+3 -3
View File
@@ -8,7 +8,7 @@ export type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
export type RoutineSortField = "updated" | "created" | "title" | "lastRun";
export type RoutineSortDir = "asc" | "desc";
export function RoutinesToolbar({ query, onQueryChange, sortField, sortDir, groupBy, onSortChange, onGroupChange, onNewFolder, onCreateRoutine, runsHref, selectMode, onToggleSelect }: {
export function RoutinesToolbar({ query, onQueryChange, sortField, sortDir, groupBy, onSortChange, onGroupChange, onNewFolder, onCreateRoutine, tasksHref, selectMode, onToggleSelect }: {
query: string;
onQueryChange: (value: string) => void;
sortField: RoutineSortField;
@@ -18,7 +18,7 @@ export function RoutinesToolbar({ query, onQueryChange, sortField, sortDir, grou
onGroupChange: (value: RoutineGroupBy) => void;
onNewFolder?: () => void;
onCreateRoutine: () => void;
runsHref?: string;
tasksHref?: string;
selectMode?: boolean;
onToggleSelect?: () => void;
}) {
@@ -98,7 +98,7 @@ export function RoutinesToolbar({ query, onQueryChange, sortField, sortDir, grou
</Popover>
<div className="design-toolbar-actions">
{onToggleSelect && <Button variant="outline" size="sm" onClick={onToggleSelect}>{selectMode ? "Done" : "Select"}</Button>}
{runsHref && <Button variant="outline" size="sm" asChild><Link data-toolbar-control to={runsHref}>View all runs</Link></Button>}
{tasksHref && <Button variant="outline" size="sm" asChild><Link data-toolbar-control to={tasksHref}>View all tasks</Link></Button>}
{onNewFolder && <Button variant="outline" size="sm" onClick={onNewFolder}><Plus className="h-4 w-4" />New folder</Button>}
<Button variant="outline" size="sm" data-toolbar-primary onClick={onCreateRoutine}><Plus className="h-4 w-4" />Create routine</Button>
</div>
+9
View File
@@ -794,6 +794,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}).map((issue) => issue.id),
).toEqual(["remote-match"]);
@@ -815,6 +816,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}),
).toEqual([]);
@@ -836,6 +838,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
}),
).toEqual([]);
@@ -884,6 +887,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: ["failed"],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
issueFilterContext: {
externalObjectSummaryByIssueId: summaries,
@@ -980,6 +984,7 @@ describe("inbox helpers", () => {
liveOnly: true,
externalObjectStatuses: [],
showAgentCreatedTasks: true,
routineTasksOnly: false,
},
});
saveInboxFilterPreferences("company-2", {
@@ -996,6 +1001,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
@@ -1013,6 +1019,7 @@ describe("inbox helpers", () => {
liveOnly: true,
externalObjectStatuses: [],
showAgentCreatedTasks: true,
routineTasksOnly: false,
},
});
expect(loadInboxFilterPreferences("company-2")).toEqual({
@@ -1029,6 +1036,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
});
@@ -1064,6 +1072,7 @@ describe("inbox helpers", () => {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
},
});
});
+12
View File
@@ -80,6 +80,18 @@ describe("issue filters", () => {
expect(countActiveIssueFilters({ ...defaultIssueFilterState, showAgentCreatedTasks: true }, true)).toBe(1);
});
it("selects only routine executions even when automatic tasks are hidden", () => {
const routine = makeIssue({ id: "routine", originKind: "routine_execution", createdByUserId: "person" });
const issues = [makeIssue({ id: "manual", createdByUserId: "person" }), routine,
makeIssue({ id: "agent", createdByAgentId: "agent" }), makeIssue({ id: "channel", originKind: "chat_channel" })];
const filters = { ...defaultIssueFilterState, routineTasksOnly: true };
expect(applyIssueFilters(issues, filters, null, true)).toEqual([routine]);
expect(applyIssueFilters(issues, { ...filters, statuses: ["done"] })).toEqual([]);
expect(countActiveIssueFilters(filters, true)).toBe(1);
expect(normalizeIssueFilterState({ routineTasksOnly: true }).routineTasksOnly).toBe(true);
expect(normalizeIssueFilterState({ routineTasksOnly: "true" }).routineTasksOnly).toBe(false);
});
it("defaults legacy and invalid preferences to hidden without losing other filters", () => {
expect(normalizeIssueFilterState({ hideRoutineExecutions: false, statuses: ["todo"] })).toMatchObject({ showAgentCreatedTasks: false, statuses: ["todo"] });
expect(normalizeIssueFilterState({ showAgentCreatedTasks: "true" }).showAgentCreatedTasks).toBe(false);
+21 -1
View File
@@ -37,6 +37,7 @@ export type IssueFilterState = {
*/
externalObjectStatuses: string[];
showAgentCreatedTasks: boolean;
routineTasksOnly?: boolean;
};
export const defaultIssueFilterState: IssueFilterState = {
@@ -50,6 +51,7 @@ export const defaultIssueFilterState: IssueFilterState = {
liveOnly: false,
externalObjectStatuses: [],
showAgentCreatedTasks: false,
routineTasksOnly: false,
};
export const externalObjectFilterOrder = [
@@ -118,6 +120,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
liveOnly: candidate.liveOnly === true,
externalObjectStatuses: normalizeIssueFilterValueArray(candidate.externalObjectStatuses),
showAgentCreatedTasks: candidate.showAgentCreatedTasks === true,
routineTasksOnly: candidate.routineTasksOnly === true,
};
}
@@ -210,6 +213,19 @@ function issueMatchesExternalObjectStatusFilter(
}
}
/** Keep creation visibility and routine origin consistent across collection reads. */
export function issueCreationRequestFilters(
state: Pick<IssueFilterState, "routineTasksOnly" | "showAgentCreatedTasks">,
enableAgentCreatedVisibilityFilter = false,
) {
if (state.routineTasksOnly) {
return { originKind: "routine_execution", includeRoutineExecutions: true, includeAgentCreatedTasks: true };
}
return enableAgentCreatedVisibilityFilter
? { includeRoutineExecutions: true, includeAgentCreatedTasks: state.showAgentCreatedTasks }
: {};
}
export function applyIssueFilters(
issues: Issue[],
state: IssueFilterState,
@@ -222,7 +238,10 @@ export function applyIssueFilters(
if (state.liveOnly) {
result = result.filter((issue) => liveIssueIds?.has(issue.id) === true);
}
if (enableAgentCreatedVisibilityFilter && !state.showAgentCreatedTasks) {
if (state.routineTasksOnly) {
result = result.filter((issue) => issue.originKind === "routine_execution");
}
if (enableAgentCreatedVisibilityFilter && !state.showAgentCreatedTasks && !state.routineTasksOnly) {
result = result.filter((issue) => !isAgentCreatedIssue(issue));
}
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
@@ -284,6 +303,7 @@ export function countActiveIssueFilters(
if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (state.liveOnly) count += 1;
if (state.routineTasksOnly) count += 1;
if (state.externalObjectStatuses.length > 0) count += 1;
if (enableAgentCreatedVisibilityFilter && state.showAgentCreatedTasks) count += 1;
return count;
+1
View File
@@ -135,6 +135,7 @@ export const ORGANIZATION_SCOPED_PARAMS = [
"assignee",
"workspace",
"participantAgentId",
"originKind",
"q",
] as const;
+27 -1
View File
@@ -22,7 +22,9 @@ vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) }));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.breadcrumbs }) }));
vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[] }) => void }; onViewChange: (view: "blocked") => void; onLayoutChange: (layout: "board") => void }) => <>
vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[]; routineTasksOnly?: boolean }) => void }; onViewChange: (view: "blocked") => void; onLayoutChange: (layout: "board") => void }) => <>
<button onClick={() => filters.onChange({ routineTasksOnly: true })}>Routine tasks</button>
<button onClick={() => filters.onChange({ routineTasksOnly: false })}>Show other tasks</button>
<button onClick={() => filters.onChange({ liveOnly: true })}>Live runs only</button>
<button onClick={() => filters.onChange({ priorities: ["high"] })}>High priority</button>
<button onClick={() => filters.onChange({ assignees: [], statuses: [] })}>Clear assignee and status</button>
@@ -60,6 +62,30 @@ describe("Coordinator tasks using the proposed table", () => {
expect(mocks.get).not.toHaveBeenCalled();
});
it("opens routine task links, filters before paging, and lets the checkbox change the collection", async () => {
const routine = createIssue({ id: "routine-task", companyId: "company", parentId: null, originKind: "routine_execution", title: "Routine report" });
const manual = createIssue({ id: "manual-task", companyId: "company", parentId: null, originKind: "manual", title: "Manual report" });
mocks.list.mockImplementation(async (_company, filters) => filters.originKind === "routine_execution" ? [routine] : [manual, routine]);
await mount("/issues?view=all&originKind=routine_execution");
expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ originKind: "routine_execution", includeAgentCreatedTasks: true, includeRoutineExecutions: true, offset: 0 }), expect.anything());
expect([...host.querySelectorAll("[data-task-id]")].map(row => row.getAttribute("data-task-id"))).toEqual([routine.id]);
flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Show other tasks")!.click());
await vi.waitFor(() => expect(host.textContent).toContain("Manual report"));
expect(mocks.list.mock.lastCall?.[1].originKind).toBeUndefined();
flushSync(() => Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Routine tasks")!.click());
await vi.waitFor(() => expect(host.querySelector('[data-task-id="manual-task"]')).toBeNull());
});
it("filters pending-decision batches by routine origin without treating excluded IDs as missing", async () => {
const routine = createIssue({ id: "routine-decision", companyId: "company", parentId: null, originKind: "routine_execution", title: "Routine decision" });
mocks.attention.mockResolvedValue({ items: [decision, { ...decision, relatedIssue: { id: routine.id } }] });
mocks.list.mockResolvedValue([routine]);
await mount("/issues?view=needs_attention&originKind=routine_execution");
expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ ids: [pending.id, routine.id], originKind: "routine_execution" }), expect.anything());
expect(host.textContent).toContain("Routine decision");
expect(host.textContent).not.toContain("couldn’t load");
});
it("loads diagnostics for visible production board cards and retains their next-step badges", async () => {
vi.stubGlobal("IntersectionObserver", class {
constructor(private callback: IntersectionObserverCallback) {}
+20 -13
View File
@@ -49,10 +49,12 @@ export function CoordinatorTasks() {
const urlAssignee = JSON.stringify(params.getAll("assignee"));
const urlStatuses = JSON.stringify(params.getAll("status"));
const urlView = params.get("view");
const routineTasksOnly = params.get("originKind") === "routine_execution";
const originKind = routineTasksOnly ? "routine_execution" : undefined;
const normalizedView = normalizeTaskViewKey(urlView);
const presetStatuses = normalizedView && taskView(normalizedView).surface === "issues" ? taskView(normalizedView).statuses ?? [] : [];
const initialStatuses: string[] = params.has("status") ? params.getAll("status") : presetStatuses;
const previousUrlFilters = useRef({ assignee: urlAssignee, statuses: urlStatuses, view: urlView });
const previousUrlFilters = useRef({ assignee: urlAssignee, statuses: urlStatuses, view: urlView, routineTasksOnly });
const needsAttention = params.get("view") === "needs_attention";
const query = params.get("q") ?? "";
const deferredQuery = useDeferredValue(query.trim());
@@ -72,11 +74,11 @@ export function CoordinatorTasks() {
useEffect(() => { setBreadcrumbs([{ label: "Tasks" }]); }, [setBreadcrumbs]);
const tasks = useInfiniteQuery({
queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", "index", userId, deferredQuery, participantAgentId, workspaceIds],
queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", "index", userId, deferredQuery, participantAgentId, workspaceIds, originKind],
queryFn: async ({ pageParam, signal }) => {
const pages = await Promise.all((workspaceIds.length ? workspaceIds : [undefined]).map(workspaceId => issuesApi.listCompact(companyId!, {
projection: "index", limit: pageSize, offset: pageParam, includeAgentCreatedTasks: true, includeRoutineExecutions: true,
sortField: "updated", sortDir: "desc", q: deferredQuery || undefined, participantAgentId, workspaceId,
sortField: "updated", sortDir: "desc", q: deferredQuery || undefined, participantAgentId, workspaceId, originKind,
}, { signal })));
return { items: pages.flat(), hasNextPage: pages.some(page => page.length === pageSize) };
},
@@ -91,13 +93,13 @@ export function CoordinatorTasks() {
const attentionIds = needsAttention ? [...pendingDecisionTaskIds(attention.data?.items ?? [])] : [];
const attentionBatches = Array.from({ length: Math.ceil(attentionIds.length / 100) }, (_, index) => attentionIds.slice(index * 100, (index + 1) * 100));
const attentionTasks = useQueries({ queries: attentionBatches.map(ids => ({
queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, ids],
queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, originKind, ids],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const rows = await issuesApi.listCompact(companyId!, {
ids, projection: "index", limit: 100, q: deferredQuery || undefined, participantAgentId,
ids, projection: "index", limit: 100, q: deferredQuery || undefined, participantAgentId, originKind,
includeAgentCreatedTasks: true, includeRoutineExecutions: true,
}, { signal });
if (!deferredQuery && !participantAgentId && ids.some(id => !rows.some(row => row.id === id))) {
if (!deferredQuery && !participantAgentId && !originKind && ids.some(id => !rows.some(row => row.id === id))) {
throw new Error("Some tasks with pending decisions couldn’t load.");
}
return rows;
@@ -126,22 +128,27 @@ export function CoordinatorTasks() {
searchMode: "server",
liveIssueIds: collectLiveIssueIds(liveRuns.data, records),
viewStorageKey: taskListViewStorageKey("coordinator", userId ?? "local-board"),
initialFilters: { assignees: params.getAll("assignee"), statuses: initialStatuses },
initialFilters: { assignees: params.getAll("assignee"), statuses: initialStatuses, routineTasksOnly },
});
useEffect(() => {
const previous = previousUrlFilters.current;
if (previous.assignee === urlAssignee && previous.view === urlView && previous.statuses === urlStatuses) return;
previousUrlFilters.current = { assignee: urlAssignee, statuses: urlStatuses, view: urlView };
if (previous.assignee === urlAssignee && previous.view === urlView && previous.statuses === urlStatuses && previous.routineTasksOnly === routineTasksOnly) return;
previousUrlFilters.current = { assignee: urlAssignee, statuses: urlStatuses, view: urlView, routineTasksOnly };
collection.updateFilters({
...(previous.routineTasksOnly !== routineTasksOnly ? { routineTasksOnly } : {}),
...(previous.assignee !== urlAssignee ? { assignees: params.getAll("assignee") } : {}),
...(previous.view !== urlView || previous.statuses !== urlStatuses ? { statuses: initialStatuses } : {}),
});
}, [urlAssignee, urlView, urlStatuses, initialStatuses, collection.updateFilters]);
}, [urlAssignee, urlView, urlStatuses, initialStatuses, routineTasksOnly, collection.updateFilters]);
const updateFilters = (patch: Partial<IssueFilterState>) => {
collection.updateFilters(patch);
if (patch.assignees === undefined && patch.statuses === undefined) return;
if (patch.assignees === undefined && patch.statuses === undefined && patch.routineTasksOnly === undefined) return;
setParams(current => {
const next = new URLSearchParams(current);
if (patch.routineTasksOnly !== undefined) {
if (patch.routineTasksOnly) next.set("originKind", "routine_execution");
else next.delete("originKind");
}
if (patch.assignees !== undefined) {
next.delete("assignee");
for (const id of patch.assignees) next.append("assignee", id);
@@ -160,7 +167,7 @@ export function CoordinatorTasks() {
collection.updateFilters(defaultIssueFilterState);
setParams(current => {
const next = new URLSearchParams(current);
for (const key of ["q", "assignee", "status"]) next.delete(key);
for (const key of ["q", "assignee", "status", "originKind"]) next.delete(key);
if (next.get("view") !== "needs_attention") next.set("view", "all");
return next;
}, { replace: true });
@@ -198,7 +205,7 @@ export function CoordinatorTasks() {
<TaskListToolbar view={needsAttention ? "blocked" : "all"} viewOptions={views} onViewChange={view => setView(view === "blocked")}
query={collection.query} onQueryChange={collection.setQuery} layout={collection.layout} onLayoutChange={collection.setLayout}
allExpanded={collection.allExpanded} onToggleExpanded={collection.toggleExpanded} onNewTask={() => openNewIssue()}
filters={{ state: collection.filters, onChange: updateFilters, activeFilterCount: collection.activeFilterCount,
filters={{ enableRoutineTasksFilter: true, state: collection.filters, onChange: updateFilters, activeFilterCount: collection.activeFilterCount,
agents: agents.data ?? [], projects: projects.data ?? [], labels: labels.data ?? [], currentUserId: userId, creators: collection.creators }} />
{error ? <div role="alert" className="flex items-center gap-2 text-sm text-destructive"><span>{error instanceof Error ? error.message : "Could not load tasks"}</span><Button variant="outline" size="sm" onClick={retry}>Retry</Button></div> : null}
{rowDetailsError ? <div role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><span>Task details couldn’t load.</span><Button variant="ghost" size="sm" onClick={() => void retryRowDetails()}>Retry</Button></div> : null}
+1
View File
@@ -92,6 +92,7 @@ export function DesignSandboxTasks() {
<TaskListToolbar view={collection.view} onViewChange={collection.setView} query={collection.query} onQueryChange={collection.setQuery}
layout={collection.layout} onLayoutChange={collection.setLayout} allExpanded={collection.allExpanded} onToggleExpanded={collection.toggleExpanded}
onNewTask={() => openNewIssue()} filters={{ state: collection.filters, onChange: collection.updateFilters, activeFilterCount: collection.activeFilterCount,
enableLiveRunsFilter: false,
agents: agents.data ?? [], projects: projects.data ?? [], labels: labels.data ?? [], currentUserId: userId, creators: collection.creators }} />
{error ? <div role="alert" className="flex items-center gap-2 text-sm text-destructive"><span>{error instanceof Error ? error.message : "Could not load tasks"}</span><Button variant="outline" size="sm" onClick={() => { update.reset(); void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); void agents.refetch(); void projects.refetch(); void directory.refetch(); void labels.refetch(); }}>Retry</Button></div> : null}
{tasks.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading tasks…</p> : <>
@@ -30,8 +30,8 @@ it.each([["mine", "mine"], ["unread", "unread"], ["blocked", "blocked"], ["recen
expect(host.textContent).not.toContain("Coordinator tasks");
} finally { cleanup(); }
});
it("keeps organization-scoped task links out of Inbox", () => {
const { host, cleanup } = render("/ORG/issues?view=mine&workspace=workspace");
it.each(["workspace=workspace", "originKind=routine_execution"])("keeps organization-scoped task links out of Inbox: %s", filter => {
const { host, cleanup } = render(`/ORG/issues?view=mine&${filter}`);
try { expect(host.textContent).toContain("Coordinator tasks"); expect(host.textContent).not.toContain("Inbox owner"); }
finally { cleanup(); }
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { MemoryRouter } from "@/lib/router";
import { useLocation } from "react-router-dom";
import { Issues } from "./Issues";
const mocks = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../context/DesignSandboxContext", () => ({ useDesignSandbox: () => false }));
vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: false }) }));
vi.mock("../hooks/useStreamlinedUiEnabled", () => ({ useStreamlinedUiEnabled: () => ({ enabled: true }) }));
vi.mock("../hooks/useCombinedInboxTasksEnabled", () => ({ useCombinedInboxTasksEnabled: () => ({ enabled: false }) }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("../api/issues", () => ({ issuesApi: { listCompact: mocks.list } }));
vi.mock("../api/agents", () => ({ agentsApi: { list: async () => [] } }));
vi.mock("../api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: async () => [] } }));
vi.mock("../hooks/useUpdateIssueMutation", () => ({ useUpdateIssueMutation: () => ({ mutate: vi.fn() }) }));
vi.mock("../components/IssuesList", () => ({ IssuesList: ({ issues, routineTasksOnly, onRoutineTasksOnlyChange, onLoadMoreIssues }: {
issues: Issue[]; routineTasksOnly: boolean; onRoutineTasksOnlyChange: (checked: boolean) => void; onLoadMoreIssues: () => void;
}) => <>
<label><input type="checkbox" checked={routineTasksOnly} onChange={event => onRoutineTasksOnlyChange(event.target.checked)} />Routine tasks</label>
<button onClick={onLoadMoreIssues}>Load more</button>
<output>{issues.map(issue => issue.id).join(",")}</output>
<p>{useLocation().search}</p>
</> }));
afterEach(() => { localStorage.clear(); vi.clearAllMocks(); });
it("fetches routine tasks before pagination and removes the URL filter when unchecked", async () => {
mocks.list.mockImplementation(async (_company, filters) => filters.originKind === "routine_execution"
? Array.from({ length: filters.offset ? 1 : 100 }, (_, index) => ({ id: `routine-${filters.offset + index}` }))
: [{ id: "human-task" }]);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
try {
flushSync(() => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/issues?view=all&originKind=routine_execution"]}><Issues /></MemoryRouter></QueryClientProvider>));
await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("routine-99"));
expect(host.querySelector("input")?.checked).toBe(true);
expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({
originKind: "routine_execution", includeAgentCreatedTasks: true, includeRoutineExecutions: true, offset: 0,
}), expect.anything());
flushSync(() => host.querySelector("button")!.click());
await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("routine-100"));
expect(mocks.list.mock.lastCall?.[1]).toMatchObject({ originKind: "routine_execution", offset: 100 });
flushSync(() => host.querySelector("input")!.click());
await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe("human-task"));
expect(mocks.list.mock.lastCall?.[1]).toMatchObject({ includeAgentCreatedTasks: false, offset: 0 });
expect(mocks.list.mock.lastCall?.[1].originKind).toBeUndefined();
expect(host.querySelector("p")?.textContent).toBe("?view=all");
} finally {
flushSync(() => root.unmount()); client.clear(); host.remove();
}
});
+21 -4
View File
@@ -186,7 +186,7 @@ function OrganizationIssues({
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const fetchNextPageInFlightRef = useRef(false);
const urlSearch = searchParams.get("q") ?? "";
@@ -198,6 +198,19 @@ function OrganizationIssues({
return urlSearch;
}, [searchOverride, urlSearch, location.search]);
const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, "paperclip:issues-view");
const routineTasksOnly = searchParams.get("originKind") === "routine_execution";
const includeAgentCreatedTasks = showAgentCreatedTasks || routineTasksOnly;
const originKind = routineTasksOnly ? "routine_execution" : undefined;
const handleRoutineTasksOnlyChange = useCallback((checked: boolean) => {
setSearchParams(current => {
const next = new URLSearchParams(current);
if (syncedSearch) next.set("q", syncedSearch);
else next.delete("q");
if (checked) next.set("originKind", "routine_execution");
else next.delete("originKind");
return next;
}, { replace: true });
}, [setSearchParams, syncedSearch]);
const participantAgentId = searchParams.get("participantAgentId") ?? undefined;
const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0);
const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined;
@@ -273,7 +286,8 @@ function OrganizationIssues({
workspaceIdFilter ?? "__all__",
streamlinedUiEnabled ? "index" : "compact",
"with-routine-executions",
showAgentCreatedTasks,
includeAgentCreatedTasks,
originKind,
"infinite",
issuePageSize,
],
@@ -282,7 +296,8 @@ function OrganizationIssues({
participantAgentId,
workspaceId: workspaceIdFilter,
includeRoutineExecutions: true,
includeAgentCreatedTasks: showAgentCreatedTasks,
includeAgentCreatedTasks,
originKind,
limit: issuePageSize,
offset: pageParam,
sortField: "updated",
@@ -341,10 +356,12 @@ function OrganizationIssues({
initialSearch={syncedSearch}
onSearchChange={handleSearchChange}
enableAgentCreatedVisibilityFilter
routineTasksOnly={routineTasksOnly}
onRoutineTasksOnlyChange={handleRoutineTasksOnlyChange}
hasMoreIssues={hasMoreServerIssues}
onLoadMoreIssues={loadMoreServerIssues}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined}
searchFilters={participantAgentId || workspaceIdFilter || originKind ? { participantAgentId, workspaceId: workspaceIdFilter, originKind } : undefined}
/>
);
}
+15
View File
@@ -1084,6 +1084,21 @@ describe("Routines page", () => {
});
});
it("links View all tasks to the routine-filtered task list", async () => {
routinesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(<QueryClientProvider client={queryClient}><Routines /></QueryClientProvider>);
await flush();
});
const link = Array.from(container.querySelectorAll("a")).find(anchor => anchor.textContent === "View all tasks");
expect(link?.getAttribute("href")).toBe("/issues?view=all&originKind=routine_execution");
expect(container.textContent).not.toContain("View all runs");
await act(() => root.unmount());
queryClient.clear();
});
it("keeps the list focused on routines and links run history to Audit", async () => {
currentSearch = "tab=runs";
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
+1 -1
View File
@@ -852,7 +852,7 @@ export function Routines() {
onGroupChange={groupBy => updateRoutineView({ groupBy, collapsedGroups: [] })}
onNewFolder={routineViewState.groupBy === "folder" && !hasRoutineFolders ? () => openCreateFolder() : undefined}
onCreateRoutine={openCreateRoutine}
runsHref={streamlinedUiEnabled ? auditSectionHref("runs", {}) : undefined}
tasksHref="/issues?view=all&originKind=routine_execution"
selectMode={selectMode} onToggleSelect={showFolderRail ? () => setSelectMode(current => !current) : undefined}
/>
@@ -60,7 +60,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
args: { taskTitle: "Prepare my board briefing" },
@@ -64,7 +64,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
render: () => <PrivacyPage />,
@@ -32,7 +32,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
} satisfies Meta;
@@ -284,7 +284,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
} satisfies Meta<typeof BlockedTabSurface>;
@@ -688,7 +688,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
} satisfies Meta<typeof StateMatrixStory>;
@@ -890,7 +890,7 @@ const meta = {
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Focused creation-visibility stories mount the production collection with simulated hierarchy reads and assert hidden roots, opt-in inclusion, and the unchecked filter control. Creation visibility examples and fixture setup now live in a support module so the surface matrix stays below 1000 lines; exported story IDs are unchanged. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
"notes": "Creation visibility now defaults to hiding automated independent tasks, with a Show agent-created tasks control and inbox participation exceptions. Source alignment reviewed; these fixture stories do not establish live server pagination or authorization. Focused creation-visibility stories mount the production collection with simulated hierarchy reads and assert hidden roots, opt-in inclusion, and the unchecked filter control. Creation visibility examples and fixture setup now live in a support module so the surface matrix stays below 1000 lines; exported story IDs are unchanged. Creation visibility now shares the subscribed preference across modern and legacy controls; hierarchy child reads share a bounded rendering budget. Source alignment inspected; server behavior is tested separately. Hierarchy fetch and rendering budgets are separate; loaded context remains during paging. The large-tree UI regression exercises delayed reads in both list interfaces. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work."
},
},
} satisfies Meta<typeof IssueManagementStories>;
@@ -121,7 +121,7 @@ function RoutinesToolbarSpecimen() {
return <Specimen title="Routines toolbar" source="RoutinesToolbar · shared routine search, sorting, grouping, folder creation and run history controls used by the Routines page.">
<RoutinesToolbar query={query} onQueryChange={setQuery} sortField={field} sortDir={direction} groupBy={group}
onSortChange={(nextField, nextDirection) => { setField(nextField); setDirection(nextDirection); }} onGroupChange={setGroup}
runsHref="/activity/runs" onNewFolder={group === "folder" ? () => setAction("New folder requested · local preview only") : undefined}
tasksHref="/issues?view=all&originKind=routine_execution" onNewFolder={group === "folder" ? () => setAction("New folder requested · local preview only") : undefined}
onCreateRoutine={() => setAction("Create routine requested · local preview only")} />
<output className="block text-xs" aria-live="polite">Search: {query || "All routines"} · Sort: {field} · Direction: {direction} · Group: {group}{action ? ` · ${action}` : ""}</output>
</Specimen>;
@@ -96,7 +96,7 @@ const filter: Story = {
await expect(await canvas.findByText("Human request")).toBeVisible();
canvas.getByTitle("Filter").click();
const page = within(canvasElement.ownerDocument.body);
await expect(await page.findByRole("checkbox", { name: /show agent-created tasks/ })).not.toBeChecked();
await expect(await page.findByRole("checkbox", { name: /Show agent-created tasks/ })).not.toBeChecked();
},
};