web: Add Tasks to the monitoring sidebar (#1681)
* web: add Tasks to the monitoring sidebar (#1680) Make Tasks a pinnable panel in the monitoring sidebar alongside Logs / Protocol / Network / Console, so a live task list can be watched beside any screen. - InspectorView: add TASKS_TAB, extend the MonitorTab union / MONITOR_TABS / isMonitorTab, and add a monitorScreens[TASKS_TAB] entry built from a shared tasksScreenProps object (so the embedded and full-size instances can't drift), with the shared column search injected into its ui.search. The full-size Tasks screen now uses the same shared props. - TasksScreen / TaskListPanel: add an embedded prop mirroring LoggingScreen/LogStreamPanel — when embedded the screen fills its column height and drops the controls sidebar, and TaskListPanel scrolls via EmbeddableScrollArea (whose full-size cap matches the old viewport calc, so the standalone screen is unchanged). Tasks is capability-gated: it only appears in the monitor group when the server advertises the tasks capability (via availableTabs), so no change is needed to the failed-connect subset. Closes #1680 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * web: embedded task list ignores the status filter (review #1681) Mirror the sibling monitor panels: the embedded column exposes only the shared search box (its status filter lives in the full-size sidebar, which the embedded view drops), so a stale status filter must not silently hide tasks with no visible control to clear it. Thread an `ignoreStatus` flag into TaskListPanel.matchesFilters, set from the `embedded` prop — matching LogStreamPanel's `ignoreLevels`, NetworkStreamPanel's `ignoreCategories`, and ProtocolListPanel's `ignoreDirectionAndMethod` (#1616). Add tests for the ignored-status / still-applied-search embedded behavior, plus a direct assertion that the Tasks radio appears in the monitor column. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d75ef5f8e
commit
590359abc5
6 changed files with 123 additions and 45 deletions
|
|
@ -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(
|
||||
<TaskListPanel
|
||||
{...baseProps}
|
||||
tasks={sampleTasks}
|
||||
statusFilter="working"
|
||||
embedded
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Active (2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed (2)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still applies the search text when embedded", () => {
|
||||
renderWithMantine(
|
||||
<TaskListPanel
|
||||
{...baseProps}
|
||||
tasks={sampleTasks}
|
||||
statusFilter="working"
|
||||
searchText="Connection"
|
||||
embedded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<TaskListPanel
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { Task, TaskStatus } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { TaskCard } from "../TaskCard/TaskCard";
|
||||
import type { TaskProgress } from "../TaskCard/TaskCard";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
|
|
@ -21,6 +14,13 @@ export interface TaskListPanelProps {
|
|||
statusFilter?: TaskStatus;
|
||||
onCancel: (taskId: string) => 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 ? (
|
||||
<EmptyState>No tasks</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{activeTasks.length > 0 && (
|
||||
<>
|
||||
|
|
@ -153,7 +162,7 @@ export function TaskListPanel({
|
|||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@ describe("TasksScreen", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("drops the controls sidebar when embedded, keeping the list", () => {
|
||||
renderWithMantine(<TasksScreen {...baseProps} embedded />);
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ScreenLayout>
|
||||
<Sidebar>
|
||||
<SidebarCard>
|
||||
<TaskControls
|
||||
searchText={search}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
|
||||
onStatusFilterChange={(value) =>
|
||||
onUiChange({ ...ui, statusFilter: value })
|
||||
}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</SidebarCard>
|
||||
</Sidebar>
|
||||
// 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).
|
||||
<ScreenLayout {...(embedded ? { h: "100%" } : {})}>
|
||||
{embedded ? null : (
|
||||
<Sidebar>
|
||||
<SidebarCard>
|
||||
<TaskControls
|
||||
searchText={search}
|
||||
statusFilter={statusFilter}
|
||||
onSearchChange={(value) => onUiChange({ ...ui, search: value })}
|
||||
onStatusFilterChange={(value) =>
|
||||
onUiChange({ ...ui, statusFilter: value })
|
||||
}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</SidebarCard>
|
||||
</Sidebar>
|
||||
)}
|
||||
<TaskListPanel
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
|
|
@ -70,6 +82,7 @@ export function TasksScreen({
|
|||
statusFilter={statusFilter}
|
||||
onCancel={onCancel}
|
||||
onClearCompleted={onClearCompleted}
|
||||
embedded={embedded}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string, ReactNode> = {
|
||||
[TASKS_TAB]: (
|
||||
<TasksScreen
|
||||
{...tasksScreenProps}
|
||||
ui={{ ...tasksUi, search: monitorSearch }}
|
||||
embedded
|
||||
/>
|
||||
),
|
||||
[LOGS_TAB]: (
|
||||
<LoggingScreen
|
||||
{...loggingScreenProps}
|
||||
|
|
@ -1234,16 +1254,8 @@ export function InspectorView({
|
|||
onCompactChange={setResourcesCompact}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Tasks"}>
|
||||
<TasksScreen
|
||||
tasks={tasks}
|
||||
progressByTaskId={progressByTaskId}
|
||||
ui={tasksUi}
|
||||
onUiChange={onTasksUiChange}
|
||||
onRefresh={onRefreshTasks}
|
||||
onClearCompleted={onClearCompletedTasks}
|
||||
onCancel={onCancelTask}
|
||||
/>
|
||||
<ScreenStage active={activeTab === TASKS_TAB}>
|
||||
<TasksScreen {...tasksScreenProps} />
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === LOGS_TAB}>
|
||||
<LoggingScreen {...loggingScreenProps} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue