mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
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:
1 parent
5c807d11fa
commit
b31ec0a2dc
33 files changed
+370
-78
No files matched your search
+8
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -135,6 +135,7 @@ export const ORGANIZATION_SCOPED_PARAMS = [
|
||||
"assignee",
|
||||
"workspace",
|
||||
"participantAgentId",
|
||||
"originKind",
|
||||
"q",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" })]);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user