From b31ec0a2dc079e32272a8f618425fe92b68bbaec Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:28:58 -0500 Subject: [PATCH] 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 --- doc/PRODUCT.md | 9 ++- doc/SPEC-implementation.md | 9 ++- maps/agent-access-map/tasks.md | 4 +- maps/feature-map/inbox-search.md | 2 +- maps/feature-map/routines.md | 4 +- maps/feature-map/tasks.md | 4 +- ui/src/components/IssueFilterPanel.tsx | 19 ++++-- .../components/IssueFiltersPopover.test.tsx | 37 +++++++++++- ui/src/components/IssueFiltersPopover.tsx | 23 ++++++-- ui/src/components/IssuesList.test.tsx | 45 +++++++++++++- ui/src/components/IssuesList.tsx | 29 +++++---- ui/src/components/LegacyIssuesList.tsx | 30 ++++++---- ui/src/components/RoutinesToolbar.tsx | 6 +- ui/src/lib/inbox.test.ts | 9 +++ ui/src/lib/issue-filters.test.ts | 12 ++++ ui/src/lib/issue-filters.ts | 22 ++++++- ui/src/lib/task-views.ts | 1 + ui/src/pages/CoordinatorTasks.test.tsx | 28 ++++++++- ui/src/pages/CoordinatorTasks.tsx | 33 +++++++---- ui/src/pages/DesignSandboxTasks.tsx | 1 + .../pages/Issues.coordinator-routing.test.tsx | 4 +- ui/src/pages/Issues.routine-filter.test.tsx | 59 +++++++++++++++++++ ui/src/pages/Issues.tsx | 25 ++++++-- ui/src/pages/Routines.test.tsx | 15 +++++ ui/src/pages/Routines.tsx | 2 +- .../private-tasks/01-creation.stories.tsx | 2 +- .../06-full-product-pages.stories.tsx | 2 +- .../private-tasks/07-user-stories.stories.tsx | 2 +- .../inbox-search/blocked-inbox.stories.tsx | 2 +- .../tasks/external-objects.stories.tsx | 2 +- .../tasks/issue-management.stories.tsx | 2 +- .../design-proposals/ToolbarsPreview.tsx | 2 +- .../support/issue-creation-visibility.tsx | 2 +- 33 files changed, 370 insertions(+), 78 deletions(-) create mode 100644 ui/src/pages/Issues.routine-filter.test.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 2e2091de5bf..d9581ac22ac 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -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 diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e397c78bcc4..77dae972cb4 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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 diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index 656a073bcef..681376b7a38 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -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. diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 136f589b985..803f172f833 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -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. diff --git a/maps/feature-map/routines.md b/maps/feature-map/routines.md index f401996bdf2..75f8e21a926 100644 --- a/maps/feature-map/routines.md +++ b/maps/feature-map/routines.md @@ -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 diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index cfac7d124d6..475c764ec14 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -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. diff --git a/ui/src/components/IssueFilterPanel.tsx b/ui/src/components/IssueFilterPanel.tsx index 8dd1c3153de..e039d78c95a 100644 --- a/ui/src/components/IssueFilterPanel.tsx +++ b/ui/src/components/IssueFilterPanel.tsx @@ -9,7 +9,7 @@ import { StatusIcon } from "./StatusIcon"; import { TruncatedText } from "./TruncatedText"; import { issueFilterLabel, issueStatusOrder, searchIssueFilterOptions, toggleIssueFilterValue } from "@/lib/issue-filters"; -type PanelProps = Pick, "state" | "onChange" | "activeFilterCount" | "agents" | "projects" | "labels" | "creators" | "currentUserId"> & { onClear: () => void }; +type PanelProps = Pick, "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 ; } -/** 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 <> Filters{activeFilterCount ? : null}
- - {issueStatusOrder.map(status => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={} />)} - +
+ + {issueStatusOrder.map(status => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })} icon={} />)} + + {enableLiveRunsFilter || enableAgentCreatedVisibilityFilter || enableRoutineTasksFilter ? + {enableLiveRunsFilter ? onChange({ liveOnly: !state.liveOnly })} /> : null} + {enableAgentCreatedVisibilityFilter ? onChange({ showAgentCreatedTasks: !state.showAgentCreatedTasks })} /> : null} + {enableRoutineTasksFilter ? onChange({ routineTasksOnly: !state.routineTasksOnly })} /> : null} + : null} +
{visibleProjects.map(project => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })} />)} {!visibleProjects.length ?

{queries.projects ? "No projects match." : "No projects available."}

: null} diff --git a/ui/src/components/IssueFiltersPopover.test.tsx b/ui/src/components/IssueFiltersPopover.test.tsx index 25d7bc42fd9..1774daad2c6 100644 --- a/ui/src/components/IssueFiltersPopover.test.tsx +++ b/ui/src/components/IssueFiltersPopover.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/checkbox", () => ({ - Checkbox: ({ checked }: { checked?: boolean }) => , + Checkbox: ({ checked, onCheckedChange, ...props }: { checked?: boolean; onCheckedChange?: (checked: boolean) => void }) => 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()); + 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()); + expect(container.textContent).not.toContain("Live runs only"); + expect(container.textContent).not.toContain("Visibility"); + act(() => root.render()); + 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); }); diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 0f29d2b5e69..4c1733d30f6 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -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 ? :
+ {refreshed ? :
Filters {activeFilterCount > 0 ? ( @@ -516,25 +520,32 @@ export function IssueFiltersPopover({
) : null} -
+ {enableLiveRunsFilter || enableAgentCreatedVisibilityFilter || enableRoutineTasksFilter ?
Visibility - : null} {enableAgentCreatedVisibilityFilter ? ( ) : null} -
+ {enableRoutineTasksFilter ? : null} +
: null}
} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index edee260d51b..4f6e5abb916 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -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( 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('[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); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1af75084e93..7d37b2f3a11 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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; 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(() => + const [savedViewState, setViewState] = useState(() => 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) => { + 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({ /> void; initialSearch?: string; searchFilters?: Omit; 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(() => + const [savedViewState, setViewState] = useState(() => 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) => { + 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({ /> 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
{onToggleSelect && } - {runsHref && } + {tasksHref && } {onNewFolder && }
diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 367eeee9acb..a0b453b041e 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -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, }, }); }); diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index e8c26f54455..f0f710b5757 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -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); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index 9ef192e814c..aede1f21aab 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -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, + 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; diff --git a/ui/src/lib/task-views.ts b/ui/src/lib/task-views.ts index 3fecf249416..3e26a353519 100644 --- a/ui/src/lib/task-views.ts +++ b/ui/src/lib/task-views.ts @@ -135,6 +135,7 @@ export const ORGANIZATION_SCOPED_PARAMS = [ "assignee", "workspace", "participantAgentId", + "originKind", "q", ] as const; diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 4426202ae58..3ec1d10ce68 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -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 }) => <> + + @@ -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) {} diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 5864b927966..3ab3a81d536 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -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) => { 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() { 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 ?
{error instanceof Error ? error.message : "Could not load tasks"}
: null} {rowDetailsError ?
Task details couldn’t load.
: null} diff --git a/ui/src/pages/DesignSandboxTasks.tsx b/ui/src/pages/DesignSandboxTasks.tsx index 657a40b4629..ca6ae1fc9c2 100644 --- a/ui/src/pages/DesignSandboxTasks.tsx +++ b/ui/src/pages/DesignSandboxTasks.tsx @@ -92,6 +92,7 @@ export function DesignSandboxTasks() { 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 ?
{error instanceof Error ? error.message : "Could not load tasks"}
: null} {tasks.isPending ?

Loading tasks…

: <> diff --git a/ui/src/pages/Issues.coordinator-routing.test.tsx b/ui/src/pages/Issues.coordinator-routing.test.tsx index 0210f238089..a1219de0c35 100644 --- a/ui/src/pages/Issues.coordinator-routing.test.tsx +++ b/ui/src/pages/Issues.coordinator-routing.test.tsx @@ -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(); } }); diff --git a/ui/src/pages/Issues.routine-filter.test.tsx b/ui/src/pages/Issues.routine-filter.test.tsx new file mode 100644 index 00000000000..3f5c27ff718 --- /dev/null +++ b/ui/src/pages/Issues.routine-filter.test.tsx @@ -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; +}) => <> + + + {issues.map(issue => issue.id).join(",")} +

{useLocation().search}

+ })); + +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()); + 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(); + } +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index d26e66a6883..61d9da5f0e1 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -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} /> ); } diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 7364fb250e0..53c75af751f 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -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(); + 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" })]); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 9133842b99a..35c2b73d088 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -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} /> diff --git a/ui/storybook/stories/access/private-tasks/01-creation.stories.tsx b/ui/storybook/stories/access/private-tasks/01-creation.stories.tsx index e1bdf9a1350..a4340932e73 100644 --- a/ui/storybook/stories/access/private-tasks/01-creation.stories.tsx +++ b/ui/storybook/stories/access/private-tasks/01-creation.stories.tsx @@ -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" }, diff --git a/ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx b/ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx index c9fd4e6828a..53d7413fe88 100644 --- a/ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx +++ b/ui/storybook/stories/access/private-tasks/06-full-product-pages.stories.tsx @@ -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: () => , diff --git a/ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx b/ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx index d26304ac1df..950db2b8f28 100644 --- a/ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx +++ b/ui/storybook/stories/access/private-tasks/07-user-stories.stories.tsx @@ -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; diff --git a/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx index f541d6ed518..b1352168bc8 100644 --- a/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx +++ b/ui/storybook/stories/inbox-search/blocked-inbox.stories.tsx @@ -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; diff --git a/ui/storybook/stories/tasks/external-objects.stories.tsx b/ui/storybook/stories/tasks/external-objects.stories.tsx index 6694e5b2771..5dd89820273 100644 --- a/ui/storybook/stories/tasks/external-objects.stories.tsx +++ b/ui/storybook/stories/tasks/external-objects.stories.tsx @@ -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; diff --git a/ui/storybook/stories/tasks/issue-management.stories.tsx b/ui/storybook/stories/tasks/issue-management.stories.tsx index c53fb3540e9..32eeb9044a7 100644 --- a/ui/storybook/stories/tasks/issue-management.stories.tsx +++ b/ui/storybook/stories/tasks/issue-management.stories.tsx @@ -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; diff --git a/ui/storybook/support/design-proposals/ToolbarsPreview.tsx b/ui/storybook/support/design-proposals/ToolbarsPreview.tsx index 65befde48a8..1b172a0afeb 100644 --- a/ui/storybook/support/design-proposals/ToolbarsPreview.tsx +++ b/ui/storybook/support/design-proposals/ToolbarsPreview.tsx @@ -121,7 +121,7 @@ function RoutinesToolbarSpecimen() { return { 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")} /> Search: {query || "All routines"} · Sort: {field} · Direction: {direction} · Group: {group}{action ? ` · ${action}` : ""} ; diff --git a/ui/storybook/support/issue-creation-visibility.tsx b/ui/storybook/support/issue-creation-visibility.tsx index 1c6f81cd3e4..02fd4d93db9 100644 --- a/ui/storybook/support/issue-creation-visibility.tsx +++ b/ui/storybook/support/issue-creation-visibility.tsx @@ -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(); }, };