diff --git a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx index ff025541..81c4b769 100644 --- a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx +++ b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.test.tsx @@ -80,6 +80,36 @@ describe("TaskListPanel", () => { expect(screen.queryByText(/^Completed/)).not.toBeInTheDocument(); }); + it("ignores the status filter when embedded (only the search box is exposed there)", () => { + // A stale status filter from the full-size screen must not silently hide + // tasks in the embedded column, which has no control to see or clear it. + renderWithMantine( + , + ); + expect(screen.getByText("Active (2)")).toBeInTheDocument(); + expect(screen.getByText("Completed (2)")).toBeInTheDocument(); + }); + + it("still applies the search text when embedded", () => { + renderWithMantine( + , + ); + // Status filter ignored, but the text filter narrows to the failed task. + expect(screen.getByText("Completed (1)")).toBeInTheDocument(); + expect(screen.queryByText(/^Active/)).not.toBeInTheDocument(); + }); + it("filters tasks by search text matching statusMessage", () => { renderWithMantine( void; onClearCompleted: () => void; + /** + * True when rendered inside the monitoring sidebar. Switches the scroll region + * from the viewport-height calc to filling its flex parent (via + * `EmbeddableScrollArea`), so it fits below the column's controls without + * viewport math (#1616). + */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -56,9 +56,17 @@ function isActiveStatus(status: TaskStatus): boolean { function matchesFilters( task: Task, searchText: string, - statusFilter?: TaskStatus, + statusFilter: TaskStatus | undefined, + // The embedded column exposes only the shared search box (its status filter + // lives in the full-size sidebar, which the embedded view drops), so it + // applies the text filter but skips the status filter — mirrors + // LogStreamPanel's `ignoreLevels` etc. so a stale status filter can't + // silently hide tasks with no visible control to clear it (#1616). + ignoreStatus: boolean, ): boolean { - if (statusFilter && task.status !== statusFilter) return false; + if (!ignoreStatus && statusFilter && task.status !== statusFilter) { + return false; + } if (searchText) { const term = searchText.toLowerCase(); const searchable = @@ -75,13 +83,17 @@ export function TaskListPanel({ statusFilter, onCancel, onClearCompleted, + embedded = false, }: TaskListPanelProps) { const viewportRef = useScrollMemory("tasks-list"); const [compact, setCompact] = useState(false); const filteredTasks = useMemo( - () => tasks.filter((t) => matchesFilters(t, searchText, statusFilter)), - [tasks, searchText, statusFilter], + () => + tasks.filter((t) => + matchesFilters(t, searchText, statusFilter, embedded), + ), + [tasks, searchText, statusFilter, embedded], ); const activeTasks = useMemo( @@ -111,10 +123,7 @@ export function TaskListPanel({ {!hasResults ? ( No tasks ) : ( - + {activeTasks.length > 0 && ( <> @@ -153,7 +162,7 @@ export function TaskListPanel({ )} - + )} ); diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx index 41b308bc..e862e1f6 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.test.tsx @@ -54,6 +54,15 @@ describe("TasksScreen", () => { ); }); + it("drops the controls sidebar when embedded, keeping the list", () => { + renderWithMantine(); + // The list panel still renders (its "Tasks" heading is present)... + expect(screen.getAllByText("Tasks").length).toBeGreaterThan(0); + // ...but the controls sidebar (Refresh + Search) is not rendered. + expect(screen.queryByRole("button", { name: "Refresh" })).toBeNull(); + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + }); + it("emits the cleared status filter through onUiChange", async () => { const user = userEvent.setup(); const onUiChange = vi.fn(); diff --git a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx index 379fc7a5..0a9a1b5e 100644 --- a/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx +++ b/clients/web/src/components/screens/TasksScreen/TasksScreen.tsx @@ -12,6 +12,12 @@ export interface TasksScreenProps { onRefresh: () => void; onClearCompleted: () => void; onCancel: (taskId: string) => void; + /** + * True when rendered inside the monitoring sidebar: the screen fills its + * parent's height (instead of the viewport calc) and drops the filter + * sidebar so the narrow column is list-only. Mirrors `LoggingScreen`. + */ + embedded?: boolean; } // Search + status filter — controlled by the parent (App) as one object so they @@ -46,23 +52,29 @@ export function TasksScreen({ onRefresh, onClearCompleted, onCancel, + embedded = false, }: TasksScreenProps) { const { search, statusFilter } = ui; return ( - - - - onUiChange({ ...ui, search: value })} - onStatusFilterChange={(value) => - onUiChange({ ...ui, statusFilter: value }) - } - onRefresh={onRefresh} - /> - - + // Embedded fills the monitoring sidebar column (100%); standalone keeps the + // ScreenLayout's default full-screen height. Only override `h` when embedded + // — passing `h={undefined}` would clobber the default (withProps spreads). + + {embedded ? null : ( + + + onUiChange({ ...ui, search: value })} + onStatusFilterChange={(value) => + onUiChange({ ...ui, statusFilter: value }) + } + onRefresh={onRefresh} + /> + + + )} ); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 2f494904..9be90112 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1874,6 +1874,9 @@ describe("InspectorView", () => { expect( within(header).queryByRole("radio", { name: "Network" }), ).toBeNull(); + // Tasks (#1680) joins the monitor group, so it also leaves the header + // (the server advertises the `tasks` capability via allCapabilities). + expect(within(header).queryByRole("radio", { name: "Tasks" })).toBeNull(); // ...and a non-monitor tab still sits in the header. expect( within(header).getByRole("radio", { name: "Tools" }), @@ -1884,6 +1887,8 @@ describe("InspectorView", () => { expect( screen.getByRole("radio", { name: "Network" }), ).toBeInTheDocument(); + // Tasks is available as a column panel. + expect(screen.getByRole("radio", { name: "Tasks" })).toBeInTheDocument(); }); it("returns the monitor group to the header when the column is closed", async () => { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index d8d335cd..0e397a26 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -152,6 +152,7 @@ function useListCompact( } const SERVERS_TAB = INSPECTOR_SERVERS_TAB; +const TASKS_TAB = "Tasks"; const LOGS_TAB = "Logs"; const PROTOCOL_TAB = "Protocol"; const NETWORK_TAB = "Network"; @@ -163,7 +164,7 @@ const ALL_TABS: string[] = [ "Tools", "Prompts", "Resources", - "Tasks", + TASKS_TAB, LOGS_TAB, PROTOCOL_TAB, NETWORK_TAB, @@ -174,9 +175,11 @@ const ALL_TABS: string[] = [ // a group action: opening the column removes all *available* monitor tabs from // the header and hosts them in the column instead. Console (#1621) is the // stdio server's stderr stream — mutually exclusive with Network (Console shows -// for stdio, Network for HTTP), but both live in the monitor group. -type MonitorTab = "Logs" | "Protocol" | "Network" | "Console"; +// for stdio, Network for HTTP), but both live in the monitor group. Tasks +// (#1680) joins the group so a live task list can be watched beside any screen. +type MonitorTab = "Tasks" | "Logs" | "Protocol" | "Network" | "Console"; const MONITOR_TABS: string[] = [ + TASKS_TAB, LOGS_TAB, PROTOCOL_TAB, NETWORK_TAB, @@ -185,6 +188,7 @@ const MONITOR_TABS: string[] = [ function isMonitorTab(tab: string): tab is MonitorTab { return ( + tab === TASKS_TAB || tab === LOGS_TAB || tab === PROTOCOL_TAB || tab === NETWORK_TAB || @@ -1069,12 +1073,28 @@ export function InspectorView({ sortDirection: consoleSort, onSortChange: setConsoleSort, }; + const tasksScreenProps = { + tasks, + progressByTaskId, + ui: tasksUi, + onUiChange: onTasksUiChange, + onRefresh: onRefreshTasks, + onClearCompleted: onClearCompletedTasks, + onCancel: onCancelTask, + }; // Embedded instances for the pinned column, keyed by tab. MonitoringScreen // renders only the active one; the rest are unmounted element values. Each // screen's search field is overridden with the shared column search so it // filters whichever tab is showing, and carries over as tabs change. const monitorScreens: Record = { + [TASKS_TAB]: ( + + ), [LOGS_TAB]: ( - - + +