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]: (
-
-
+
+