From 8d507334aba6128e0fba5d00b7638470280d1af6 Mon Sep 17 00:00:00 2001 From: liangfung Date: Tue, 1 Sep 2026 00:37:53 +0800 Subject: [PATCH 1/8] feat(vscode-webui): add unified background job and terminal manage panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate Pochi-managed background tasks and active terminals into a single, high-visibility dropdown menu in the top-right of the chat view to simplify monitoring and control. 🤖 Generated with [Pochi](https://getpochi.com) | [Task](https://app.getpochi.com/share/p-30e08c1b7a2944839da7090bb9b04770) Co-Authored-By: Pochi --- packages/common/src/base/environment.ts | 8 + .../src/components/job-control-button.tsx | 52 +++ .../__stories__/manage-panel.stories.tsx | 130 ++++++++ .../background-task-debug-panel.test.tsx | 69 +++- .../background-task-debug-panel.tsx | 186 ++++------- .../chat/components/manage-panel.test.tsx | 291 +++++++++++++++++ .../features/chat/components/manage-panel.tsx | 306 ++++++++++++++++++ .../chat/components/panel-section.tsx | 105 ++++++ .../features/chat/components/status-dot.tsx | 37 +++ .../src/features/chat/hooks/use-job-list.ts | 19 ++ .../features/chat/lib/build-job-list.test.ts | 248 ++++++++++++++ .../src/features/chat/lib/build-job-list.ts | 175 ++++++++++ .../vscode-webui/src/features/chat/page.tsx | 10 +- .../vscode-webui/src/features/chat/styles.ts | 5 +- .../components/command-execution-panel.tsx | 104 +----- .../vscode-webui/src/i18n/locales/en.json | 9 + .../vscode-webui/src/i18n/locales/jp.json | 9 + .../vscode-webui/src/i18n/locales/ko.json | 9 + .../vscode-webui/src/i18n/locales/zh.json | 9 + .../src/lib/hooks/use-open-background-job.ts | 39 +++ .../integrations/terminal/terminal-state.ts | 42 ++- 21 files changed, 1625 insertions(+), 237 deletions(-) create mode 100644 packages/vscode-webui/src/components/job-control-button.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/manage-panel.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/panel-section.tsx create mode 100644 packages/vscode-webui/src/features/chat/components/status-dot.tsx create mode 100644 packages/vscode-webui/src/features/chat/hooks/use-job-list.ts create mode 100644 packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts create mode 100644 packages/vscode-webui/src/features/chat/lib/build-job-list.ts create mode 100644 packages/vscode-webui/src/lib/hooks/use-open-background-job.ts diff --git a/packages/common/src/base/environment.ts b/packages/common/src/base/environment.ts index f4af81d06d..f56b4d2020 100644 --- a/packages/common/src/base/environment.ts +++ b/packages/common/src/base/environment.ts @@ -55,6 +55,14 @@ export const Environment = z.object({ z.object({ name: z.string().describe("The name of the terminal."), isActive: z.boolean().describe("Whether the terminal is active."), + isRunning: z + .boolean() + .optional() + .describe("Whether a command is currently running in it."), + lastCommand: z + .string() + .optional() + .describe("The most recent command run in the terminal."), backgroundJobId: z .string() .optional() diff --git a/packages/vscode-webui/src/components/job-control-button.tsx b/packages/vscode-webui/src/components/job-control-button.tsx new file mode 100644 index 0000000000..c6e3c12d69 --- /dev/null +++ b/packages/vscode-webui/src/components/job-control-button.tsx @@ -0,0 +1,52 @@ +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { FC, ReactNode } from "react"; + +/** + * The badge in front of a job/terminal row: opens the live terminal, or -- + * once that terminal is gone -- its recorded output file. + */ +export const JobControlButton: FC<{ + label: string; + isActive?: boolean; + /** Nothing left to open: keep the badge, drop the interaction. */ + inert?: boolean; + onClick: () => void; + children: ReactNode; +}> = ({ label, isActive, inert, onClick, children }) => ( + + + {inert ? ( + // A plain span rather than a disabled button: disabled buttons swallow + // pointer events, which would hide the tooltip explaining why nothing + // can be opened anymore. + + {children} + + ) : ( + + )} + + + {label} + + +); diff --git a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx new file mode 100644 index 0000000000..bd188be85b --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx @@ -0,0 +1,130 @@ +import type { BackgroundJobNotification } from "@getpochi/common"; +import type { Message } from "@getpochi/livekit"; +import { signal } from "@preact/signals-core"; +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import type { TerminalSnapshot } from "../../lib/build-job-list"; +import { ManagePanel } from "../manage-panel"; + +const meta = { + title: "Features/Chat/ManagePanel", + component: ManagePanel, + args: { + taskId: "story-task", + messages: [], + }, + decorators: [ + (Story) => ( + // The panel is positioned by `page.tsx` in the app, so give it a + // stand-in corner here. +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Nothing running: the chip stays put so the panel remains discoverable. */ +export const Empty: Story = { + play: openPanel, +}; + +export const WithJobs: Story = { + args: { + messages: [ + message([ + executeCommandPart("bgjob-cmd-1", "bun run dev"), + executeCommandPart("bgjob-cmd-2", "bun run build"), + notificationPart(notification("bgjob-cmd-2", "completed")), + executeCommandPart("bgjob-cmd-3", "bun run test"), + notificationPart(notification("bgjob-cmd-3", "failed")), + ]), + ], + }, + decorators: [ + withTerminals([ + terminal("bgjob-cmd-1", { name: "Pochi: bun run dev" }), + terminal("term-1", { name: "zsh", isActive: true }), + terminal("term-2", { name: "zsh" }), + ]), + ], + play: openPanel, +}; + +async function openPanel({ + canvasElement, +}: { canvasElement: HTMLElement }): Promise { + const canvas = within(canvasElement); + const toggle = canvas.getByTestId("manage-panel-toggle"); + await userEvent.click(toggle); + await expect(toggle).toHaveAttribute("data-state", "open"); +} + +/** + * Seeds the terminal query so the panel sees live terminals without a host. + */ +function withTerminals(terminals: TerminalSnapshot[]) { + const data = { + terminals: signal(terminals), + openBackgroundJobTerminal: () => {}, + }; + + return (Story: React.ComponentType) => { + const queryClient = useQueryClient(); + // Seed once, before the panel below mounts and fires the query. + useState(() => { + queryClient.setQueryData(["visibleTerminals"], data); + return null; + }); + return ; + }; +} + +function message(parts: unknown[]): Message { + return { id: "message-1", role: "assistant", parts } as unknown as Message; +} + +function executeCommandPart(backgroundJobId: string, command: string) { + return { + type: "tool-executeCommand", + state: "output-available", + input: { command, background: true }, + output: { _meta: { backgroundJobId } }, + }; +} + +function notificationPart(data: BackgroundJobNotification) { + return { type: "data-background-job-notification", data }; +} + +function terminal( + backgroundJobId: string, + { name, isActive = false }: { name: string; isActive?: boolean }, +): TerminalSnapshot { + return { + name, + isActive, + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + }; +} + +function notification( + backgroundJobId: string, + status: BackgroundJobNotification["status"], +): BackgroundJobNotification { + return { + notificationId: `${backgroundJobId}:terminal`, + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + command: `run ${backgroundJobId}`, + status, + summary: `Background command "${backgroundJobId}" ${status}`, + finishedAt: Date.now(), + }; +} diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx index b78c6d8b84..41d5357a4d 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx @@ -1,8 +1,11 @@ // @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BackgroundTaskDebugPanel } from "./background-task-debug-panel"; +import { + BackgroundTaskDetail, + BackgroundTaskList, +} from "./background-task-debug-panel"; const task = { id: "task-1", @@ -14,6 +17,11 @@ const task = { }; let messageRows: Array<{ data: unknown }> = []; +let backgroundTasks: unknown[] = [task]; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); vi.mock("@/components/task-thread", () => ({ TaskThread: ({ @@ -45,16 +53,6 @@ vi.mock("@/components/ui/button", () => ({ ), })); -vi.mock("@/components/ui/hover-card", () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, -})); - -vi.mock("@/features/settings", () => ({ - useIsDevMode: () => [true], -})); - vi.mock("@/lib/hooks/use-background-task-state", () => ({ useBackgroundTaskState: () => ({ backgroundTaskState: { @@ -78,7 +76,7 @@ vi.mock("@getpochi/livekit", () => ({ vi.mock("@/lib/use-default-store", () => ({ useDefaultStore: () => ({ useQuery: (query: string) => { - if (query === "backgroundTasks") return [task]; + if (query === "backgroundTasks") return backgroundTasks; if (query === "task") return task; if (query === "messages") return messageRows; return []; @@ -86,8 +84,28 @@ vi.mock("@/lib/use-default-store", () => ({ }), })); +/** Mirrors how the manage panel wires the list to the detail drawer. */ +function BackgroundTaskDebugSection() { + const [selectedTaskId, setSelectedTaskId] = useState(null); + + return ( + <> + + {selectedTaskId && ( + setSelectedTaskId(null)} + /> + )} + + ); +} + function openTaskDetail() { - render(); + render(); fireEvent.click(screen.getByText("Background task")); } @@ -95,9 +113,30 @@ function getDetailValue(label: string): string | null | undefined { return screen.getByText(label).parentElement?.lastElementChild?.textContent; } -describe("BackgroundTaskDebugPanel", () => { +describe("background task debug section", () => { beforeEach(() => { messageRows = []; + backgroundTasks = [task]; + }); + + it("holds a long task list back behind a see-more toggle", () => { + backgroundTasks = Array.from({ length: 7 }, (_, index) => ({ + ...task, + id: `task-${index}`, + title: `Task ${index}`, + })); + + render(); + + // Five rows, then the offer to see the other two. + expect(screen.getByText("Task 4")).toBeDefined(); + expect(screen.queryByText("Task 5")).toBeNull(); + + fireEvent.click(screen.getByText("managePanel.seeMore")); + expect(screen.getByText("Task 6")).toBeDefined(); + + fireEvent.click(screen.getByText("managePanel.seeLess")); + expect(screen.queryByText("Task 5")).toBeNull(); }); it("uses a single borderless scroll area that fills the remaining height", () => { diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index d7ee324f3b..bffceaa756 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -1,26 +1,17 @@ /** - * BackgroundTaskDebugPanel — dev-mode-only floating debug UI for background tasks. + * Dev-mode-only debug UI for background tasks, rendered as one category of + * the manage panel (see `manage-panel.tsx`). * - * Renders a thin vertical handle on the right edge of the chat page. Hovering - * the handle opens an overview list of all background tasks (any status). - * Clicking a task opens a slide-out panel that shows the task's messages and - * todos via the reusable component. + * is an overview of all background tasks (any status); + * selecting one opens , a slide-out panel showing that + * task's messages and todos via the reusable component. * - * Mounted from `features/chat/page.tsx` (only renders when `isDevMode` is true). - * - * This is a developer-only surface, so the user-facing strings here are not - * translated. + * This is a developer-only surface, so the strings here are not translated. */ /* eslint-disable i18next/no-literal-string */ import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; import { Button } from "@/components/ui/button"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { useIsDevMode } from "@/features/settings"; import { useBackgroundTaskState } from "@/lib/hooks/use-background-task-state"; import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; @@ -32,85 +23,15 @@ import { PauseCircle, X, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; +import { createPortal } from "react-dom"; import { formatTokens } from "../lib/format-tokens"; +import { PanelSection, useCappedList } from "./panel-section"; +import { StatusDot, StatusSpinner } from "./status-dot"; -export function BackgroundTaskDebugPanel() { - const [isDevMode] = useIsDevMode(); - - if (isDevMode !== true) return null; - - return ; -} - -function BackgroundTaskDebugPanelInner() { - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [isListOpen, setIsListOpen] = useState(false); - - const handleSelectTask = (taskId: string) => { - setSelectedTaskId(taskId); - // Auto-hide the overview list as soon as a task is selected — the - // slide-out detail panel becomes the focus. - setIsListOpen(false); - }; - - return ( - <> - - - {/* - The visible gray bar stays small (`w-1.5 h-16`) so the UI is - unobtrusive, but the hit area is a much larger transparent - column (`w-5 h-40`) anchored to the right edge — hovering - anywhere within that column instantly opens the list. - */} - - - - - - - {selectedTaskId && ( - setSelectedTaskId(null)} - /> - )} - - ); -} +export const BackgroundTaskDetailTestId = "background-task-debug-detail"; -function BackgroundTaskList({ +export function BackgroundTaskList({ selectedTaskId, onSelect, }: { @@ -119,24 +40,20 @@ function BackgroundTaskList({ }) { const store = useDefaultStore(); const backgroundTasks = store.useQuery(catalog.queries.backgroundTasks$); + // Tasks pile up faster than anything else in the panel, so this category is + // capped like the others instead of running off the bottom. + const { visibleItems, seeMoreButton } = useCappedList(backgroundTasks); return ( -
-
- - Background Tasks - - - {backgroundTasks.length} - -
+ {backgroundTasks.length === 0 ? ( -
+
No background tasks
) : ( -
    - {backgroundTasks.map((task) => ( + // No scroll container of its own: the panel's ScrollArea scrolls it. +
      + {visibleItems.map((task) => ( )} -
+ {seeMoreButton} +
); } @@ -165,31 +83,45 @@ function BackgroundTaskListItem({ type="button" onClick={onSelect} className={cn( - "flex w-full flex-col items-start gap-1 px-3 py-2 text-left", + "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left", "transition-colors hover:bg-muted/60", isSelected && "bg-muted", )} > -
- - - {task.title || "(Untitled)"} - - - {formatRelative(task.updatedAt)} - -
-
- {task.status} - - {task.id.slice(0, 8)} - -
+ {/* The status dot carries the status; the id lives in the detail view. */} + + + {task.title || "(Untitled)"} + + + {formatRelative(task.updatedAt)} + ); } +/** In a dense list, dots keep every row on the same visual rhythm. */ +function BackgroundTaskStatusDot({ task }: { task: Task }) { + switch (task.status) { + case "pending-model": + case "pending-tool": + return ; + case "pending-input": + return ; + case "completed": + return ; + case "failed": + return ; + default: + return ; + } +} + +/** + * The detail header shows a single task, so it can afford a shaped icon: it is + * far more legible than a dot when nothing else is around to compare it to. + */ function BackgroundTaskStatusIcon({ task }: { task: Task }) { switch (task.status) { case "pending-model": @@ -208,7 +140,7 @@ function BackgroundTaskStatusIcon({ task }: { task: Task }) { } } -function BackgroundTaskDetail({ +export function BackgroundTaskDetail({ taskId, onClose, }: { @@ -238,14 +170,17 @@ function BackgroundTaskDetail({ ? latestAssistantMessage.metadata : undefined; - return ( + // Portaled to : the manage panel sits in a `z-20` stacking context, so + // an in-place `z-[60]` would still be trapped below the popover's portal. + return createPortal(
@@ -312,7 +247,8 @@ function BackgroundTaskDetail({ instantAutoScroll />
-
+
, + document.body, ); } diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx new file mode 100644 index 0000000000..af6f77a33f --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx @@ -0,0 +1,291 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { JobList } from "../lib/build-job-list"; +import { ManagePanel } from "./manage-panel"; + +const open = vi.fn(); +let jobList: JobList = { pochi: [], terminals: [] }; +let isDevMode = false; +let openState = { isTerminalClosed: false, canOpenOutputFile: false }; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// The popover is rendered inline so the content is always assertable; opening +// and closing it is Radix's responsibility, not this component's. +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/features/settings", () => ({ + useIsDevMode: () => [isDevMode], +})); + +vi.mock("../hooks/use-job-list", () => ({ + useJobList: () => jobList, +})); + +vi.mock("@/lib/hooks/use-open-background-job", () => ({ + useOpenBackgroundJob: () => ({ + liveTerminal: undefined, + ...openState, + open, + }), +})); + +vi.mock("./background-task-debug-panel", () => ({ + BackgroundTaskList: () =>
, + BackgroundTaskDetail: () => null, +})); + +const renderPanel = () => render(); + +describe("ManagePanel", () => { + beforeEach(() => { + open.mockClear(); + isDevMode = false; + jobList = { pochi: [], terminals: [] }; + openState = { isTerminalClosed: false, canOpenOutputFile: false }; + }); + + it("keeps the chip bare when there is nothing to manage", () => { + const { container } = renderPanel(); + + const chip = screen.getByTestId("manage-panel-toggle"); + // Icon only: no label, and no badge until something is actually running. + expect(chip.textContent).toBe(""); + expect(chip.getAttribute("aria-label")).toBe("managePanel.toggle"); + expect(container.querySelector(".animate-spin")).toBeNull(); + expect(screen.getByText("managePanel.empty")).toBeDefined(); + expect(screen.queryByText("managePanel.pochiGroup")).toBeNull(); + expect(screen.queryByText("managePanel.terminalsGroup")).toBeNull(); + }); + + it("hides a category that has nothing in it", () => { + jobList = { + pochi: [], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "idle", + isActive: true, + }, + ], + }; + + renderPanel(); + + expect(screen.getByText("managePanel.terminalsGroup")).toBeDefined(); + expect(screen.queryByText("managePanel.pochiGroup")).toBeNull(); + expect(screen.queryByText("managePanel.empty")).toBeNull(); + }); + + it("badges the running rows only, not everything listed", () => { + jobList = { + pochi: [ + { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + command: "bun run dev", + status: "running", + isActive: false, + }, + ], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "idle", + isActive: true, + }, + ], + }; + + const { container } = renderPanel(); + + // Two rows are listed, but only one of them is working. + expect(screen.getByTestId("manage-panel-toggle").textContent).toBe("1"); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + expect(screen.getByText("bun run dev")).toBeDefined(); + expect(screen.getByText("zsh")).toBeDefined(); + }); + + it("drops the badge when an open terminal is merely idle", () => { + jobList = { + pochi: [], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "idle", + isActive: true, + }, + ], + }; + + const { container } = renderPanel(); + + expect(screen.getByTestId("manage-panel-toggle").textContent).toBe(""); + expect(container.querySelector(".animate-spin")).toBeNull(); + }); + + it("labels a terminal that has not reported a name yet", () => { + jobList = { + pochi: [], + terminals: [ + { + backgroundJobId: "term-1", + title: "", + status: "idle", + isActive: true, + }, + ], + }; + + renderPanel(); + + expect( + screen.getByText("commandExecutionPanel.userTerminal"), + ).toBeDefined(); + }); + + it("collapses a section from its title", () => { + jobList = { + pochi: [], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "idle", + isActive: true, + }, + ], + }; + + renderPanel(); + + const title = screen.getByText("managePanel.terminalsGroup"); + fireEvent.click(title); + expect(screen.queryByText("zsh")).toBeNull(); + + fireEvent.click(title); + expect(screen.getByText("zsh")).toBeDefined(); + }); + + it("holds a long category back behind a see-more toggle", () => { + jobList = { + pochi: [], + terminals: Array.from({ length: 7 }, (_, index) => ({ + backgroundJobId: `term-${index}`, + title: `zsh ${index}`, + status: "idle" as const, + isActive: false, + })), + }; + + renderPanel(); + + // Five rows, then the offer to see the other two. + expect(screen.getByText("zsh 4")).toBeDefined(); + expect(screen.queryByText("zsh 5")).toBeNull(); + + fireEvent.click(screen.getByText("managePanel.seeMore")); + expect(screen.getByText("zsh 6")).toBeDefined(); + + fireEvent.click(screen.getByText("managePanel.seeLess")); + expect(screen.queryByText("zsh 5")).toBeNull(); + }); + + it("explains a row by its command, and stays quiet without one", () => { + jobList = { + pochi: [ + { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + command: "bun run dev", + status: "running", + isActive: false, + }, + ], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "idle", + isActive: true, + }, + ], + }; + + renderPanel(); + + const jobRow = screen.getByLabelText("commandExecutionPanel.openJob"); + expect(jobRow.dataset.slot).toBe("tooltip-trigger"); + const terminalRow = screen.getByLabelText( + "commandExecutionPanel.openTerminal", + ); + expect(terminalRow.dataset.slot).toBeUndefined(); + }); + + it("opens a job by clicking anywhere on its row", () => { + jobList = { + pochi: [ + { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + status: "running", + isActive: false, + }, + ], + terminals: [], + }; + + renderPanel(); + + const row = screen.getByLabelText("commandExecutionPanel.openJob"); + expect(row.tagName).toBe("BUTTON"); + fireEvent.click(screen.getByText("bun run dev")); + expect(open).toHaveBeenCalled(); + expect(screen.getByText("%1")).toBeDefined(); + }); + + it("drops the interaction once there is nothing left to open", () => { + openState = { isTerminalClosed: true, canOpenOutputFile: false }; + jobList = { + pochi: [], + terminals: [ + { + backgroundJobId: "term-1", + title: "zsh", + status: "stopped", + isActive: false, + }, + ], + }; + + renderPanel(); + + const row = screen.getByLabelText("commandExecutionPanel.terminalClosed"); + expect(row.tagName).not.toBe("BUTTON"); + fireEvent.click(screen.getByText("zsh")); + expect(open).not.toHaveBeenCalled(); + }); + + it("shows the background task category only in dev mode", () => { + renderPanel(); + expect(screen.queryByTestId("background-task-list")).toBeNull(); + + isDevMode = true; + renderPanel(); + expect(screen.getAllByTestId("background-task-list")).toHaveLength(1); + }); +}); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx new file mode 100644 index 0000000000..ad52a98d8d --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx @@ -0,0 +1,306 @@ +/** + * ManagePanel — the docked overview of everything running alongside the + * conversation: Pochi's background commands for this task, the user's open + * terminals, and (in dev mode) the background task list. + * + * The component is deliberately unaware of where it sits; `page.tsx` owns the + * positioning so the panel can later move into a column of its own. + */ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useIsDevMode } from "@/features/settings"; +import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; +import { cn } from "@/lib/utils"; +import type { Message } from "@getpochi/livekit"; +import { ListIcon, TerminalIcon } from "lucide-react"; +import { Fragment, type ReactNode, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useJobList } from "../hooks/use-job-list"; +import type { JobListEntry, JobStatus } from "../lib/build-job-list"; +import { + BackgroundTaskDetail, + BackgroundTaskDetailTestId, + BackgroundTaskList, +} from "./background-task-debug-panel"; +import { PanelSection, useCappedList } from "./panel-section"; +import { StatusDot, StatusSpinner } from "./status-dot"; + +export function ManagePanel({ + taskId, + messages, +}: { + taskId: string; + messages: Message[]; +}) { + const { t } = useTranslation(); + const [isDevMode] = useIsDevMode(); + const [isOpen, setIsOpen] = useState(false); + const [debugTaskId, setDebugTaskId] = useState(null); + const { pochi, terminals } = useJobList(taskId, messages); + + // The badge is the trigger's whole status language: a number appears only + // while something is actually running, so no badge means nothing is working. + const runningCount = [...pochi, ...terminals].filter( + (job) => job.status === "running", + ).length; + + // A category with nothing in it says nothing, so it is left out entirely and + // the separators are placed between whatever is left. + const sections: { key: string; node: ReactNode }[] = []; + if (pochi.length > 0) { + sections.push({ + key: "pochi", + node: , + }); + } + if (terminals.length > 0) { + sections.push({ + key: "terminals", + node: ( + + ), + }); + } + if (isDevMode === true) { + sections.push({ + key: "tasks", + node: ( + + ), + }); + } + + return ( + <> + + {/* The name of the panel is carried by the tooltip; on a header row + that already holds the task title, an icon is quieter. */} + + + + + + + {t("managePanel.title")} + + { + const target = event.target as Element | null; + if ( + target?.closest?.(`[data-testid="${BackgroundTaskDetailTestId}"]`) + ) { + event.preventDefault(); + } + }} + > + {/* One scroll container for the whole panel, so every list here + shares the VS Code themed scrollbar. */} + +
+ {sections.length === 0 ? ( +
+ {t("managePanel.empty")} +
+ ) : ( + sections.map((section, index) => ( + + {index > 0 && } + {section.node} + + )) + )} +
+
+
+
+ {/* + Rendered outside the popover: its content is positioned by Floating UI + with a transform, which would break the drawer's fixed positioning. + The drawer portals itself to so it also escapes this panel's + stacking context and can paint above the popover. + */} + {debugTaskId && ( + setDebugTaskId(null)} + /> + )} + + ); +} + +/** Separates two categories; inside a category, spacing does the grouping. */ +function SectionSeparator() { + return
; +} + +function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { + const { visibleItems, seeMoreButton } = useCappedList(jobs); + + return ( + +
    + {visibleItems.map((job) => ( +
  • + +
  • + ))} +
+ {seeMoreButton} +
+ ); +} + +function JobRow({ job }: { job: JobListEntry }) { + const { t } = useTranslation(); + const { liveTerminal, isTerminalClosed, canOpenOutputFile, open } = + useOpenBackgroundJob(job.backgroundJobId, job.outputFile); + const isUserTerminal = job.backgroundJobId.startsWith("term-"); + // Live terminal -> reveal and focus it; gone -> open its recorded output. + const canOpen = !isTerminalClosed || canOpenOutputFile; + + const label = isTerminalClosed + ? canOpenOutputFile + ? t("commandExecutionPanel.terminalClosedOpenOutput") + : t("commandExecutionPanel.terminalClosed") + : isUserTerminal + ? t("commandExecutionPanel.openTerminal", { + name: liveTerminal?.name ?? job.title, + }) + : t("commandExecutionPanel.openJob", { + displayId: job.displayId ?? job.backgroundJobId, + }); + + const rowClassName = + "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left transition-colors"; + const rowContent = ( + <> + + {/* A terminal opened seconds ago has no name and no command yet; the + same fallback the command panels use keeps the row readable. */} + + {job.title || t("commandExecutionPanel.userTerminal")} + + + {isUserTerminal || !job.displayId ? ( + + ) : ( +
{job.displayId}
+ )} +
+ + ); + + const row = canOpen ? ( + + ) : ( +
+ {rowContent} +
+ ); + + // The hover reveals what the row is about, not what clicking it does: the + // command, like the panels in the message list. A terminal that has run + // nothing has nothing to add, so it gets no tooltip at all. + if (!job.command) return row; + + return ( + + {row} + + + {job.command} + + + + ); +} + +/** + * The badge on a row. Purely decorative here: the whole row carries the + * interaction, so it must not be a nested button. + */ +function JobBadge({ + isActive, + inert, + children, +}: { + isActive: boolean; + inert: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function JobStatusIndicator({ status }: { status: JobStatus }) { + if (status === "running") return ; + + return ( + + ); +} diff --git a/packages/vscode-webui/src/features/chat/components/panel-section.tsx b/packages/vscode-webui/src/features/chat/components/panel-section.tsx new file mode 100644 index 0000000000..2d98280730 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/panel-section.tsx @@ -0,0 +1,105 @@ +import { cn } from "@/lib/utils"; +import { ChevronRightIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { useTranslation } from "react-i18next"; + +/** + * One category of the background panel. The title row doubles as the collapse + * control, so there is no extra button to aim at, and the count stays visible + * while the section is folded. + * + * Lives in its own file because both `manage-panel.tsx` and the dev-only + * `background-task-debug-panel.tsx` use it, and the former imports the latter. + */ +export function PanelSection({ + label, + count, + children, +}: { + label: string; + count: number; + children: ReactNode; +}) { + const [isCollapsed, setIsCollapsed] = useState(false); + + return ( +
+ + {!isCollapsed && children} +
+ ); +} + +/** How many rows a category shows before it has to be asked for the rest. */ +const CollapsedItemCount = 5; + +/** + * Caps a category's rows and hands back the control that reveals the rest, so + * every category in the panel truncates the same way. The state lives here, so + * one long category can be expanded without touching its neighbours. + */ +export function useCappedList(items: readonly T[]) { + const [isExpanded, setIsExpanded] = useState(false); + const hiddenCount = items.length - CollapsedItemCount; + + return { + visibleItems: isExpanded ? items : items.slice(0, CollapsedItemCount), + seeMoreButton: + hiddenCount > 0 ? ( + setIsExpanded((prev) => !prev)} + /> + ) : null, + }; +} + +function SeeMoreButton({ + isExpanded, + onToggle, +}: { + isExpanded: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/packages/vscode-webui/src/features/chat/components/status-dot.tsx b/packages/vscode-webui/src/features/chat/components/status-dot.tsx new file mode 100644 index 0000000000..b4ce184b09 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/status-dot.tsx @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import type { ReactNode } from "react"; + +/** + * The status marker in front of a row in the background panel. Shared so job + * rows, terminal rows and task rows all read their status the same way. + * + * A dot carries every resting status; work in progress gets a spinner, which + * is the only state that has to be recognizable at a glance. + */ +export function StatusDot({ className }: { className?: string }) { + return ( + + + + ); +} + +export function StatusSpinner({ className }: { className?: string }) { + return ( + + + + ); +} + +/** Keeps dots and spinners on the same column so row titles line up. */ +function IndicatorSlot({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts new file mode 100644 index 0000000000..ecba8044f9 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts @@ -0,0 +1,19 @@ +import { useBackgroundJobNotifications } from "@/lib/hooks/use-background-job-notifications"; +import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; +import type { Message } from "@getpochi/livekit"; +import { useMemo } from "react"; +import { type JobList, buildJobList } from "../lib/build-job-list"; + +/** + * The background work of this task, plus every terminal the user has open. + */ +/** @useSignals */ +export function useJobList(taskId: string, messages: Message[]): JobList { + const { terminals } = useVisibleTerminals(); + const { notifications } = useBackgroundJobNotifications(taskId); + + return useMemo( + () => buildJobList({ messages, notifications, terminals }), + [messages, notifications, terminals], + ); +} diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts new file mode 100644 index 0000000000..2beddc86d1 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts @@ -0,0 +1,248 @@ +import type { BackgroundJobNotification } from "@getpochi/common"; +import type { Message } from "@getpochi/livekit"; +import { describe, expect, it } from "vitest"; +import { type TerminalSnapshot, buildJobList } from "./build-job-list"; + +describe("buildJobList", () => { + it("lists a running job from its live terminal", () => { + const { pochi } = buildJobList({ + messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + notifications: [], + terminals: [terminal("bgjob-cmd-1", { isActive: true })], + }); + + expect(pochi).toEqual([ + { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + command: "bun run dev", + status: "running", + outputFile: "/tmp/bgjob-cmd-1.log", + isActive: true, + }, + ]); + }); + + it("keeps a finished job whose notification was already delivered", () => { + const { pochi } = buildJobList({ + messages: [ + message([ + executeCommandPart("bgjob-cmd-1", "bun run dev"), + notificationPart(notification("bgjob-cmd-1", "failed")), + ]), + ], + // The host copy is dropped once it has been delivered as a message part. + notifications: [], + terminals: [], + }); + + expect(pochi).toEqual([ + { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + command: "bun run dev", + status: "failed", + outputFile: "/tmp/bgjob-cmd-1.log", + isActive: false, + }, + ]); + }); + + it("keeps a finished job whose notification is still undelivered", () => { + const { pochi } = buildJobList({ + messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + notifications: [notification("bgjob-cmd-1", "completed")], + terminals: [], + }); + + expect(pochi).toMatchObject([ + { backgroundJobId: "bgjob-cmd-1", status: "completed" }, + ]); + }); + + it("surfaces a notification whose executeCommand part is gone", () => { + const { pochi } = buildJobList({ + messages: [], + notifications: [notification("bgjob-cmd-9", "stopped")], + terminals: [], + }); + + expect(pochi).toEqual([ + { + backgroundJobId: "bgjob-cmd-9", + title: "run bgjob-cmd-9", + command: "run bgjob-cmd-9", + status: "stopped", + outputFile: "/tmp/bgjob-cmd-9.log", + isActive: false, + }, + ]); + }); + + it("drops a job that has neither a terminal nor a notification", () => { + const { pochi } = buildJobList({ + messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + notifications: [], + terminals: [], + }); + + expect(pochi).toEqual([]); + }); + + it("lists the newest job first, numbered like the badges in the message list", () => { + const { pochi } = buildJobList({ + messages: [ + message([ + executeCommandPart("bgjob-cmd-1", "first"), + executeCommandPart("bgjob-cmd-2", "second"), + ]), + ], + notifications: [ + notification("bgjob-cmd-1", "completed"), + notification("bgjob-cmd-2", "completed"), + // Started before both, but its message was compacted away. + notification("bgjob-cmd-9", "completed"), + ], + terminals: [], + }); + + expect(pochi.map((job) => job.displayId)).toEqual(["%2", "%1", undefined]); + }); + + it("lists every user terminal, including ones this task never touched", () => { + const { pochi, terminals } = buildJobList({ + messages: [], + notifications: [], + terminals: [ + terminal("term-1", { name: "zsh", isActive: true, isRunning: true }), + terminal("term-2", { name: "zsh" }), + // A background job belonging to some other task. + terminal("bgjob-cmd-other"), + ], + }); + + expect(pochi).toEqual([]); + expect(terminals).toEqual([ + { + backgroundJobId: "term-1", + title: "zsh", + status: "running", + outputFile: "/tmp/term-1.log", + isActive: true, + }, + { + backgroundJobId: "term-2", + title: "zsh", + // Alive but not executing anything. + status: "idle", + outputFile: "/tmp/term-2.log", + isActive: false, + }, + ]); + }); + + it("disambiguates bare shell terminals by their last command", () => { + const { terminals } = buildJobList({ + messages: [], + notifications: [], + terminals: [ + terminal("term-1", { name: "zsh", lastCommand: "bun run dev" }), + terminal("term-2", { name: "npm: dev", lastCommand: "npm run dev" }), + ], + }); + + expect(terminals.map((entry) => entry.title)).toEqual([ + "zsh · bun run dev", + "npm: dev", + ]); + // The command is kept apart from the title so the row can explain itself + // on hover even when the title already reads well. + expect(terminals.map((entry) => entry.command)).toEqual([ + "bun run dev", + "npm run dev", + ]); + }); + + it("still lists a terminal that has not reported a name yet", () => { + const { terminals } = buildJobList({ + messages: [], + notifications: [], + // A terminal the user just opened: VS Code has not resolved its shell + // process title, and nothing has run in it. + terminals: [terminal("term-1", { name: "", isActive: true })], + }); + + expect(terminals).toMatchObject([ + { backgroundJobId: "term-1", title: "", status: "idle" }, + ]); + }); + + it("hides running jobs until the terminal list has loaded", () => { + const { pochi, terminals } = buildJobList({ + messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + notifications: [], + terminals: undefined, + }); + + expect(pochi).toEqual([]); + expect(terminals).toEqual([]); + }); +}); + +function message(parts: unknown[]): Message { + return { id: "message-1", role: "assistant", parts } as unknown as Message; +} + +function executeCommandPart(backgroundJobId: string, command: string) { + return { + type: "tool-executeCommand", + state: "output-available", + input: { command, background: true }, + output: { _meta: { backgroundJobId } }, + }; +} + +function notificationPart(data: BackgroundJobNotification) { + return { type: "data-background-job-notification", data }; +} + +function terminal( + backgroundJobId: string, + { + name = "zsh", + isActive = false, + isRunning = false, + lastCommand, + }: { + name?: string; + isActive?: boolean; + isRunning?: boolean; + lastCommand?: string; + } = {}, +): TerminalSnapshot { + return { + name, + isActive, + isRunning, + lastCommand, + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + }; +} + +function notification( + backgroundJobId: string, + status: BackgroundJobNotification["status"], +): BackgroundJobNotification { + return { + notificationId: `${backgroundJobId}:terminal`, + backgroundJobId, + outputFile: `/tmp/${backgroundJobId}.log`, + command: `run ${backgroundJobId}`, + status, + summary: `Background command "${backgroundJobId}" ${status}`, + finishedAt: 1, + }; +} diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts new file mode 100644 index 0000000000..bbdea5d747 --- /dev/null +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts @@ -0,0 +1,175 @@ +import { formatTerminalDisplayName } from "@/lib/terminal-display-name"; +import type { BackgroundJobNotification } from "@getpochi/common"; +import type { Message } from "@getpochi/livekit"; + +/** User terminals are read-only and are never scoped to a task. */ +const UserTerminalPrefix = "term-"; + +export type JobStatus = "running" | "idle" | "completed" | "failed" | "stopped"; + +export interface JobListEntry { + backgroundJobId: string; + /** `%1`-style label, matching the badge shown inside the message list. */ + displayId?: string; + title: string; + /** + * The command behind the row, shown on hover. Absent for a terminal that has + * not run anything yet, which is exactly when there is nothing to say. + */ + command?: string; + status: JobStatus; + /** Transcript to fall back to once the terminal is gone. */ + outputFile?: string; + isActive: boolean; +} + +/** The subset of `TerminalInfo` the list needs. */ +export interface TerminalSnapshot { + name: string; + isActive: boolean; + /** Whether a command is executing in the terminal right now. */ + isRunning?: boolean; + lastCommand?: string; + backgroundJobId?: string; + outputFile?: string; +} + +export interface JobList { + /** Background commands Pochi started for this task. */ + pochi: JobListEntry[]; + /** Every terminal the user has open, regardless of task. */ + terminals: JobListEntry[]; +} + +/** + * Collects the background work worth surfacing in the manage panel. + * + * Pochi jobs are task-scoped, which falls out of only ever looking at this + * task's messages. Their lifecycle is split across two complementary sources: + * a notification waits in the host store until it has been delivered as a + * `data-background-job-notification` message part, at which point the host + * copy is dropped. Reading only one of them loses finished jobs, so both are + * merged here. + */ +export function buildJobList({ + messages, + notifications, + terminals, +}: { + messages: readonly Message[]; + notifications: readonly BackgroundJobNotification[]; + terminals: readonly TerminalSnapshot[] | undefined; +}): JobList { + const commands = new Map(); + const finished = new Map(); + + for (const message of messages) { + for (const part of message.parts) { + if ( + part.type === "tool-executeCommand" && + part.state !== "input-streaming" && + part.input?.background === true && + part.output?._meta?.backgroundJobId + ) { + const backgroundJobId = part.output._meta.backgroundJobId; + // First occurrence wins so the `%N` numbering stays stable, matching + // `useBackgroundJobDisplay`. + if (!commands.has(backgroundJobId)) { + commands.set(backgroundJobId, part.input.command); + } + } else if (part.type === "data-background-job-notification") { + finished.set(part.data.backgroundJobId, part.data); + } + } + } + for (const notification of notifications) { + finished.set(notification.backgroundJobId, notification); + } + + const liveJobs = new Map(); + for (const terminal of terminals ?? []) { + if (terminal.backgroundJobId) { + liveJobs.set(terminal.backgroundJobId, terminal); + } + } + + const pochi: JobListEntry[] = []; + let index = 0; + for (const [backgroundJobId, command] of commands) { + index += 1; + const displayId = `%${index}`; + const notification = finished.get(backgroundJobId); + if (notification) { + const resolvedCommand = command ?? notification.command; + pochi.push({ + backgroundJobId, + displayId, + title: resolvedCommand ?? backgroundJobId, + command: resolvedCommand, + status: notification.status, + outputFile: notification.outputFile, + isActive: false, + }); + continue; + } + + const live = liveJobs.get(backgroundJobId); + if (live) { + pochi.push({ + backgroundJobId, + displayId, + title: command ?? backgroundJobId, + command, + status: "running", + outputFile: live.outputFile, + isActive: live.isActive, + }); + } + // Otherwise the job left nothing to act on: its terminal is gone and no + // completion notification survived. Listing it would only offer a dead row. + } + + // A notification can outlive the `executeCommand` part that started it, + // because compaction rewrites older messages. + const orphaned: JobListEntry[] = []; + for (const notification of finished.values()) { + if (commands.has(notification.backgroundJobId)) continue; + orphaned.push({ + backgroundJobId: notification.backgroundJobId, + title: notification.command ?? notification.backgroundJobId, + command: notification.command, + status: notification.status, + outputFile: notification.outputFile, + isActive: false, + }); + } + + const userTerminals = (terminals ?? []).flatMap((terminal) => + terminal.backgroundJobId?.startsWith(UserTerminalPrefix) + ? [ + { + backgroundJobId: terminal.backgroundJobId, + // A bare shell name ("zsh") says nothing; the last command does. + // A just-opened terminal has neither: VS Code only fills the name + // in once the shell process reports its title. The row falls back + // to a generic label rather than rendering blank. + title: + formatTerminalDisplayName(terminal.name, terminal.lastCommand) ?? + "", + command: terminal.lastCommand, + // A user terminal is always alive, so its dot tracks whether it is + // busy, not whether it exists. + status: terminal.isRunning ? "running" : "idle", + outputFile: terminal.outputFile, + isActive: terminal.isActive, + }, + ] + : [], + ); + + // Newest command first: the one just started is the one being watched. The + // `%N` labels keep counting from the start of the task, so the numbering + // still matches the badges in the message list. Jobs whose message was + // compacted away are the oldest, so they stay at the bottom. + return { pochi: [...pochi.reverse(), ...orphaned], terminals: userTerminals }; +} diff --git a/packages/vscode-webui/src/features/chat/page.tsx b/packages/vscode-webui/src/features/chat/page.tsx index b3694f9323..261a4c9d7a 100644 --- a/packages/vscode-webui/src/features/chat/page.tsx +++ b/packages/vscode-webui/src/features/chat/page.tsx @@ -33,10 +33,10 @@ import { useSelectedModels, useSettingsStore, } from "../settings"; -import { BackgroundTaskDebugPanel } from "./components/background-task-debug-panel"; import { ChatArea } from "./components/chat-area"; import { ChatSkeleton } from "./components/chat-skeleton"; import { ChatToolbar } from "./components/chat-toolbar"; +import { ManagePanel } from "./components/manage-panel"; import { SubtaskHeader } from "./components/subtask"; import { useAbortBeforeNavigation } from "./hooks/use-abort-before-navigation"; import { useAutoOpenPlanFile } from "./hooks/use-auto-open-plan-file"; @@ -456,6 +456,13 @@ function Chat({ user, uid, info }: ChatProps) { className="absolute top-1 right-2 z-10" /> )} + {/* + The panel itself is position-agnostic; it is docked here so it can + later move into a column of its own without touching its internals. + */} +
+ +
-
); } diff --git a/packages/vscode-webui/src/features/chat/styles.ts b/packages/vscode-webui/src/features/chat/styles.ts index 684241edea..ba14c5cb5a 100644 --- a/packages/vscode-webui/src/features/chat/styles.ts +++ b/packages/vscode-webui/src/features/chat/styles.ts @@ -1,4 +1,7 @@ import { tw } from "@/lib/utils"; -export const ChatContainerClassName = tw`mx-auto flex h-screen max-w-6xl flex-col`; +// `relative` anchors the absolutely positioned headers/panels to the message +// column instead of the viewport, which only differs once the viewport is +// wider than `max-w-6xl`. +export const ChatContainerClassName = tw`relative mx-auto flex h-screen max-w-6xl flex-col`; export const ChatToolbarContainerClassName = tw`relative flex flex-col px-4`; diff --git a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx index 68cfd4bc88..b9c24ab640 100644 --- a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx +++ b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx @@ -1,3 +1,4 @@ +import { JobControlButton } from "@/components/job-control-button"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -6,10 +7,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useBackgroundJobInfo } from "@/features/chat"; -import { useBackgroundCommands } from "@/lib/hooks/use-background-commands"; import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; import { useDebounceState } from "@/lib/hooks/use-debounce-state"; -import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; +import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; import { formatTerminalDisplayName } from "@/lib/terminal-display-name"; import { cn } from "@/lib/utils"; import { isVSCodeEnvironment, vscodeHost } from "@/lib/vscode"; @@ -25,14 +25,7 @@ import { TerminalIcon, XCircle, } from "lucide-react"; -import { - type FC, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { XTerm } from "./xterm"; @@ -108,50 +101,6 @@ const ToggleExpandButton: FC<{ expanded: boolean; onToggle: () => void }> = ({ ); }; -/** - * The badge in front of a job/terminal panel: opens the live terminal, or -- - * once that terminal is gone -- its recorded output file. - */ -const JobControlButton: FC<{ - label: string; - isActive?: boolean; - /** Nothing left to open: keep the badge, drop the interaction. */ - inert?: boolean; - onClick: () => void; - children: React.ReactNode; -}> = ({ label, isActive, inert, onClick, children }) => ( - - - {inert ? ( - // A plain span rather than a disabled button: disabled buttons swallow - // pointer events, which would hide the tooltip explaining why nothing - // can be opened anymore. - - {children} - - ) : ( - - )} - - - {label} - - -); - export const CommandPanelContainer: FC<{ icon: React.ReactNode; title: React.ReactNode; @@ -246,19 +195,13 @@ export const BackgroundJobPanel: FC<{ const [expanded, setExpanded] = useState(false); const toggleExpanded = () => setExpanded((prev) => !prev); const info = useBackgroundJobInfo(backgroundJobId); - const { backgroundCommands, show: showBackgroundCommand } = - useBackgroundCommands(); - const { terminals, openBackgroundJobTerminal } = useVisibleTerminals(); + const { + liveTerminal, + isTerminalClosed, + canOpenOutputFile, + open: openTerminalOrOutputFile, + } = useOpenBackgroundJob(backgroundJobId, outputFile); const isUserTerminal = backgroundJobId.startsWith("term-"); - const isDetachableBackgroundCommand = - backgroundCommands?.[backgroundJobId] !== undefined; - // Live name wins over the snapshot: the terminal may have been renamed - // since the read. The snapshot keeps historical reads meaningful after the - // terminal is closed. - const liveTerminal = useMemo( - () => terminals?.find((tm) => tm.backgroundJobId === backgroundJobId), - [backgroundJobId, terminals], - ); const isNotification = appearance === "notification"; const recoveredNotificationCommand = isNotification ? recoverNotificationCommand(summary, status) @@ -268,6 +211,9 @@ export const BackgroundJobPanel: FC<{ : (info?.command ?? command); const hasTrackedJob = Boolean(info?.command); const copyCommand = resolvedCommand ?? lastCommand; + // Live name wins over the snapshot: the terminal may have been renamed since + // the read. The snapshot keeps historical reads meaningful after the + // terminal is closed. const displayTerminalName = formatTerminalDisplayName( liveTerminal?.name ?? terminalName, lastCommand, @@ -277,32 +223,6 @@ export const BackgroundJobPanel: FC<{ : (resolvedCommand ?? backgroundJobId); const isActive = liveTerminal?.isActive ?? false; - // Terminals closed after the read keep their badge, so the panel still reads - // as a terminal/job panel; the badge then falls back to the output file. - const isTerminalClosed = terminals !== undefined && !liveTerminal; - const canOpenOutputFile = isTerminalClosed && outputFile !== undefined; - - const openTerminalOrOutputFile = useCallback(() => { - if (isTerminalClosed) { - if (outputFile) vscodeHost.openFile(outputFile); - return; - } - if (isDetachableBackgroundCommand) { - showBackgroundCommand?.(backgroundJobId); - } else if (isUserTerminal || backgroundCommands !== undefined) { - // Keep the legacy terminal path for user terminals and shell fallbacks. - openBackgroundJobTerminal?.(backgroundJobId); - } - }, [ - backgroundCommands, - backgroundJobId, - isDetachableBackgroundCommand, - isTerminalClosed, - isUserTerminal, - openBackgroundJobTerminal, - outputFile, - showBackgroundCommand, - ]); const closedLabel = canOpenOutputFile ? t("commandExecutionPanel.terminalClosedOpenOutput") diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index cb391a79d3..f3831d6fa9 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -593,6 +593,15 @@ "stopped": "Stopped", "openOutput": "Open output file" }, + "managePanel": { + "title": "Background", + "toggle": "Show background jobs and terminals", + "pochiGroup": "Commands", + "terminalsGroup": "Terminals", + "empty": "Nothing running in the background", + "seeMore": "See more", + "seeLess": "See less" + }, "forkTask": { "forkedTaskTitle": "Forked from {{taskTitle}}" }, diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index 626ce66bd2..a0382d4900 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -592,6 +592,15 @@ "stopped": "停止", "openOutput": "出力ファイルを開く" }, + "managePanel": { + "title": "バックグラウンド", + "toggle": "バックグラウンドジョブとターミナルを表示", + "pochiGroup": "コマンド", + "terminalsGroup": "ターミナル", + "empty": "バックグラウンドで実行中のものはありません", + "seeMore": "もっと見る", + "seeLess": "折りたたむ" + }, "forkTask": { "forkedTaskTitle": "フォーク 元: {{taskTitle}}" }, diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index 03c94b7a16..1317747c81 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -585,6 +585,15 @@ "stopped": "중지됨", "openOutput": "출력 파일 열기" }, + "managePanel": { + "title": "백그라운드", + "toggle": "백그라운드 작업 및 터미널 보기", + "pochiGroup": "명령", + "terminalsGroup": "터미널", + "empty": "백그라운드에서 실행 중인 항목이 없습니다", + "seeMore": "더 보기", + "seeLess": "간단히 보기" + }, "forkTask": { "forkedTaskTitle": "포크됨 원본: {{taskTitle}}" }, diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index b4edca98d5..18c141c32a 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -590,6 +590,15 @@ "stopped": "已停止", "openOutput": "打开输出文件" }, + "managePanel": { + "title": "后台", + "toggle": "查看后台任务与终端", + "pochiGroup": "命令", + "terminalsGroup": "终端", + "empty": "当前没有后台任务", + "seeMore": "查看更多", + "seeLess": "收起" + }, "forkTask": { "forkedTaskTitle": "任务分支自:{{taskTitle}}" }, diff --git a/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts b/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts new file mode 100644 index 0000000000..ead4e4680f --- /dev/null +++ b/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts @@ -0,0 +1,39 @@ +import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; +import { vscodeHost } from "@/lib/vscode"; +import { useCallback, useMemo } from "react"; + +/** + * Resolves what a background job / terminal control can open: the live + * terminal while it exists, its recorded output file once the terminal is + * gone. + */ +export function useOpenBackgroundJob( + backgroundJobId: string, + outputFile: string | undefined, +) { + const { terminals, openBackgroundJobTerminal } = useVisibleTerminals(); + const liveTerminal = useMemo( + () => terminals?.find((tm) => tm.backgroundJobId === backgroundJobId), + [backgroundJobId, terminals], + ); + + // `terminals === undefined` means "not loaded yet", which must not be + // reported as a closed terminal. + const isTerminalClosed = terminals !== undefined && !liveTerminal; + const canOpenOutputFile = isTerminalClosed && outputFile !== undefined; + + const open = useCallback(() => { + if (isTerminalClosed) { + if (outputFile) vscodeHost.openFile(outputFile); + return; + } + openBackgroundJobTerminal?.(backgroundJobId); + }, [ + backgroundJobId, + isTerminalClosed, + openBackgroundJobTerminal, + outputFile, + ]); + + return { liveTerminal, isTerminalClosed, canOpenOutputFile, open }; +} diff --git a/packages/vscode/src/integrations/terminal/terminal-state.ts b/packages/vscode/src/integrations/terminal/terminal-state.ts index 27d7a66920..0ba47ea7dc 100644 --- a/packages/vscode/src/integrations/terminal/terminal-state.ts +++ b/packages/vscode/src/integrations/terminal/terminal-state.ts @@ -20,6 +20,17 @@ const logger = getLogger("TerminalState"); export interface TerminalInfo { name: string; isActive: boolean; + /** + * Whether a shell command is executing in the terminal right now. + * + * Only tracked for user terminals: background job terminals report their + * lifecycle through job notifications instead, and always read `false` here. + * Requires shell integration; without it no execution events arrive and the + * terminal stays permanently idle. + */ + isRunning?: boolean; + /** The most recent command captured from the terminal, if any. */ + lastCommand?: string; /** * A stable id associated with the terminal's output file. * @@ -51,6 +62,7 @@ export class TerminalState implements vscode.Disposable { private readonly runningExecutions = new Map< vscode.TerminalShellExecution, { + terminalId: string; history: TerminalHistoryManager; captureFinished: Promise; } @@ -114,6 +126,13 @@ export class TerminalState implements vscode.Disposable { vscode.window.onDidCloseTerminal(this.onTerminalClosed), ); this.disposables.push(TerminalJob.onDidCreate(this.onTerminalChanged)); + // A terminal is reported with an empty `name` until its shell process + // reports a title. Shell integration activating is the first event after + // that point, so it is when a freshly opened terminal finally has a name + // worth publishing. + this.disposables.push( + vscode.window.onDidChangeTerminalShellIntegration(this.onTerminalChanged), + ); this.disposables.push(TerminalJob.onDidDispose(this.onTerminalChanged)); this.disposables.push( TerminalJob.onDidChangeVisibility(this.onTerminalChanged), @@ -182,7 +201,11 @@ export class TerminalState implements vscode.Disposable { history, headerWritten, ); - this.runningExecutions.set(event.execution, { history, captureFinished }); + this.runningExecutions.set(event.execution, { + terminalId: id, + history, + captureFinished, + }); // Reflect the command immediately, then expose its output file only after // the reconstructed command header has actually reached the transcript. this.onTerminalChanged(); @@ -204,8 +227,18 @@ export class TerminalState implements vscode.Disposable { ? undefined : ExecutionError.create(`Command exited with code ${event.exitCode}.`); runningExecution.history.finalize(error); + // The terminal is idle again; without this the `isRunning` flag published + // on start would stay on until some unrelated terminal event fires. + this.onTerminalChanged(); }; + private hasRunningExecution(terminalId: string): boolean { + for (const execution of this.runningExecutions.values()) { + if (execution.terminalId === terminalId) return true; + } + return false; + } + private async captureExecutionOutput( execution: vscode.TerminalShellExecution, history: TerminalHistoryManager, @@ -264,14 +297,19 @@ export class TerminalState implements vscode.Disposable { .map((terminal) => { const id = this.getTerminalId(terminal); const job = TerminalJob.get(terminal); + let lastCommand: string | undefined; if (job) { listedJobIds.add(job.id); } else { - TerminalHistoryManager.getOrCreate(id).terminalName = terminal.name; + const history = TerminalHistoryManager.getOrCreate(id); + history.terminalName = terminal.name; + lastCommand = history.lastCommand; } return { name: terminal.name, isActive: terminal === vscode.window.activeTerminal, + isRunning: this.hasRunningExecution(id), + lastCommand, backgroundJobId: id, outputFile: this.getTerminalOutputFile(terminal), }; From e01097409c58f956407f6e4bc8f72675fcc6ea66 Mon Sep 17 00:00:00 2001 From: liangfung Date: Tue, 1 Sep 2026 17:20:11 +0800 Subject: [PATCH 2/8] update: sheet --- bun.lock | 123 ++++++++- packages/common/src/base/environment.ts | 8 - packages/vscode-webui/package.json | 1 + .../vscode-webui/src/components/ui/sheet.tsx | 150 +++++++++++ .../__stories__/manage-panel.stories.tsx | 16 +- .../background-task-debug-panel.test.tsx | 69 ++--- .../background-task-debug-panel.tsx | 186 +++++++++----- .../chat/components/chat-toolbar.test.tsx | 3 + .../features/chat/components/chat-toolbar.tsx | 2 + .../chat/components/manage-panel.test.tsx | 236 +++++------------- .../features/chat/components/manage-panel.tsx | 231 ++++++----------- .../src/features/chat/hooks/use-job-list.ts | 2 +- .../features/chat/lib/build-job-list.test.ts | 77 +----- .../src/features/chat/lib/build-job-list.ts | 34 +-- .../vscode-webui/src/features/chat/page.tsx | 10 +- .../vscode-webui/src/features/chat/styles.ts | 5 +- .../vscode-webui/src/i18n/locales/en.json | 8 +- .../vscode-webui/src/i18n/locales/jp.json | 8 +- .../vscode-webui/src/i18n/locales/ko.json | 8 +- .../vscode-webui/src/i18n/locales/zh.json | 8 +- .../integrations/terminal/terminal-state.ts | 42 +--- 21 files changed, 571 insertions(+), 656 deletions(-) create mode 100644 packages/vscode-webui/src/components/ui/sheet.tsx diff --git a/bun.lock b/bun.lock index 694abb5c68..3ff6d28e5a 100644 --- a/bun.lock +++ b/bun.lock @@ -276,7 +276,7 @@ }, "packages/vscode": { "name": "pochi", - "version": "0.61.0-dev", + "version": "0.67.0-dev", "dependencies": { "@ai-sdk/google-vertex": "catalog:", "@getpochi/common": "workspace:*", @@ -375,6 +375,7 @@ "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-hover-card": "^1.1.11", "@radix-ui/react-label": "^2.1.6", @@ -1279,17 +1280,17 @@ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.23", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-focus-guards": "1.1.6", "@radix-ui/react-focus-scope": "1.1.16", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4", "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w=="], "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.6", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ=="], - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.16", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ=="], "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], @@ -1305,7 +1306,7 @@ "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.17", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ=="], "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], @@ -1321,7 +1322,7 @@ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], @@ -4741,22 +4742,106 @@ "@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-dialog/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.2.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA=="], + + "@radix-ui/react-dialog/@radix-ui/react-id": ["@radix-ui/react-id@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA=="], + + "@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.10", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-dialog/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-dialog/react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], + + "@radix-ui/react-hover-card/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-hover-card/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], "@radix-ui/react-hover-card/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.10", "", { "dependencies": { "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg=="], + + "@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-scroll-area/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-select/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-select/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-select/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="], + "@radix-ui/react-tabs/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-tooltip/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@1.29.2", "https://registry.npmmirror.com/@shikijs/types/-/types-1.29.2.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], @@ -5083,8 +5168,12 @@ "fumadocs-mdx/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "fumadocs-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "fumadocs-ui/@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="], + "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "geckodriver/decamelize": ["decamelize@6.0.1", "https://registry.npmmirror.com/decamelize/-/decamelize-6.0.1.tgz", {}, "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ=="], "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], @@ -5593,6 +5682,10 @@ "@posthog/core/cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@radix-ui/react-dialog/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.5", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], + "@shikijs/rehype/shiki/@shikijs/core": ["@shikijs/core@3.11.0", "", { "dependencies": { "@shikijs/types": "3.11.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-oJwU+DxGqp6lUZpvtQgVOXNZcVsirN76tihOLBmwILkKuRuwHteApP8oTXmL4tF5vS5FbOY0+8seXmiCoslk4g=="], "@shikijs/rehype/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.11.0", "", { "dependencies": { "@shikijs/types": "3.11.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-6/ov6pxrSvew13k9ztIOnSBOytXeKs5kfIR7vbhdtVRg+KPzvp2HctYGeWkqv7V6YIoLicnig/QF3iajqyElZA=="], @@ -5893,14 +5986,28 @@ "fumadocs-mdx/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "fumadocs-ui/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "fumadocs-ui/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "fumadocs-ui/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "fumadocs-ui/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "fumadocs-ui/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "fumadocs-ui/@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + "fumadocs-ui/@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "fumadocs-ui/@radix-ui/react-popover/react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], diff --git a/packages/common/src/base/environment.ts b/packages/common/src/base/environment.ts index f56b4d2020..f4af81d06d 100644 --- a/packages/common/src/base/environment.ts +++ b/packages/common/src/base/environment.ts @@ -55,14 +55,6 @@ export const Environment = z.object({ z.object({ name: z.string().describe("The name of the terminal."), isActive: z.boolean().describe("Whether the terminal is active."), - isRunning: z - .boolean() - .optional() - .describe("Whether a command is currently running in it."), - lastCommand: z - .string() - .optional() - .describe("The most recent command run in the terminal."), backgroundJobId: z .string() .optional() diff --git a/packages/vscode-webui/package.json b/packages/vscode-webui/package.json index ded99fd93d..87c0165200 100644 --- a/packages/vscode-webui/package.json +++ b/packages/vscode-webui/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-hover-card": "^1.1.11", "@radix-ui/react-label": "^2.1.6", diff --git a/packages/vscode-webui/src/components/ui/sheet.tsx b/packages/vscode-webui/src/components/ui/sheet.tsx new file mode 100644 index 0000000000..99df8c6fc5 --- /dev/null +++ b/packages/vscode-webui/src/components/ui/sheet.tsx @@ -0,0 +1,150 @@ +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import { type VariantProps, cva } from "class-variance-authority"; +import { X } from "lucide-react"; +import type * as React from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b", + bottom: + "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t", + left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", + right: + "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + }, +); + +type SheetContentProps = React.ComponentProps & + VariantProps; + +function SheetContent({ + side = "right", + className, + children, + ...props +}: SheetContentProps) { + const { t } = useTranslation(); + + return ( + + + + {children} + + + {t("common.close")} + + + + ); +} + +function SheetHeader({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function SheetFooter({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx index bd188be85b..baf5cdfa02 100644 --- a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx +++ b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx @@ -17,8 +17,8 @@ const meta = { }, decorators: [ (Story) => ( - // The panel is positioned by `page.tsx` in the app, so give it a - // stand-in corner here. + // The trigger lives in the chat toolbar in the app, so give it a + // stand-in row here.
@@ -29,7 +29,7 @@ const meta = { export default meta; type Story = StoryObj; -/** Nothing running: the chip stays put so the panel remains discoverable. */ +/** Nothing running: the trigger stays put so the panel remains discoverable. */ export const Empty: Story = { play: openPanel, }; @@ -47,11 +47,7 @@ export const WithJobs: Story = { ], }, decorators: [ - withTerminals([ - terminal("bgjob-cmd-1", { name: "Pochi: bun run dev" }), - terminal("term-1", { name: "zsh", isActive: true }), - terminal("term-2", { name: "zsh" }), - ]), + withTerminals([terminal("bgjob-cmd-1", { name: "Pochi: bun run dev" })]), ], play: openPanel, }; @@ -104,11 +100,11 @@ function notificationPart(data: BackgroundJobNotification) { function terminal( backgroundJobId: string, - { name, isActive = false }: { name: string; isActive?: boolean }, + { name }: { name: string }, ): TerminalSnapshot { return { name, - isActive, + isActive: false, backgroundJobId, outputFile: `/tmp/${backgroundJobId}.log`, }; diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx index 41d5357a4d..b78c6d8b84 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx @@ -1,11 +1,8 @@ // @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; -import { useState } from "react"; +import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - BackgroundTaskDetail, - BackgroundTaskList, -} from "./background-task-debug-panel"; +import { BackgroundTaskDebugPanel } from "./background-task-debug-panel"; const task = { id: "task-1", @@ -17,11 +14,6 @@ const task = { }; let messageRows: Array<{ data: unknown }> = []; -let backgroundTasks: unknown[] = [task]; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); vi.mock("@/components/task-thread", () => ({ TaskThread: ({ @@ -53,6 +45,16 @@ vi.mock("@/components/ui/button", () => ({ ), })); +vi.mock("@/components/ui/hover-card", () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock("@/features/settings", () => ({ + useIsDevMode: () => [true], +})); + vi.mock("@/lib/hooks/use-background-task-state", () => ({ useBackgroundTaskState: () => ({ backgroundTaskState: { @@ -76,7 +78,7 @@ vi.mock("@getpochi/livekit", () => ({ vi.mock("@/lib/use-default-store", () => ({ useDefaultStore: () => ({ useQuery: (query: string) => { - if (query === "backgroundTasks") return backgroundTasks; + if (query === "backgroundTasks") return [task]; if (query === "task") return task; if (query === "messages") return messageRows; return []; @@ -84,28 +86,8 @@ vi.mock("@/lib/use-default-store", () => ({ }), })); -/** Mirrors how the manage panel wires the list to the detail drawer. */ -function BackgroundTaskDebugSection() { - const [selectedTaskId, setSelectedTaskId] = useState(null); - - return ( - <> - - {selectedTaskId && ( - setSelectedTaskId(null)} - /> - )} - - ); -} - function openTaskDetail() { - render(); + render(); fireEvent.click(screen.getByText("Background task")); } @@ -113,30 +95,9 @@ function getDetailValue(label: string): string | null | undefined { return screen.getByText(label).parentElement?.lastElementChild?.textContent; } -describe("background task debug section", () => { +describe("BackgroundTaskDebugPanel", () => { beforeEach(() => { messageRows = []; - backgroundTasks = [task]; - }); - - it("holds a long task list back behind a see-more toggle", () => { - backgroundTasks = Array.from({ length: 7 }, (_, index) => ({ - ...task, - id: `task-${index}`, - title: `Task ${index}`, - })); - - render(); - - // Five rows, then the offer to see the other two. - expect(screen.getByText("Task 4")).toBeDefined(); - expect(screen.queryByText("Task 5")).toBeNull(); - - fireEvent.click(screen.getByText("managePanel.seeMore")); - expect(screen.getByText("Task 6")).toBeDefined(); - - fireEvent.click(screen.getByText("managePanel.seeLess")); - expect(screen.queryByText("Task 5")).toBeNull(); }); it("uses a single borderless scroll area that fills the remaining height", () => { diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index bffceaa756..d7ee324f3b 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -1,17 +1,26 @@ /** - * Dev-mode-only debug UI for background tasks, rendered as one category of - * the manage panel (see `manage-panel.tsx`). + * BackgroundTaskDebugPanel — dev-mode-only floating debug UI for background tasks. * - * is an overview of all background tasks (any status); - * selecting one opens , a slide-out panel showing that - * task's messages and todos via the reusable component. + * Renders a thin vertical handle on the right edge of the chat page. Hovering + * the handle opens an overview list of all background tasks (any status). + * Clicking a task opens a slide-out panel that shows the task's messages and + * todos via the reusable component. * - * This is a developer-only surface, so the strings here are not translated. + * Mounted from `features/chat/page.tsx` (only renders when `isDevMode` is true). + * + * This is a developer-only surface, so the user-facing strings here are not + * translated. */ /* eslint-disable i18next/no-literal-string */ import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { useIsDevMode } from "@/features/settings"; import { useBackgroundTaskState } from "@/lib/hooks/use-background-task-state"; import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; @@ -23,15 +32,85 @@ import { PauseCircle, X, } from "lucide-react"; -import { useMemo } from "react"; -import { createPortal } from "react-dom"; +import { useMemo, useState } from "react"; import { formatTokens } from "../lib/format-tokens"; -import { PanelSection, useCappedList } from "./panel-section"; -import { StatusDot, StatusSpinner } from "./status-dot"; -export const BackgroundTaskDetailTestId = "background-task-debug-detail"; +export function BackgroundTaskDebugPanel() { + const [isDevMode] = useIsDevMode(); + + if (isDevMode !== true) return null; + + return ; +} + +function BackgroundTaskDebugPanelInner() { + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [isListOpen, setIsListOpen] = useState(false); + + const handleSelectTask = (taskId: string) => { + setSelectedTaskId(taskId); + // Auto-hide the overview list as soon as a task is selected — the + // slide-out detail panel becomes the focus. + setIsListOpen(false); + }; + + return ( + <> + + + {/* + The visible gray bar stays small (`w-1.5 h-16`) so the UI is + unobtrusive, but the hit area is a much larger transparent + column (`w-5 h-40`) anchored to the right edge — hovering + anywhere within that column instantly opens the list. + */} + + + + + + + {selectedTaskId && ( + setSelectedTaskId(null)} + /> + )} + + ); +} -export function BackgroundTaskList({ +function BackgroundTaskList({ selectedTaskId, onSelect, }: { @@ -40,20 +119,24 @@ export function BackgroundTaskList({ }) { const store = useDefaultStore(); const backgroundTasks = store.useQuery(catalog.queries.backgroundTasks$); - // Tasks pile up faster than anything else in the panel, so this category is - // capped like the others instead of running off the bottom. - const { visibleItems, seeMoreButton } = useCappedList(backgroundTasks); return ( - +
+
+ + Background Tasks + + + {backgroundTasks.length} + +
{backgroundTasks.length === 0 ? ( -
+
No background tasks
) : ( - // No scroll container of its own: the panel's ScrollArea scrolls it. -
    - {visibleItems.map((task) => ( +
      + {backgroundTasks.map((task) => ( )} - {seeMoreButton} - +
); } @@ -83,45 +165,31 @@ function BackgroundTaskListItem({ type="button" onClick={onSelect} className={cn( - "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left", + "flex w-full flex-col items-start gap-1 px-3 py-2 text-left", "transition-colors hover:bg-muted/60", isSelected && "bg-muted", )} > - {/* The status dot carries the status; the id lives in the detail view. */} - - - {task.title || "(Untitled)"} - - - {formatRelative(task.updatedAt)} - +
+ + + {task.title || "(Untitled)"} + + + {formatRelative(task.updatedAt)} + +
+
+ {task.status} + + {task.id.slice(0, 8)} + +
); } -/** In a dense list, dots keep every row on the same visual rhythm. */ -function BackgroundTaskStatusDot({ task }: { task: Task }) { - switch (task.status) { - case "pending-model": - case "pending-tool": - return ; - case "pending-input": - return ; - case "completed": - return ; - case "failed": - return ; - default: - return ; - } -} - -/** - * The detail header shows a single task, so it can afford a shaped icon: it is - * far more legible than a dot when nothing else is around to compare it to. - */ function BackgroundTaskStatusIcon({ task }: { task: Task }) { switch (task.status) { case "pending-model": @@ -140,7 +208,7 @@ function BackgroundTaskStatusIcon({ task }: { task: Task }) { } } -export function BackgroundTaskDetail({ +function BackgroundTaskDetail({ taskId, onClose, }: { @@ -170,17 +238,14 @@ export function BackgroundTaskDetail({ ? latestAssistantMessage.metadata : undefined; - // Portaled to : the manage panel sits in a `z-20` stacking context, so - // an in-place `z-[60]` would still be trapped below the popover's portal. - return createPortal( + return (
@@ -247,8 +312,7 @@ export function BackgroundTaskDetail({ instantAutoScroll />
-
, - document.body, +
); } diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx index 0618747aa8..a4fad02c37 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.test.tsx @@ -175,6 +175,9 @@ vi.mock("./chat-input-form", () => ({ vi.mock("./error-message-view", () => ({ ErrorMessageView: () => null, })); +vi.mock("./manage-panel", () => ({ + ManagePanel: () => null, +})); vi.mock("./submit-review-button", () => ({ SubmitReviewsButton: () => null, })); diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx index dcf167e740..57222dc7be 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx @@ -64,6 +64,7 @@ import { } from "../lib/background-job-notification-queue"; import { ChatInputForm, type ChatInputFormHandle } from "./chat-input-form"; import { ErrorMessageView } from "./error-message-view"; +import { ManagePanel } from "./manage-panel"; import { SubmitReviewsButton } from "./submit-review-button"; import { CompleteSubtaskButton } from "./subtask"; @@ -580,6 +581,7 @@ export const ChatToolbar: React.FC = ({ todos={todos} getSystemPrompt={getSystemPrompt} /> + ({ useTranslation: () => ({ t: (key: string) => key }), })); -// The popover is rendered inline so the content is always assertable; opening +// The drawer is rendered inline so the content is always assertable; opening // and closing it is Radix's responsibility, not this component's. -vi.mock("@/components/ui/popover", () => ({ - Popover: ({ children }: { children: ReactNode }) => <>{children}, - PopoverContent: ({ children }: { children: ReactNode }) => <>{children}, - PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, -})); - -vi.mock("@/features/settings", () => ({ - useIsDevMode: () => [isDevMode], +vi.mock("@/components/ui/sheet", () => ({ + Sheet: ({ children }: { children: ReactNode }) => <>{children}, + SheetContent: ({ children }: { children: ReactNode }) => <>{children}, + SheetTitle: ({ children }: { children: ReactNode }) => <>{children}, + SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}, })); vi.mock("../hooks/use-job-list", () => ({ @@ -32,160 +28,87 @@ vi.mock("../hooks/use-job-list", () => ({ vi.mock("@/lib/hooks/use-open-background-job", () => ({ useOpenBackgroundJob: () => ({ - liveTerminal: undefined, ...openState, open, }), })); -vi.mock("./background-task-debug-panel", () => ({ - BackgroundTaskList: () =>
, - BackgroundTaskDetail: () => null, -})); - const renderPanel = () => render(); +const runningJob = { + backgroundJobId: "bgjob-cmd-1", + displayId: "%1", + title: "bun run dev", + command: "bun run dev", + status: "running" as const, + isActive: false, +}; + describe("ManagePanel", () => { beforeEach(() => { open.mockClear(); - isDevMode = false; - jobList = { pochi: [], terminals: [] }; + jobList = { pochi: [] }; openState = { isTerminalClosed: false, canOpenOutputFile: false }; }); - it("keeps the chip bare when there is nothing to manage", () => { + it("keeps the trigger bare when there is nothing running", () => { const { container } = renderPanel(); - const chip = screen.getByTestId("manage-panel-toggle"); + const trigger = screen.getByTestId("manage-panel-toggle"); // Icon only: no label, and no badge until something is actually running. - expect(chip.textContent).toBe(""); - expect(chip.getAttribute("aria-label")).toBe("managePanel.toggle"); + expect(trigger.textContent).toBe(""); + expect(trigger.getAttribute("aria-label")).toBe("managePanel.toggle"); + expect(trigger.querySelector(".bg-blue-500")).toBeNull(); expect(container.querySelector(".animate-spin")).toBeNull(); expect(screen.getByText("managePanel.empty")).toBeDefined(); expect(screen.queryByText("managePanel.pochiGroup")).toBeNull(); - expect(screen.queryByText("managePanel.terminalsGroup")).toBeNull(); }); - it("hides a category that has nothing in it", () => { - jobList = { - pochi: [], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "idle", - isActive: true, - }, - ], - }; - - renderPanel(); - - expect(screen.getByText("managePanel.terminalsGroup")).toBeDefined(); - expect(screen.queryByText("managePanel.pochiGroup")).toBeNull(); - expect(screen.queryByText("managePanel.empty")).toBeNull(); - }); - - it("badges the running rows only, not everything listed", () => { - jobList = { - pochi: [ - { - backgroundJobId: "bgjob-cmd-1", - displayId: "%1", - title: "bun run dev", - command: "bun run dev", - status: "running", - isActive: false, - }, - ], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "idle", - isActive: true, - }, - ], - }; + it("badges the trigger while a command is running", () => { + jobList = { pochi: [runningJob] }; const { container } = renderPanel(); - // Two rows are listed, but only one of them is working. - expect(screen.getByTestId("manage-panel-toggle").textContent).toBe("1"); + const trigger = screen.getByTestId("manage-panel-toggle"); + // A dot, not a count: the number lives next to the section title. + expect(trigger.textContent).toBe(""); + expect(trigger.querySelector(".bg-blue-500")).not.toBeNull(); expect(container.querySelector(".animate-spin")).not.toBeNull(); expect(screen.getByText("bun run dev")).toBeDefined(); - expect(screen.getByText("zsh")).toBeDefined(); + expect(screen.queryByText("managePanel.empty")).toBeNull(); }); - it("drops the badge when an open terminal is merely idle", () => { - jobList = { - pochi: [], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "idle", - isActive: true, - }, - ], - }; + it("drops the badge once every command has finished", () => { + jobList = { pochi: [{ ...runningJob, status: "completed" }] }; const { container } = renderPanel(); - expect(screen.getByTestId("manage-panel-toggle").textContent).toBe(""); - expect(container.querySelector(".animate-spin")).toBeNull(); - }); - - it("labels a terminal that has not reported a name yet", () => { - jobList = { - pochi: [], - terminals: [ - { - backgroundJobId: "term-1", - title: "", - status: "idle", - isActive: true, - }, - ], - }; - - renderPanel(); - expect( - screen.getByText("commandExecutionPanel.userTerminal"), - ).toBeDefined(); + screen.getByTestId("manage-panel-toggle").querySelector(".bg-blue-500"), + ).toBeNull(); + expect(container.querySelector(".animate-spin")).toBeNull(); }); it("collapses a section from its title", () => { - jobList = { - pochi: [], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "idle", - isActive: true, - }, - ], - }; + jobList = { pochi: [runningJob] }; renderPanel(); - const title = screen.getByText("managePanel.terminalsGroup"); + const title = screen.getByText("managePanel.pochiGroup"); fireEvent.click(title); - expect(screen.queryByText("zsh")).toBeNull(); + expect(screen.queryByText("bun run dev")).toBeNull(); fireEvent.click(title); - expect(screen.getByText("zsh")).toBeDefined(); + expect(screen.getByText("bun run dev")).toBeDefined(); }); it("holds a long category back behind a see-more toggle", () => { jobList = { - pochi: [], - terminals: Array.from({ length: 7 }, (_, index) => ({ - backgroundJobId: `term-${index}`, - title: `zsh ${index}`, - status: "idle" as const, + pochi: Array.from({ length: 7 }, (_, index) => ({ + backgroundJobId: `bgjob-cmd-${index}`, + displayId: `%${index}`, + title: `bun run dev ${index}`, + status: "completed" as const, isActive: false, })), }; @@ -193,61 +116,33 @@ describe("ManagePanel", () => { renderPanel(); // Five rows, then the offer to see the other two. - expect(screen.getByText("zsh 4")).toBeDefined(); - expect(screen.queryByText("zsh 5")).toBeNull(); + expect(screen.getByText("bun run dev 4")).toBeDefined(); + expect(screen.queryByText("bun run dev 5")).toBeNull(); fireEvent.click(screen.getByText("managePanel.seeMore")); - expect(screen.getByText("zsh 6")).toBeDefined(); + expect(screen.getByText("bun run dev 6")).toBeDefined(); fireEvent.click(screen.getByText("managePanel.seeLess")); - expect(screen.queryByText("zsh 5")).toBeNull(); + expect(screen.queryByText("bun run dev 5")).toBeNull(); }); it("explains a row by its command, and stays quiet without one", () => { jobList = { - pochi: [ - { - backgroundJobId: "bgjob-cmd-1", - displayId: "%1", - title: "bun run dev", - command: "bun run dev", - status: "running", - isActive: false, - }, - ], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "idle", - isActive: true, - }, - ], + pochi: [runningJob, { ...runningJob, backgroundJobId: "bgjob-cmd-2" }], }; + jobList.pochi[1].command = undefined; renderPanel(); - const jobRow = screen.getByLabelText("commandExecutionPanel.openJob"); - expect(jobRow.dataset.slot).toBe("tooltip-trigger"); - const terminalRow = screen.getByLabelText( - "commandExecutionPanel.openTerminal", + const [withCommand, withoutCommand] = screen.getAllByLabelText( + "commandExecutionPanel.openJob", ); - expect(terminalRow.dataset.slot).toBeUndefined(); + expect(withCommand.dataset.slot).toBe("tooltip-trigger"); + expect(withoutCommand.dataset.slot).toBeUndefined(); }); it("opens a job by clicking anywhere on its row", () => { - jobList = { - pochi: [ - { - backgroundJobId: "bgjob-cmd-1", - displayId: "%1", - title: "bun run dev", - status: "running", - isActive: false, - }, - ], - terminals: [], - }; + jobList = { pochi: [runningJob] }; renderPanel(); @@ -260,32 +155,13 @@ describe("ManagePanel", () => { it("drops the interaction once there is nothing left to open", () => { openState = { isTerminalClosed: true, canOpenOutputFile: false }; - jobList = { - pochi: [], - terminals: [ - { - backgroundJobId: "term-1", - title: "zsh", - status: "stopped", - isActive: false, - }, - ], - }; + jobList = { pochi: [{ ...runningJob, status: "stopped" }] }; renderPanel(); const row = screen.getByLabelText("commandExecutionPanel.terminalClosed"); expect(row.tagName).not.toBe("BUTTON"); - fireEvent.click(screen.getByText("zsh")); + fireEvent.click(screen.getByText("bun run dev")); expect(open).not.toHaveBeenCalled(); }); - - it("shows the background task category only in dev mode", () => { - renderPanel(); - expect(screen.queryByTestId("background-task-list")).toBeNull(); - - isDevMode = true; - renderPanel(); - expect(screen.getAllByTestId("background-task-list")).toHaveLength(1); - }); }); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx index ad52a98d8d..c87cfca4f3 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx @@ -1,36 +1,28 @@ /** * ManagePanel — the docked overview of everything running alongside the - * conversation: Pochi's background commands for this task, the user's open - * terminals, and (in dev mode) the background task list. - * - * The component is deliberately unaware of where it sits; `page.tsx` owns the - * positioning so the panel can later move into a column of its own. + * conversation: Pochi's background commands for this task. */ -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useIsDevMode } from "@/features/settings"; import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; import { cn } from "@/lib/utils"; import type { Message } from "@getpochi/livekit"; -import { ListIcon, TerminalIcon } from "lucide-react"; -import { Fragment, type ReactNode, useState } from "react"; +import { ListChevronsDownUpIcon, TerminalIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { useJobList } from "../hooks/use-job-list"; import type { JobListEntry, JobStatus } from "../lib/build-job-list"; -import { - BackgroundTaskDetail, - BackgroundTaskDetailTestId, - BackgroundTaskList, -} from "./background-task-debug-panel"; import { PanelSection, useCappedList } from "./panel-section"; import { StatusDot, StatusSpinner } from "./status-dot"; @@ -42,135 +34,66 @@ export function ManagePanel({ messages: Message[]; }) { const { t } = useTranslation(); - const [isDevMode] = useIsDevMode(); const [isOpen, setIsOpen] = useState(false); - const [debugTaskId, setDebugTaskId] = useState(null); - const { pochi, terminals } = useJobList(taskId, messages); - - // The badge is the trigger's whole status language: a number appears only - // while something is actually running, so no badge means nothing is working. - const runningCount = [...pochi, ...terminals].filter( - (job) => job.status === "running", - ).length; + const { pochi } = useJobList(taskId, messages); - // A category with nothing in it says nothing, so it is left out entirely and - // the separators are placed between whatever is left. - const sections: { key: string; node: ReactNode }[] = []; - if (pochi.length > 0) { - sections.push({ - key: "pochi", - node: , - }); - } - if (terminals.length > 0) { - sections.push({ - key: "terminals", - node: ( - - ), - }); - } - if (isDevMode === true) { - sections.push({ - key: "tasks", - node: ( - - ), - }); - } + // The badge is the trigger's whole status language: a blue dot appears only + // while something is actually running. + const runningCount = pochi.filter((job) => job.status === "running").length; return ( - <> - - {/* The name of the panel is carried by the tooltip; on a header row - that already holds the task title, an icon is quieter. */} - - - - - - - {t("managePanel.title")} - - { - const target = event.target as Element | null; - if ( - target?.closest?.(`[data-testid="${BackgroundTaskDetailTestId}"]`) - ) { - event.preventDefault(); - } - }} - > - {/* One scroll container for the whole panel, so every list here - shares the VS Code themed scrollbar. */} - -
- {sections.length === 0 ? ( -
- {t("managePanel.empty")} -
- ) : ( - sections.map((section, index) => ( - - {index > 0 && } - {section.node} - - )) + + {/* The name of the panel is carried by the tooltip; in the toolbar's + icon row, the trigger wears the same clothes as its neighbours. */} + + + +
-
-
-
- {/* - Rendered outside the popover: its content is positioned by Floating UI - with a transform, which would break the drawer's fixed positioning. - The drawer portals itself to so it also escapes this panel's - stacking context and can paint above the popover. - */} - {debugTaskId && ( - setDebugTaskId(null)} - /> - )} - + + + + {t("managePanel.title")} + + +
+ + {t("managePanel.title")} + +
+ {/* One scroll container for the whole panel, so every list here + shares the VS Code themed scrollbar. */} + +
+ {pochi.length === 0 ? ( +
+ {t("managePanel.empty")} +
+ ) : ( + + )} +
+
+
+ ); } -/** Separates two categories; inside a category, spacing does the grouping. */ -function SectionSeparator() { - return
; -} - function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { const { visibleItems, seeMoreButton } = useCappedList(jobs); @@ -190,9 +113,10 @@ function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { function JobRow({ job }: { job: JobListEntry }) { const { t } = useTranslation(); - const { liveTerminal, isTerminalClosed, canOpenOutputFile, open } = - useOpenBackgroundJob(job.backgroundJobId, job.outputFile); - const isUserTerminal = job.backgroundJobId.startsWith("term-"); + const { isTerminalClosed, canOpenOutputFile, open } = useOpenBackgroundJob( + job.backgroundJobId, + job.outputFile, + ); // Live terminal -> reveal and focus it; gone -> open its recorded output. const canOpen = !isTerminalClosed || canOpenOutputFile; @@ -200,29 +124,22 @@ function JobRow({ job }: { job: JobListEntry }) { ? canOpenOutputFile ? t("commandExecutionPanel.terminalClosedOpenOutput") : t("commandExecutionPanel.terminalClosed") - : isUserTerminal - ? t("commandExecutionPanel.openTerminal", { - name: liveTerminal?.name ?? job.title, - }) - : t("commandExecutionPanel.openJob", { - displayId: job.displayId ?? job.backgroundJobId, - }); + : t("commandExecutionPanel.openJob", { + displayId: job.displayId ?? job.backgroundJobId, + }); const rowClassName = "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left transition-colors"; const rowContent = ( <> - {/* A terminal opened seconds ago has no name and no command yet; the - same fallback the command panels use keeps the row readable. */} - - {job.title || t("commandExecutionPanel.userTerminal")} - + {job.title} - {isUserTerminal || !job.displayId ? ( - - ) : ( + {/* A job whose message was compacted away has lost its `%N`. */} + {job.displayId ? (
{job.displayId}
+ ) : ( + )}
diff --git a/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts index ecba8044f9..96c56570b6 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts @@ -5,7 +5,7 @@ import { useMemo } from "react"; import { type JobList, buildJobList } from "../lib/build-job-list"; /** - * The background work of this task, plus every terminal the user has open. + * The background work of this task. */ /** @useSignals */ export function useJobList(taskId: string, messages: Message[]): JobList { diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts index 2beddc86d1..71602d2900 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts @@ -111,83 +111,14 @@ describe("buildJobList", () => { expect(pochi.map((job) => job.displayId)).toEqual(["%2", "%1", undefined]); }); - it("lists every user terminal, including ones this task never touched", () => { - const { pochi, terminals } = buildJobList({ - messages: [], - notifications: [], - terminals: [ - terminal("term-1", { name: "zsh", isActive: true, isRunning: true }), - terminal("term-2", { name: "zsh" }), - // A background job belonging to some other task. - terminal("bgjob-cmd-other"), - ], - }); - - expect(pochi).toEqual([]); - expect(terminals).toEqual([ - { - backgroundJobId: "term-1", - title: "zsh", - status: "running", - outputFile: "/tmp/term-1.log", - isActive: true, - }, - { - backgroundJobId: "term-2", - title: "zsh", - // Alive but not executing anything. - status: "idle", - outputFile: "/tmp/term-2.log", - isActive: false, - }, - ]); - }); - - it("disambiguates bare shell terminals by their last command", () => { - const { terminals } = buildJobList({ - messages: [], - notifications: [], - terminals: [ - terminal("term-1", { name: "zsh", lastCommand: "bun run dev" }), - terminal("term-2", { name: "npm: dev", lastCommand: "npm run dev" }), - ], - }); - - expect(terminals.map((entry) => entry.title)).toEqual([ - "zsh · bun run dev", - "npm: dev", - ]); - // The command is kept apart from the title so the row can explain itself - // on hover even when the title already reads well. - expect(terminals.map((entry) => entry.command)).toEqual([ - "bun run dev", - "npm run dev", - ]); - }); - - it("still lists a terminal that has not reported a name yet", () => { - const { terminals } = buildJobList({ - messages: [], - notifications: [], - // A terminal the user just opened: VS Code has not resolved its shell - // process title, and nothing has run in it. - terminals: [terminal("term-1", { name: "", isActive: true })], - }); - - expect(terminals).toMatchObject([ - { backgroundJobId: "term-1", title: "", status: "idle" }, - ]); - }); - it("hides running jobs until the terminal list has loaded", () => { - const { pochi, terminals } = buildJobList({ + const { pochi } = buildJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [], terminals: undefined, }); expect(pochi).toEqual([]); - expect(terminals).toEqual([]); }); }); @@ -213,20 +144,14 @@ function terminal( { name = "zsh", isActive = false, - isRunning = false, - lastCommand, }: { name?: string; isActive?: boolean; - isRunning?: boolean; - lastCommand?: string; } = {}, ): TerminalSnapshot { return { name, isActive, - isRunning, - lastCommand, backgroundJobId, outputFile: `/tmp/${backgroundJobId}.log`, }; diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts index bbdea5d747..df61e9be00 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts @@ -1,10 +1,6 @@ -import { formatTerminalDisplayName } from "@/lib/terminal-display-name"; import type { BackgroundJobNotification } from "@getpochi/common"; import type { Message } from "@getpochi/livekit"; -/** User terminals are read-only and are never scoped to a task. */ -const UserTerminalPrefix = "term-"; - export type JobStatus = "running" | "idle" | "completed" | "failed" | "stopped"; export interface JobListEntry { @@ -27,9 +23,6 @@ export interface JobListEntry { export interface TerminalSnapshot { name: string; isActive: boolean; - /** Whether a command is executing in the terminal right now. */ - isRunning?: boolean; - lastCommand?: string; backgroundJobId?: string; outputFile?: string; } @@ -37,8 +30,6 @@ export interface TerminalSnapshot { export interface JobList { /** Background commands Pochi started for this task. */ pochi: JobListEntry[]; - /** Every terminal the user has open, regardless of task. */ - terminals: JobListEntry[]; } /** @@ -144,32 +135,9 @@ export function buildJobList({ }); } - const userTerminals = (terminals ?? []).flatMap((terminal) => - terminal.backgroundJobId?.startsWith(UserTerminalPrefix) - ? [ - { - backgroundJobId: terminal.backgroundJobId, - // A bare shell name ("zsh") says nothing; the last command does. - // A just-opened terminal has neither: VS Code only fills the name - // in once the shell process reports its title. The row falls back - // to a generic label rather than rendering blank. - title: - formatTerminalDisplayName(terminal.name, terminal.lastCommand) ?? - "", - command: terminal.lastCommand, - // A user terminal is always alive, so its dot tracks whether it is - // busy, not whether it exists. - status: terminal.isRunning ? "running" : "idle", - outputFile: terminal.outputFile, - isActive: terminal.isActive, - }, - ] - : [], - ); - // Newest command first: the one just started is the one being watched. The // `%N` labels keep counting from the start of the task, so the numbering // still matches the badges in the message list. Jobs whose message was // compacted away are the oldest, so they stay at the bottom. - return { pochi: [...pochi.reverse(), ...orphaned], terminals: userTerminals }; + return { pochi: [...pochi.reverse(), ...orphaned] }; } diff --git a/packages/vscode-webui/src/features/chat/page.tsx b/packages/vscode-webui/src/features/chat/page.tsx index 261a4c9d7a..b3694f9323 100644 --- a/packages/vscode-webui/src/features/chat/page.tsx +++ b/packages/vscode-webui/src/features/chat/page.tsx @@ -33,10 +33,10 @@ import { useSelectedModels, useSettingsStore, } from "../settings"; +import { BackgroundTaskDebugPanel } from "./components/background-task-debug-panel"; import { ChatArea } from "./components/chat-area"; import { ChatSkeleton } from "./components/chat-skeleton"; import { ChatToolbar } from "./components/chat-toolbar"; -import { ManagePanel } from "./components/manage-panel"; import { SubtaskHeader } from "./components/subtask"; import { useAbortBeforeNavigation } from "./hooks/use-abort-before-navigation"; import { useAutoOpenPlanFile } from "./hooks/use-auto-open-plan-file"; @@ -456,13 +456,6 @@ function Chat({ user, uid, info }: ChatProps) { className="absolute top-1 right-2 z-10" /> )} - {/* - The panel itself is position-agnostic; it is docked here so it can - later move into a column of its own without touching its internals. - */} -
- -
+
); } diff --git a/packages/vscode-webui/src/features/chat/styles.ts b/packages/vscode-webui/src/features/chat/styles.ts index ba14c5cb5a..684241edea 100644 --- a/packages/vscode-webui/src/features/chat/styles.ts +++ b/packages/vscode-webui/src/features/chat/styles.ts @@ -1,7 +1,4 @@ import { tw } from "@/lib/utils"; -// `relative` anchors the absolutely positioned headers/panels to the message -// column instead of the viewport, which only differs once the viewport is -// wider than `max-w-6xl`. -export const ChatContainerClassName = tw`relative mx-auto flex h-screen max-w-6xl flex-col`; +export const ChatContainerClassName = tw`mx-auto flex h-screen max-w-6xl flex-col`; export const ChatToolbarContainerClassName = tw`relative flex flex-col px-4`; diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index f3831d6fa9..88deb3cf82 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -5,7 +5,8 @@ "account": "Account", "help": "Help", "reset": "Reset", - "clear": "Clear" + "clear": "Clear", + "close": "Close" }, "error": { "somethingWentWrong": "Something went wrong", @@ -594,10 +595,9 @@ "openOutput": "Open output file" }, "managePanel": { - "title": "Background", - "toggle": "Show background jobs and terminals", + "title": "Background Jobs", + "toggle": "Show background jobs", "pochiGroup": "Commands", - "terminalsGroup": "Terminals", "empty": "Nothing running in the background", "seeMore": "See more", "seeLess": "See less" diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index a0382d4900..00d17bd6e4 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -3,7 +3,8 @@ "disable": "無効化", "openFolder": "フォルダーを開く", "account": "アカウント", - "help": "ヘルプ" + "help": "ヘルプ", + "close": "閉じる" }, "error": { "somethingWentWrong": "問題が発生しました", @@ -593,10 +594,9 @@ "openOutput": "出力ファイルを開く" }, "managePanel": { - "title": "バックグラウンド", - "toggle": "バックグラウンドジョブとターミナルを表示", + "title": "バックグラウンドジョブ", + "toggle": "バックグラウンドジョブを表示", "pochiGroup": "コマンド", - "terminalsGroup": "ターミナル", "empty": "バックグラウンドで実行中のものはありません", "seeMore": "もっと見る", "seeLess": "折りたたむ" diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index 1317747c81..88046c86a2 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -3,7 +3,8 @@ "disable": "비활성화", "openFolder": "폴더 열기", "account": "계정", - "help": "도움말" + "help": "도움말", + "close": "닫기" }, "error": { "somethingWentWrong": "문제가 발생했습니다", @@ -586,10 +587,9 @@ "openOutput": "출력 파일 열기" }, "managePanel": { - "title": "백그라운드", - "toggle": "백그라운드 작업 및 터미널 보기", + "title": "백그라운드 작업", + "toggle": "백그라운드 작업 보기", "pochiGroup": "명령", - "terminalsGroup": "터미널", "empty": "백그라운드에서 실행 중인 항목이 없습니다", "seeMore": "더 보기", "seeLess": "간단히 보기" diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index 18c141c32a..110c927287 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -3,7 +3,8 @@ "disable": "禁用", "openFolder": "打开文件夹", "account": "账户", - "help": "帮助" + "help": "帮助", + "close": "关闭" }, "error": { "somethingWentWrong": "出错了", @@ -591,10 +592,9 @@ "openOutput": "打开输出文件" }, "managePanel": { - "title": "后台", - "toggle": "查看后台任务与终端", + "title": "后台任务", + "toggle": "查看后台任务", "pochiGroup": "命令", - "terminalsGroup": "终端", "empty": "当前没有后台任务", "seeMore": "查看更多", "seeLess": "收起" diff --git a/packages/vscode/src/integrations/terminal/terminal-state.ts b/packages/vscode/src/integrations/terminal/terminal-state.ts index 0ba47ea7dc..27d7a66920 100644 --- a/packages/vscode/src/integrations/terminal/terminal-state.ts +++ b/packages/vscode/src/integrations/terminal/terminal-state.ts @@ -20,17 +20,6 @@ const logger = getLogger("TerminalState"); export interface TerminalInfo { name: string; isActive: boolean; - /** - * Whether a shell command is executing in the terminal right now. - * - * Only tracked for user terminals: background job terminals report their - * lifecycle through job notifications instead, and always read `false` here. - * Requires shell integration; without it no execution events arrive and the - * terminal stays permanently idle. - */ - isRunning?: boolean; - /** The most recent command captured from the terminal, if any. */ - lastCommand?: string; /** * A stable id associated with the terminal's output file. * @@ -62,7 +51,6 @@ export class TerminalState implements vscode.Disposable { private readonly runningExecutions = new Map< vscode.TerminalShellExecution, { - terminalId: string; history: TerminalHistoryManager; captureFinished: Promise; } @@ -126,13 +114,6 @@ export class TerminalState implements vscode.Disposable { vscode.window.onDidCloseTerminal(this.onTerminalClosed), ); this.disposables.push(TerminalJob.onDidCreate(this.onTerminalChanged)); - // A terminal is reported with an empty `name` until its shell process - // reports a title. Shell integration activating is the first event after - // that point, so it is when a freshly opened terminal finally has a name - // worth publishing. - this.disposables.push( - vscode.window.onDidChangeTerminalShellIntegration(this.onTerminalChanged), - ); this.disposables.push(TerminalJob.onDidDispose(this.onTerminalChanged)); this.disposables.push( TerminalJob.onDidChangeVisibility(this.onTerminalChanged), @@ -201,11 +182,7 @@ export class TerminalState implements vscode.Disposable { history, headerWritten, ); - this.runningExecutions.set(event.execution, { - terminalId: id, - history, - captureFinished, - }); + this.runningExecutions.set(event.execution, { history, captureFinished }); // Reflect the command immediately, then expose its output file only after // the reconstructed command header has actually reached the transcript. this.onTerminalChanged(); @@ -227,18 +204,8 @@ export class TerminalState implements vscode.Disposable { ? undefined : ExecutionError.create(`Command exited with code ${event.exitCode}.`); runningExecution.history.finalize(error); - // The terminal is idle again; without this the `isRunning` flag published - // on start would stay on until some unrelated terminal event fires. - this.onTerminalChanged(); }; - private hasRunningExecution(terminalId: string): boolean { - for (const execution of this.runningExecutions.values()) { - if (execution.terminalId === terminalId) return true; - } - return false; - } - private async captureExecutionOutput( execution: vscode.TerminalShellExecution, history: TerminalHistoryManager, @@ -297,19 +264,14 @@ export class TerminalState implements vscode.Disposable { .map((terminal) => { const id = this.getTerminalId(terminal); const job = TerminalJob.get(terminal); - let lastCommand: string | undefined; if (job) { listedJobIds.add(job.id); } else { - const history = TerminalHistoryManager.getOrCreate(id); - history.terminalName = terminal.name; - lastCommand = history.lastCommand; + TerminalHistoryManager.getOrCreate(id).terminalName = terminal.name; } return { name: terminal.name, isActive: terminal === vscode.window.activeTerminal, - isRunning: this.hasRunningExecution(id), - lastCommand, backgroundJobId: id, outputFile: this.getTerminalOutputFile(terminal), }; From 02a5e771df88946d33f61905dc44617d6dcd7490 Mon Sep 17 00:00:00 2001 From: liangfung Date: Tue, 1 Sep 2026 17:49:59 +0800 Subject: [PATCH 3/8] chore(vscode-webui): drop leftovers from superseded panel directions The background panel went through a terminals/tasks phase and a floating layout phase; this removes what those left behind so the PR contains only what the shipped drawer needs: the JobControlButton extraction (its only consumer was the file it came from), the never-produced "idle" status, the never-read TerminalSnapshot.name, and the radix-dialog range that dragged a shared transitive subtree forward. --- bun.lock | 2 +- packages/vscode-webui/package.json | 2 +- .../src/components/job-control-button.tsx | 52 -------- .../__stories__/manage-panel.stories.tsx | 10 +- .../features/chat/components/manage-panel.tsx | 119 ++++++++++++++---- .../chat/components/panel-section.tsx | 105 ---------------- .../features/chat/components/status-dot.tsx | 37 ------ .../features/chat/lib/build-job-list.test.ts | 9 +- .../src/features/chat/lib/build-job-list.ts | 8 +- .../components/command-execution-panel.tsx | 45 ++++++- 10 files changed, 143 insertions(+), 246 deletions(-) delete mode 100644 packages/vscode-webui/src/components/job-control-button.tsx delete mode 100644 packages/vscode-webui/src/features/chat/components/panel-section.tsx delete mode 100644 packages/vscode-webui/src/features/chat/components/status-dot.tsx diff --git a/bun.lock b/bun.lock index 3ff6d28e5a..4e9a7f1023 100644 --- a/bun.lock +++ b/bun.lock @@ -375,7 +375,7 @@ "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-hover-card": "^1.1.11", "@radix-ui/react-label": "^2.1.6", diff --git a/packages/vscode-webui/package.json b/packages/vscode-webui/package.json index 87c0165200..f586367512 100644 --- a/packages/vscode-webui/package.json +++ b/packages/vscode-webui/package.json @@ -41,7 +41,7 @@ "@radix-ui/react-checkbox": "^1.2.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-hover-card": "^1.1.11", "@radix-ui/react-label": "^2.1.6", diff --git a/packages/vscode-webui/src/components/job-control-button.tsx b/packages/vscode-webui/src/components/job-control-button.tsx deleted file mode 100644 index c6e3c12d69..0000000000 --- a/packages/vscode-webui/src/components/job-control-button.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import type { FC, ReactNode } from "react"; - -/** - * The badge in front of a job/terminal row: opens the live terminal, or -- - * once that terminal is gone -- its recorded output file. - */ -export const JobControlButton: FC<{ - label: string; - isActive?: boolean; - /** Nothing left to open: keep the badge, drop the interaction. */ - inert?: boolean; - onClick: () => void; - children: ReactNode; -}> = ({ label, isActive, inert, onClick, children }) => ( - - - {inert ? ( - // A plain span rather than a disabled button: disabled buttons swallow - // pointer events, which would hide the tooltip explaining why nothing - // can be opened anymore. - - {children} - - ) : ( - - )} - - - {label} - - -); diff --git a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx index baf5cdfa02..71019d8ed8 100644 --- a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx +++ b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx @@ -46,9 +46,7 @@ export const WithJobs: Story = { ]), ], }, - decorators: [ - withTerminals([terminal("bgjob-cmd-1", { name: "Pochi: bun run dev" })]), - ], + decorators: [withTerminals([terminal("bgjob-cmd-1")])], play: openPanel, }; @@ -98,12 +96,8 @@ function notificationPart(data: BackgroundJobNotification) { return { type: "data-background-job-notification", data }; } -function terminal( - backgroundJobId: string, - { name }: { name: string }, -): TerminalSnapshot { +function terminal(backgroundJobId: string): TerminalSnapshot { return { - name, isActive: false, backgroundJobId, outputFile: `/tmp/${backgroundJobId}.log`, diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx index c87cfca4f3..cae6800130 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx @@ -1,6 +1,6 @@ /** - * ManagePanel — the docked overview of everything running alongside the - * conversation: Pochi's background commands for this task. + * ManagePanel — a toolbar trigger opening a drawer that lists the background + * commands Pochi started for this task. */ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -18,13 +18,16 @@ import { import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; import { cn } from "@/lib/utils"; import type { Message } from "@getpochi/livekit"; -import { ListChevronsDownUpIcon, TerminalIcon } from "lucide-react"; +import { + ChevronRightIcon, + ListChevronsDownUpIcon, + Loader2, + TerminalIcon, +} from "lucide-react"; import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { useJobList } from "../hooks/use-job-list"; import type { JobListEntry, JobStatus } from "../lib/build-job-list"; -import { PanelSection, useCappedList } from "./panel-section"; -import { StatusDot, StatusSpinner } from "./status-dot"; export function ManagePanel({ taskId, @@ -94,20 +97,73 @@ export function ManagePanel({ ); } +/** How many rows the list shows before it has to be asked for the rest. */ +const CollapsedItemCount = 5; + function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { - const { visibleItems, seeMoreButton } = useCappedList(jobs); + const { t } = useTranslation(); + const [isCollapsed, setIsCollapsed] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const visibleJobs = isExpanded ? jobs : jobs.slice(0, CollapsedItemCount); return ( - -
    - {visibleItems.map((job) => ( -
  • - -
  • - ))} -
- {seeMoreButton} -
+
+ {/* The title row doubles as the collapse control, so there is no extra + button to aim at, and the count stays visible while folded. */} + + {!isCollapsed && ( + <> +
    + {visibleJobs.map((job) => ( +
  • + +
  • + ))} +
+ {jobs.length > CollapsedItemCount && ( + + )} + + )} +
); } @@ -164,8 +220,8 @@ function JobRow({ job }: { job: JobListEntry }) { ); // The hover reveals what the row is about, not what clicking it does: the - // command, like the panels in the message list. A terminal that has run - // nothing has nothing to add, so it gets no tooltip at all. + // command, like the panels in the message list. A job whose command is + // unknown has nothing to add, so it gets no tooltip at all. if (!job.command) return row; return ( @@ -208,16 +264,25 @@ function JobBadge({ ); } +/** + * The status marker in front of a row. A dot carries every resting status; + * work in progress gets a spinner, which is the only state that has to be + * recognizable at a glance. Both sit in the same column so titles line up. + */ function JobStatusIndicator({ status }: { status: JobStatus }) { - if (status === "running") return ; - return ( - + + {status === "running" ? ( + + ) : ( + + )} + ); } diff --git a/packages/vscode-webui/src/features/chat/components/panel-section.tsx b/packages/vscode-webui/src/features/chat/components/panel-section.tsx deleted file mode 100644 index 2d98280730..0000000000 --- a/packages/vscode-webui/src/features/chat/components/panel-section.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { cn } from "@/lib/utils"; -import { ChevronRightIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { useTranslation } from "react-i18next"; - -/** - * One category of the background panel. The title row doubles as the collapse - * control, so there is no extra button to aim at, and the count stays visible - * while the section is folded. - * - * Lives in its own file because both `manage-panel.tsx` and the dev-only - * `background-task-debug-panel.tsx` use it, and the former imports the latter. - */ -export function PanelSection({ - label, - count, - children, -}: { - label: string; - count: number; - children: ReactNode; -}) { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( -
- - {!isCollapsed && children} -
- ); -} - -/** How many rows a category shows before it has to be asked for the rest. */ -const CollapsedItemCount = 5; - -/** - * Caps a category's rows and hands back the control that reveals the rest, so - * every category in the panel truncates the same way. The state lives here, so - * one long category can be expanded without touching its neighbours. - */ -export function useCappedList(items: readonly T[]) { - const [isExpanded, setIsExpanded] = useState(false); - const hiddenCount = items.length - CollapsedItemCount; - - return { - visibleItems: isExpanded ? items : items.slice(0, CollapsedItemCount), - seeMoreButton: - hiddenCount > 0 ? ( - setIsExpanded((prev) => !prev)} - /> - ) : null, - }; -} - -function SeeMoreButton({ - isExpanded, - onToggle, -}: { - isExpanded: boolean; - onToggle: () => void; -}) { - const { t } = useTranslation(); - - return ( - - ); -} diff --git a/packages/vscode-webui/src/features/chat/components/status-dot.tsx b/packages/vscode-webui/src/features/chat/components/status-dot.tsx deleted file mode 100644 index b4ce184b09..0000000000 --- a/packages/vscode-webui/src/features/chat/components/status-dot.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Loader2 } from "lucide-react"; -import type { ReactNode } from "react"; - -/** - * The status marker in front of a row in the background panel. Shared so job - * rows, terminal rows and task rows all read their status the same way. - * - * A dot carries every resting status; work in progress gets a spinner, which - * is the only state that has to be recognizable at a glance. - */ -export function StatusDot({ className }: { className?: string }) { - return ( - - - - ); -} - -export function StatusSpinner({ className }: { className?: string }) { - return ( - - - - ); -} - -/** Keeps dots and spinners on the same column so row titles line up. */ -function IndicatorSlot({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts index 71602d2900..efe30956f2 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts @@ -141,16 +141,9 @@ function notificationPart(data: BackgroundJobNotification) { function terminal( backgroundJobId: string, - { - name = "zsh", - isActive = false, - }: { - name?: string; - isActive?: boolean; - } = {}, + { isActive = false }: { isActive?: boolean } = {}, ): TerminalSnapshot { return { - name, isActive, backgroundJobId, outputFile: `/tmp/${backgroundJobId}.log`, diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts index df61e9be00..18bd4015a0 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts @@ -1,17 +1,14 @@ import type { BackgroundJobNotification } from "@getpochi/common"; import type { Message } from "@getpochi/livekit"; -export type JobStatus = "running" | "idle" | "completed" | "failed" | "stopped"; +export type JobStatus = "running" | "completed" | "failed" | "stopped"; export interface JobListEntry { backgroundJobId: string; /** `%1`-style label, matching the badge shown inside the message list. */ displayId?: string; title: string; - /** - * The command behind the row, shown on hover. Absent for a terminal that has - * not run anything yet, which is exactly when there is nothing to say. - */ + /** The command behind the row, shown on hover. */ command?: string; status: JobStatus; /** Transcript to fall back to once the terminal is gone. */ @@ -21,7 +18,6 @@ export interface JobListEntry { /** The subset of `TerminalInfo` the list needs. */ export interface TerminalSnapshot { - name: string; isActive: boolean; backgroundJobId?: string; outputFile?: string; diff --git a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx index b9c24ab640..a3b6fc3364 100644 --- a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx +++ b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx @@ -1,4 +1,3 @@ -import { JobControlButton } from "@/components/job-control-button"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -101,6 +100,50 @@ const ToggleExpandButton: FC<{ expanded: boolean; onToggle: () => void }> = ({ ); }; +/** + * The badge in front of a job/terminal panel: opens the live terminal, or -- + * once that terminal is gone -- its recorded output file. + */ +const JobControlButton: FC<{ + label: string; + isActive?: boolean; + /** Nothing left to open: keep the badge, drop the interaction. */ + inert?: boolean; + onClick: () => void; + children: React.ReactNode; +}> = ({ label, isActive, inert, onClick, children }) => ( + + + {inert ? ( + // A plain span rather than a disabled button: disabled buttons swallow + // pointer events, which would hide the tooltip explaining why nothing + // can be opened anymore. + + {children} + + ) : ( + + )} + + + {label} + + +); + export const CommandPanelContainer: FC<{ icon: React.ReactNode; title: React.ReactNode; From 3074e44cbe7b088855a93aadff2685e0a078339b Mon Sep 17 00:00:00 2001 From: liangfung Date: Wed, 2 Sep 2026 14:21:27 +0800 Subject: [PATCH 4/8] update: background tasks --- .../background-task-debug-panel.test.tsx | 35 ++- .../background-task-debug-panel.tsx | 270 +++++------------- .../chat/components/manage-panel.test.tsx | 147 +++++++++- .../features/chat/components/manage-panel.tsx | 189 +++++++++--- .../features/chat/lib/build-job-list.test.ts | 12 + .../src/features/chat/lib/build-job-list.ts | 4 + .../vscode-webui/src/features/chat/page.tsx | 2 - .../components/command-execution-panel.tsx | 16 +- .../src/lib/background-job-status-label.ts | 20 ++ 9 files changed, 427 insertions(+), 268 deletions(-) create mode 100644 packages/vscode-webui/src/lib/background-job-status-label.ts diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx index b78c6d8b84..6abc25924b 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BackgroundTaskDebugPanel } from "./background-task-debug-panel"; +import { + BackgroundTaskDetail, + BackgroundTaskRow, +} from "./background-task-debug-panel"; const task = { id: "task-1", @@ -45,16 +47,6 @@ vi.mock("@/components/ui/button", () => ({ ), })); -vi.mock("@/components/ui/hover-card", () => ({ - HoverCard: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}, - HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, -})); - -vi.mock("@/features/settings", () => ({ - useIsDevMode: () => [true], -})); - vi.mock("@/lib/hooks/use-background-task-state", () => ({ useBackgroundTaskState: () => ({ backgroundTaskState: { @@ -87,15 +79,28 @@ vi.mock("@/lib/use-default-store", () => ({ })); function openTaskDetail() { - render(); - fireEvent.click(screen.getByText("Background task")); + render( {}} />); } function getDetailValue(label: string): string | null | undefined { return screen.getByText(label).parentElement?.lastElementChild?.textContent; } -describe("BackgroundTaskDebugPanel", () => { +describe("BackgroundTaskRow", () => { + it("names the task and hands its id back when picked", () => { + const onSelect = vi.fn(); + // biome-ignore lint/suspicious/noExplicitAny: the store rows are mocked. + render(); + + fireEvent.click(screen.getByText("Background task")); + expect(onSelect).toHaveBeenCalled(); + // A failed task rests on a dot, it does not spin. + expect(document.querySelector(".animate-spin")).toBeNull(); + expect(document.querySelector(".bg-destructive")).not.toBeNull(); + }); +}); + +describe("BackgroundTaskDetail", () => { beforeEach(() => { messageRows = []; }); diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index d7ee324f3b..2473163a86 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -1,219 +1,91 @@ /** - * BackgroundTaskDebugPanel — dev-mode-only floating debug UI for background tasks. + * Dev-mode background tasks, rendered as one group of the manage panel. * - * Renders a thin vertical handle on the right edge of the chat page. Hovering - * the handle opens an overview list of all background tasks (any status). - * Clicking a task opens a slide-out panel that shows the task's messages and - * todos via the reusable component. + * `useBackgroundTasks` + `BackgroundTaskRow` make up the list; picking a row + * takes the drawer to `BackgroundTaskDetail`, which shows that task's messages + * and todos through the reusable . * - * Mounted from `features/chat/page.tsx` (only renders when `isDevMode` is true). + * Nothing here is mounted unless dev mode is on, so the background tasks query + * never runs for anybody else. * - * This is a developer-only surface, so the user-facing strings here are not - * translated. + * This is a developer-only surface, so the strings here are not translated. */ -/* eslint-disable i18next/no-literal-string */ import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; import { Button } from "@/components/ui/button"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { useIsDevMode } from "@/features/settings"; import { useBackgroundTaskState } from "@/lib/hooks/use-background-task-state"; import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; import { type Message, type Task, catalog } from "@getpochi/livekit"; -import { - AlertCircle, - CheckCircle2, - Loader2, - PauseCircle, - X, -} from "lucide-react"; -import { useMemo, useState } from "react"; +import { ArrowLeftIcon, Loader2 } from "lucide-react"; +import { useMemo } from "react"; import { formatTokens } from "../lib/format-tokens"; -export function BackgroundTaskDebugPanel() { - const [isDevMode] = useIsDevMode(); +/** The panel's own section titles are translated; this one is dev-only. */ +export const BackgroundTasksLabel = "Tasks"; - if (isDevMode !== true) return null; - - return ; -} - -function BackgroundTaskDebugPanelInner() { - const [selectedTaskId, setSelectedTaskId] = useState(null); - const [isListOpen, setIsListOpen] = useState(false); - - const handleSelectTask = (taskId: string) => { - setSelectedTaskId(taskId); - // Auto-hide the overview list as soon as a task is selected — the - // slide-out detail panel becomes the focus. - setIsListOpen(false); - }; - - return ( - <> - - - {/* - The visible gray bar stays small (`w-1.5 h-16`) so the UI is - unobtrusive, but the hit area is a much larger transparent - column (`w-5 h-40`) anchored to the right edge — hovering - anywhere within that column instantly opens the list. - */} - - - - - - - {selectedTaskId && ( - setSelectedTaskId(null)} - /> - )} - - ); -} - -function BackgroundTaskList({ - selectedTaskId, - onSelect, -}: { - selectedTaskId: string | null; - onSelect: (taskId: string) => void; -}) { +export function useBackgroundTasks(): readonly Task[] { const store = useDefaultStore(); - const backgroundTasks = store.useQuery(catalog.queries.backgroundTasks$); - - return ( -
-
- - Background Tasks - - - {backgroundTasks.length} - -
- {backgroundTasks.length === 0 ? ( -
- No background tasks -
- ) : ( -
    - {backgroundTasks.map((task) => ( - onSelect(task.id)} - /> - ))} -
- )} -
- ); + return store.useQuery(catalog.queries.backgroundTasks$); } -function BackgroundTaskListItem({ +export function BackgroundTaskRow({ task, - isSelected, onSelect, }: { task: Task; - isSelected: boolean; onSelect: () => void; }) { return ( -
  • - -
  • + ); } -function BackgroundTaskStatusIcon({ task }: { task: Task }) { - switch (task.status) { - case "pending-model": - case "pending-tool": - return ( - - ); - case "pending-input": - return ; - case "completed": - return ; - case "failed": - return ; - default: - return ; - } +/** + * The same status language the command rows speak — spinner for work in + * progress, a dot for every resting state — over the task vocabulary. + */ +function BackgroundTaskStatusIndicator({ status }: { status: Task["status"] }) { + const isRunning = status === "pending-model" || status === "pending-tool"; + + return ( + + {isRunning ? ( + + ) : ( + + )} + + ); } -function BackgroundTaskDetail({ +export function BackgroundTaskDetail({ taskId, - onClose, + onBack, }: { taskId: string; - onClose: () => void; + onBack: () => void; }) { const store = useDefaultStore(); const task = store.useQuery(catalog.queries.makeTaskQuery(taskId)); @@ -239,17 +111,20 @@ function BackgroundTaskDetail({ : undefined; return ( -
    -
    +
    +
    + {/* The way back to the list, in the drawer's own header column. */} +
    - {task && } + {task && }
    {task?.title || "(Untitled)"} @@ -259,15 +134,6 @@ function BackgroundTaskDetail({
    -
    diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx index da0be479f7..e9c4eeabfa 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { JobList } from "../lib/build-job-list"; @@ -8,9 +8,26 @@ import { ManagePanel } from "./manage-panel"; const open = vi.fn(); let jobList: JobList = { pochi: [] }; let openState = { isTerminalClosed: false, canOpenOutputFile: false }; +let isDevMode = false; +let backgroundTasks: Array<{ id: string; title: string }> = []; + +// Radix positions the tooltip with one, and jsdom has none. +vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, +); vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + // Keys stand in for their translations; the exit code is interpolated so + // the hover text can be asserted. + t: (key: string, options?: { exitCode?: number }) => + options?.exitCode === undefined ? key : `${key} ${options.exitCode}`, + }), })); // The drawer is rendered inline so the content is always assertable; opening @@ -33,6 +50,42 @@ vi.mock("@/lib/hooks/use-open-background-job", () => ({ }), })); +vi.mock("@/features/settings", () => ({ + useIsDevMode: () => [isDevMode], +})); + +// The dev-only task surface has its own tests; here only its place in the +// panel matters. +vi.mock("./background-task-debug-panel", () => ({ + BackgroundTasksLabel: "Tasks", + useBackgroundTasks: () => backgroundTasks, + BackgroundTaskRow: ({ + task, + onSelect, + }: { + task: { title: string }; + onSelect: () => void; + }) => ( + + ), + BackgroundTaskDetail: ({ + taskId, + onBack, + }: { + taskId: string; + onBack: () => void; + }) => ( +
    + {taskId} + +
    + ), +})); + const renderPanel = () => render(); const runningJob = { @@ -49,6 +102,8 @@ describe("ManagePanel", () => { open.mockClear(); jobList = { pochi: [] }; openState = { isTerminalClosed: false, canOpenOutputFile: false }; + isDevMode = false; + backgroundTasks = []; }); it("keeps the trigger bare when there is nothing running", () => { @@ -141,6 +196,25 @@ describe("ManagePanel", () => { expect(withoutCommand.dataset.slot).toBeUndefined(); }); + it("says how a command ended on hover, exit code included", async () => { + jobList = { + pochi: [{ ...runningJob, status: "failed" as const, exitCode: 127 }], + }; + + renderPanel(); + + const row = screen.getByLabelText("commandExecutionPanel.openJob"); + fireEvent.pointerMove(row, { pointerType: "mouse" }); + + await waitFor(() => { + const tooltip = screen.getByRole("tooltip"); + expect(tooltip.textContent).toContain("bun run dev"); + expect(tooltip.textContent).toContain( + "backgroundJobNotifications.failed 127", + ); + }); + }); + it("opens a job by clicking anywhere on its row", () => { jobList = { pochi: [runningJob] }; @@ -164,4 +238,73 @@ describe("ManagePanel", () => { fireEvent.click(screen.getByText("bun run dev")); expect(open).not.toHaveBeenCalled(); }); + + it("keeps background tasks out of the panel outside dev mode", () => { + backgroundTasks = [{ id: "task-1", title: "A background task" }]; + + renderPanel(); + + expect(screen.queryByText("Tasks")).toBeNull(); + expect(screen.getByText("managePanel.empty")).toBeDefined(); + }); + + it("hides the task section in dev mode while there is no task", () => { + isDevMode = true; + + renderPanel(); + + expect(screen.queryByText("Tasks")).toBeNull(); + expect(screen.getByText("managePanel.empty")).toBeDefined(); + }); + + it("lists background tasks in dev mode", () => { + isDevMode = true; + backgroundTasks = [{ id: "task-1", title: "A background task" }]; + + renderPanel(); + + expect(screen.getByText("Tasks")).toBeDefined(); + expect(screen.getByText("A background task")).toBeDefined(); + expect(screen.queryByText("managePanel.empty")).toBeNull(); + }); + + it("takes the drawer to a task and back again", () => { + isDevMode = true; + backgroundTasks = [{ id: "task-1", title: "A background task" }]; + jobList = { pochi: [runningJob] }; + + renderPanel(); + + fireEvent.click(screen.getByText("A background task")); + // The detail covers the list rather than replacing it. + expect(screen.getByTestId("background-task-detail").textContent).toContain( + "task-1", + ); + expect(screen.getByTestId("background-task-layer").dataset.state).toBe( + "open", + ); + + fireEvent.click(screen.getByText("back")); + expect(screen.getByTestId("background-task-layer").dataset.state).toBe( + "closed", + ); + }); + + it("keeps the list as it was left while a task is open", () => { + isDevMode = true; + backgroundTasks = [{ id: "task-1", title: "A background task" }]; + jobList = { pochi: [runningJob] }; + + renderPanel(); + + // Fold the commands, then take a detour through a task detail. + fireEvent.click(screen.getByText("managePanel.pochiGroup")); + expect(screen.queryByText("bun run dev")).toBeNull(); + + fireEvent.click(screen.getByText("A background task")); + fireEvent.click(screen.getByText("back")); + + // The list was never unmounted, so it is still folded. + expect(screen.queryByText("bun run dev")).toBeNull(); + }); }); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx index cae6800130..8a3bfd274a 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx @@ -1,6 +1,6 @@ /** * ManagePanel — a toolbar trigger opening a drawer that lists the background - * commands Pochi started for this task. + * commands Pochi started for this task, plus its background tasks in dev mode. */ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -15,19 +15,27 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useIsDevMode } from "@/features/settings"; +import { getBackgroundJobStatusLabel } from "@/lib/background-job-status-label"; import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; import { cn } from "@/lib/utils"; -import type { Message } from "@getpochi/livekit"; +import type { Message, Task } from "@getpochi/livekit"; import { ChevronRightIcon, ListChevronsDownUpIcon, Loader2, TerminalIcon, } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { Children, type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { useJobList } from "../hooks/use-job-list"; import type { JobListEntry, JobStatus } from "../lib/build-job-list"; +import { + BackgroundTaskDetail, + BackgroundTaskRow, + BackgroundTasksLabel, + useBackgroundTasks, +} from "./background-task-debug-panel"; export function ManagePanel({ taskId, @@ -37,7 +45,13 @@ export function ManagePanel({ messages: Message[]; }) { const { t } = useTranslation(); + const [isDevMode] = useIsDevMode(); const [isOpen, setIsOpen] = useState(false); + // Picking a task slides its detail over the list. The list stays mounted + // underneath, so folded sections and scroll position survive the trip, and + // the id outlives the closing slide so the layer does not empty mid-flight. + const [detailTaskId, setDetailTaskId] = useState(null); + const [isDetailOpen, setIsDetailOpen] = useState(false); const { pochi } = useJobList(taskId, messages); // The badge is the trigger's whole status language: a blue dot appears only @@ -45,7 +59,18 @@ export function ManagePanel({ const runningCount = pochi.filter((job) => job.status === "running").length; return ( - + { + setIsOpen(open); + // Closing the drawer takes it back to the list, so it never reopens + // deep inside a task nobody asked about again. + if (!open) { + setIsDetailOpen(false); + setDetailTaskId(null); + } + }} + > {/* The name of the panel is carried by the tooltip; in the toolbar's icon row, the trigger wears the same clothes as its neighbours. */} @@ -79,32 +104,127 @@ export function ManagePanel({ {t("managePanel.title")}
    - {/* One scroll container for the whole panel, so every list here - shares the VS Code themed scrollbar. */} - -
    - {pochi.length === 0 ? ( -
    - {t("managePanel.empty")} -
    - ) : ( - +
    + {isDevMode === true ? ( + { + setDetailTaskId(id); + setIsDetailOpen(true); + }} + /> + ) : ( + + )} + {/* A layer rather than a second page: it slides in over the list and + back out again, and the list below it is never unmounted. */} +
    + {detailTaskId !== null && ( + setIsDetailOpen(false)} + /> )}
    - +
    ); } +const NoTasks: readonly Task[] = []; + +/** + * Background tasks live in a different store than the commands, and only dev + * mode ever shows them. Reading them from a separate component keeps that + * query from running for everybody else, since hooks cannot be conditional. + */ +function DevPanelBody({ + pochi, + onSelectTask, +}: { + pochi: JobListEntry[]; + onSelectTask: (taskId: string) => void; +}) { + const tasks = useBackgroundTasks(); + + return ; +} + +function PanelBody({ + pochi, + tasks, + onSelectTask, +}: { + pochi: JobListEntry[]; + tasks: readonly Task[]; + onSelectTask?: (taskId: string) => void; +}) { + const { t } = useTranslation(); + + // A group with nothing in it says nothing; an empty panel says it once. + if (pochi.length === 0 && tasks.length === 0) { + return ( +
    + {t("managePanel.empty")} +
    + ); + } + + return ( + /* One scroll container for the whole panel, so every list here shares the + VS Code themed scrollbar. */ + +
    + {pochi.length > 0 && ( + + {pochi.map((job) => ( +
  • + +
  • + ))} +
    + )} + {tasks.length > 0 && ( + + {tasks.map((task) => ( +
  • + onSelectTask?.(task.id)} + /> +
  • + ))} +
    + )} +
    +
    + ); +} + /** How many rows the list shows before it has to be asked for the rest. */ const CollapsedItemCount = 5; -function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { +function PanelGroup({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { const { t } = useTranslation(); const [isCollapsed, setIsCollapsed] = useState(false); const [isExpanded, setIsExpanded] = useState(false); - const visibleJobs = isExpanded ? jobs : jobs.slice(0, CollapsedItemCount); + const items = Children.toArray(children); + const visibleItems = isExpanded ? items : items.slice(0, CollapsedItemCount); return (
    @@ -131,19 +251,13 @@ function JobGroup({ label, jobs }: { label: string; jobs: JobListEntry[] }) { {label} - {jobs.length} + {items.length} {!isCollapsed && ( <> -
      - {visibleJobs.map((job) => ( -
    • - -
    • - ))} -
    - {jobs.length > CollapsedItemCount && ( +
      {visibleItems}
    + {items.length > CollapsedItemCount && (
    -
    ); } diff --git a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx index a3b6fc3364..b7f15c1659 100644 --- a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx +++ b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx @@ -6,13 +6,13 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useBackgroundJobInfo } from "@/features/chat"; +import { getBackgroundJobStatusLabel } from "@/lib/background-job-status-label"; import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; import { useDebounceState } from "@/lib/hooks/use-debounce-state"; import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; import { formatTerminalDisplayName } from "@/lib/terminal-display-name"; import { cn } from "@/lib/utils"; import { isVSCodeEnvironment, vscodeHost } from "@/lib/vscode"; -import type { TFunction } from "i18next"; import { CheckIcon, ChevronsDownUpIcon, @@ -433,20 +433,6 @@ const BackgroundJobStatus: FC<{ ); }; -function getBackgroundJobStatusLabel( - status: "completed" | "failed" | "stopped", - exitCode: number | undefined, - t: TFunction, -): string { - return status === "completed" - ? t("backgroundJobNotifications.completed", { exitCode: exitCode ?? 0 }) - : status === "failed" - ? exitCode === undefined - ? t("backgroundJobNotifications.failedNoExit") - : t("backgroundJobNotifications.failed", { exitCode }) - : t("backgroundJobNotifications.stopped"); -} - const OpenOutputFileButton: FC<{ outputFile: string }> = ({ outputFile }) => { const { t } = useTranslation(); const label = t("backgroundJobNotifications.openOutput"); diff --git a/packages/vscode-webui/src/lib/background-job-status-label.ts b/packages/vscode-webui/src/lib/background-job-status-label.ts new file mode 100644 index 0000000000..8b6460a52e --- /dev/null +++ b/packages/vscode-webui/src/lib/background-job-status-label.ts @@ -0,0 +1,20 @@ +import type { TFunction } from "i18next"; + +/** + * How a finished background job is worded, wherever it is shown: next to a + * notification's title, and on the hover of a row in the manage panel. The + * exit code is the part a status colour cannot carry. + */ +export function getBackgroundJobStatusLabel( + status: "completed" | "failed" | "stopped", + exitCode: number | undefined, + t: TFunction, +): string { + return status === "completed" + ? t("backgroundJobNotifications.completed", { exitCode: exitCode ?? 0 }) + : status === "failed" + ? exitCode === undefined + ? t("backgroundJobNotifications.failedNoExit") + : t("backgroundJobNotifications.failed", { exitCode }) + : t("backgroundJobNotifications.stopped"); +} From 94982e126f1b8b9d79d7d6e0f3f8d252c3f31ee4 Mon Sep 17 00:00:00 2001 From: liangfung Date: Thu, 3 Sep 2026 16:52:57 +0800 Subject: [PATCH 5/8] update --- .../background-task-debug-panel.tsx | 2 +- .../chat/components/manage-panel.test.tsx | 171 ++++++++++-- .../features/chat/components/manage-panel.tsx | 264 +++++++++++------- .../vscode-webui/src/i18n/locales/en.json | 4 +- .../vscode-webui/src/i18n/locales/jp.json | 4 +- .../vscode-webui/src/i18n/locales/ko.json | 4 +- .../vscode-webui/src/i18n/locales/zh.json | 4 +- .../src/lib/hooks/use-open-background-job.ts | 30 +- 8 files changed, 338 insertions(+), 145 deletions(-) diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index 2473163a86..4e4a19860e 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -22,7 +22,7 @@ import { useMemo } from "react"; import { formatTokens } from "../lib/format-tokens"; /** The panel's own section titles are translated; this one is dev-only. */ -export const BackgroundTasksLabel = "Tasks"; +export const BackgroundTasksLabel = "Background tasks"; export function useBackgroundTasks(): readonly Task[] { const store = useDefaultStore(); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx index e9c4eeabfa..06a3449b7b 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx @@ -5,7 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { JobList } from "../lib/build-job-list"; import { ManagePanel } from "./manage-panel"; -const open = vi.fn(); +const openTerminal = vi.fn(); +const openOutputFile = vi.fn(); let jobList: JobList = { pochi: [] }; let openState = { isTerminalClosed: false, canOpenOutputFile: false }; let isDevMode = false; @@ -46,7 +47,8 @@ vi.mock("../hooks/use-job-list", () => ({ vi.mock("@/lib/hooks/use-open-background-job", () => ({ useOpenBackgroundJob: () => ({ ...openState, - open, + openTerminal, + openOutputFile, }), })); @@ -57,7 +59,7 @@ vi.mock("@/features/settings", () => ({ // The dev-only task surface has its own tests; here only its place in the // panel matters. vi.mock("./background-task-debug-panel", () => ({ - BackgroundTasksLabel: "Tasks", + BackgroundTasksLabel: "Background tasks", useBackgroundTasks: () => backgroundTasks, BackgroundTaskRow: ({ task, @@ -88,6 +90,10 @@ vi.mock("./background-task-debug-panel", () => ({ const renderPanel = () => render(); +/** The rows in the order the panel lists them. */ +const rowTitles = () => + screen.getAllByRole("listitem").map((row) => row.textContent); + const runningJob = { backgroundJobId: "bgjob-cmd-1", displayId: "%1", @@ -97,9 +103,23 @@ const runningJob = { isActive: false, }; +// Numberless rows, so what a row reads is its title alone. +const runningRow = { + ...runningJob, + displayId: undefined, + title: "running", +}; +const finishedRow = { + ...runningRow, + backgroundJobId: "bgjob-cmd-2", + title: "done", + status: "completed" as const, +}; + describe("ManagePanel", () => { beforeEach(() => { - open.mockClear(); + openTerminal.mockClear(); + openOutputFile.mockClear(); jobList = { pochi: [] }; openState = { isTerminalClosed: false, canOpenOutputFile: false }; isDevMode = false; @@ -125,9 +145,9 @@ describe("ManagePanel", () => { const { container } = renderPanel(); const trigger = screen.getByTestId("manage-panel-toggle"); - // A dot, not a count: the number lives next to the section title. - expect(trigger.textContent).toBe(""); - expect(trigger.querySelector(".bg-blue-500")).not.toBeNull(); + // The badge carries the running count itself. + expect(trigger.textContent).toBe("1"); + expect(trigger.querySelector(".bg-blue-500")?.textContent).toBe("1"); expect(container.querySelector(".animate-spin")).not.toBeNull(); expect(screen.getByText("bun run dev")).toBeDefined(); expect(screen.queryByText("managePanel.empty")).toBeNull(); @@ -183,17 +203,23 @@ describe("ManagePanel", () => { it("explains a row by its command, and stays quiet without one", () => { jobList = { - pochi: [runningJob, { ...runningJob, backgroundJobId: "bgjob-cmd-2" }], + pochi: [ + runningJob, + { + ...runningJob, + backgroundJobId: "bgjob-cmd-2", + title: "bgjob-cmd-2", + command: undefined, + }, + ], }; - jobList.pochi[1].command = undefined; renderPanel(); - const [withCommand, withoutCommand] = screen.getAllByLabelText( - "commandExecutionPanel.openJob", + expect(screen.getByText("bun run dev").dataset.slot).toBe( + "tooltip-trigger", ); - expect(withCommand.dataset.slot).toBe("tooltip-trigger"); - expect(withoutCommand.dataset.slot).toBeUndefined(); + expect(screen.getByText("bgjob-cmd-2").dataset.slot).toBeUndefined(); }); it("says how a command ended on hover, exit code included", async () => { @@ -203,8 +229,9 @@ describe("ManagePanel", () => { renderPanel(); - const row = screen.getByLabelText("commandExecutionPanel.openJob"); - fireEvent.pointerMove(row, { pointerType: "mouse" }); + fireEvent.pointerMove(screen.getByText("bun run dev"), { + pointerType: "mouse", + }); await waitFor(() => { const tooltip = screen.getByRole("tooltip"); @@ -215,28 +242,112 @@ describe("ManagePanel", () => { }); }); - it("opens a job by clicking anywhere on its row", () => { + it("numbers a row without asking to be clicked", () => { + jobList = { pochi: [runningJob] }; + + renderPanel(); + + const displayId = screen.getByText("%1"); + expect(displayId.tagName).toBe("SPAN"); + expect(displayId.closest("button")).toBeNull(); + // A running row has controls, so the number steps aside for them. + expect(displayId.className).toContain("group-hover:opacity-0"); + }); + + it("keeps the number for a row that has nothing to press", () => { + jobList = { pochi: [{ ...runningJob, status: "stopped" as const }] }; + + renderPanel(); + + // Nothing comes to take its place, so it must not fade out either. + expect(screen.getByText("%1").className).not.toContain( + "group-hover:opacity-0", + ); + }); + + it("offers a running command its terminal and a way to stop it", () => { jobList = { pochi: [runningJob] }; renderPanel(); - const row = screen.getByLabelText("commandExecutionPanel.openJob"); - expect(row.tagName).toBe("BUTTON"); - fireEvent.click(screen.getByText("bun run dev")); - expect(open).toHaveBeenCalled(); - expect(screen.getByText("%1")).toBeDefined(); + fireEvent.click(screen.getByLabelText("managePanel.openTerminal")); + expect(openTerminal).toHaveBeenCalled(); + expect(screen.getByLabelText("managePanel.kill")).toBeDefined(); + expect( + screen.queryByLabelText("backgroundJobNotifications.openOutput"), + ).toBeNull(); }); - it("drops the interaction once there is nothing left to open", () => { + it("hides the terminal control once the terminal is gone", () => { openState = { isTerminalClosed: true, canOpenOutputFile: false }; - jobList = { pochi: [{ ...runningJob, status: "stopped" }] }; + jobList = { pochi: [runningJob] }; + + renderPanel(); + + expect(screen.queryByLabelText("managePanel.openTerminal")).toBeNull(); + // The process outlives its tab, so it can still be stopped. + expect(screen.getByLabelText("managePanel.kill")).toBeDefined(); + }); + + it("offers a finished command its output file", () => { + jobList = { + pochi: [ + { + ...runningJob, + status: "completed" as const, + outputFile: "/tmp/bgjob-cmd-1.log", + }, + ], + }; + + renderPanel(); + + const openOutput = screen.getByLabelText( + "backgroundJobNotifications.openOutput", + ); + // Only the pointer brings the controls out, and they hold their place in + // the row while hidden. jsdom has no `:hover`, so the reveal itself cannot + // be observed here — that they start hidden, and are still clickable, can. + const controls = openOutput.parentElement; + expect(controls?.className).toContain("opacity-0"); + expect(controls?.className).toContain("group-hover:opacity-100"); + + fireEvent.click(openOutput); + expect(openOutputFile).toHaveBeenCalled(); + expect(screen.queryByLabelText("managePanel.kill")).toBeNull(); + expect(screen.queryByLabelText("managePanel.openTerminal")).toBeNull(); + }); + + it("leaves a finished command without a transcript with nothing to press", () => { + jobList = { pochi: [{ ...runningJob, status: "stopped" as const }] }; + + renderPanel(); + + expect( + screen.queryByLabelText("backgroundJobNotifications.openOutput"), + ).toBeNull(); + }); + + it("lists running commands first", () => { + jobList = { pochi: [finishedRow, runningRow] }; renderPanel(); - const row = screen.getByLabelText("commandExecutionPanel.terminalClosed"); - expect(row.tagName).not.toBe("BUTTON"); - fireEvent.click(screen.getByText("bun run dev")); - expect(open).not.toHaveBeenCalled(); + expect(rowTitles()).toEqual(["running", "done"]); + }); + + it("keeps a command in place once it stops", () => { + jobList = { pochi: [finishedRow, runningRow] }; + + const { rerender } = renderPanel(); + + // Killing the top row must not drop it to the bottom under the pointer. + jobList = { + pochi: [finishedRow, { ...runningRow, status: "stopped" as const }], + }; + rerender(); + + expect(rowTitles()).toEqual(["running", "done"]); }); it("keeps background tasks out of the panel outside dev mode", () => { @@ -244,7 +355,7 @@ describe("ManagePanel", () => { renderPanel(); - expect(screen.queryByText("Tasks")).toBeNull(); + expect(screen.queryByText("Background tasks")).toBeNull(); expect(screen.getByText("managePanel.empty")).toBeDefined(); }); @@ -253,7 +364,7 @@ describe("ManagePanel", () => { renderPanel(); - expect(screen.queryByText("Tasks")).toBeNull(); + expect(screen.queryByText("Background tasks")).toBeNull(); expect(screen.getByText("managePanel.empty")).toBeDefined(); }); @@ -263,7 +374,7 @@ describe("ManagePanel", () => { renderPanel(); - expect(screen.getByText("Tasks")).toBeDefined(); + expect(screen.getByText("Background tasks")).toBeDefined(); expect(screen.getByText("A background task")).toBeDefined(); expect(screen.queryByText("managePanel.empty")).toBeNull(); }); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx index 8a3bfd274a..21ecc83af1 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/manage-panel.tsx @@ -22,11 +22,13 @@ import { cn } from "@/lib/utils"; import type { Message, Task } from "@getpochi/livekit"; import { ChevronRightIcon, + CircleStopIcon, + FileTextIcon, ListChevronsDownUpIcon, Loader2, TerminalIcon, } from "lucide-react"; -import { Children, type ReactNode, useState } from "react"; +import { Children, type ReactNode, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useJobList } from "../hooks/use-job-list"; import type { JobListEntry, JobStatus } from "../lib/build-job-list"; @@ -54,8 +56,8 @@ export function ManagePanel({ const [isDetailOpen, setIsDetailOpen] = useState(false); const { pochi } = useJobList(taskId, messages); - // The badge is the trigger's whole status language: a blue dot appears only - // while something is actually running. + // The badge is the trigger's whole status language: a blue count appears + // only while something is actually running. const runningCount = pochi.filter((job) => job.status === "running").length; return ( @@ -87,23 +89,27 @@ export function ManagePanel({ {runningCount > 0 && ( // Nudged outside the button box: the glyph is `size-5` in a - // 24px button, so a flush dot lands on top of its chevron. - + // 24px button, so a flush badge lands on top of its chevron. + // Kept at dot scale — a 12px pill that only grows sideways if + // the count reaches two digits. + + {runningCount} + )} {t("managePanel.title")} + {/* The top padding is all that is left of the header: it keeps the list + clear of the close button, which the drawer places over the content. */} -
    - - {t("managePanel.title")} - -
    + {/* The trigger's tooltip names the panel on screen; the title stays + for screen readers, which the drawer requires anyway. */} + {t("managePanel.title")}
    {isDevMode === true ? ( void; }) { const { t } = useTranslation(); + const commands = useRunningFirst(pochi); // A group with nothing in it says nothing; an empty panel says it once. if (pochi.length === 0 && tasks.length === 0) { @@ -184,9 +191,9 @@ function PanelBody({ VS Code themed scrollbar. */
    - {pochi.length > 0 && ( + {commands.length > 0 && ( - {pochi.map((job) => ( + {commands.map((job) => (
  • @@ -210,6 +217,29 @@ function PanelBody({ ); } +/** + * Running commands first, the incoming order kept within each half. + * + * A row is ranked by the status it had when it first appeared, and never + * ranked again: a command that stops — because it ended, or because it was + * just killed from that very row — keeps its place instead of dropping away + * under the pointer. The ranking lives as long as the open drawer, so the + * next visit sorts by what is true then. + */ +function useRunningFirst(jobs: JobListEntry[]): JobListEntry[] { + const ranks = useRef(new Map()); + + const rankOf = (job: JobListEntry) => { + const known = ranks.current.get(job.backgroundJobId); + if (known !== undefined) return known; + const rank = job.status === "running" ? 0 : 1; + ranks.current.set(job.backgroundJobId, rank); + return rank; + }; + + return [...jobs].sort((a, b) => rankOf(a) - rankOf(b)); +} + /** How many rows the list shows before it has to be asked for the rest. */ const CollapsedItemCount = 5; @@ -235,20 +265,27 @@ function PanelGroup({ aria-expanded={!isCollapsed} onClick={() => setIsCollapsed((prev) => !prev)} className={cn( - "flex items-center justify-between gap-2 rounded-md px-2 py-1", + "group flex items-center justify-between gap-2 rounded-md px-2 py-1", "text-left transition-colors hover:bg-muted/60", )} > + {/* No colour override: inheriting the drawer's own foreground keeps + the title at full contrast instead of the dimmer `--foreground`. */} + {label} + {/* Trailing the title, and quiet until the row is pointed at — the + titles read as headings rather than as a tree. A folded section + keeps its arrow out, since that is the only thing on screen + saying where its rows went. */} - {/* No colour override: inheriting the drawer's own foreground keeps - the title at full contrast instead of the dimmer `--foreground`. */} - {label} {items.length} @@ -283,109 +320,134 @@ function PanelGroup({ function JobRow({ job }: { job: JobListEntry }) { const { t } = useTranslation(); - const { isTerminalClosed, canOpenOutputFile, open } = useOpenBackgroundJob( - job.backgroundJobId, - job.outputFile, - ); - // Live terminal -> reveal and focus it; gone -> open its recorded output. - const canOpen = !isTerminalClosed || canOpenOutputFile; + const { isTerminalClosed, openTerminal, openOutputFile } = + useOpenBackgroundJob(job.backgroundJobId, job.outputFile); + const isRunning = job.status === "running"; - const label = isTerminalClosed - ? canOpenOutputFile - ? t("commandExecutionPanel.terminalClosedOpenOutput") - : t("commandExecutionPanel.terminalClosed") - : t("commandExecutionPanel.openJob", { - displayId: job.displayId ?? job.backgroundJobId, - }); + // The hover reveals what the row is about: the command, like the panels in + // the message list, and how it ended, in the same words the notification + // uses. A running job has neither an ending nor, if its message was + // compacted away, a command: then there is nothing to add. + const statusLabel = + job.status === "running" + ? undefined + : getBackgroundJobStatusLabel(job.status, job.exitCode, t); - const rowClassName = - "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left transition-colors"; - const rowContent = ( - <> - - {job.title} - - {/* A job whose message was compacted away has lost its `%N`. */} - {job.displayId ? ( -
    {job.displayId}
    - ) : ( - - )} -
    - + const title = ( + {job.title} ); - const row = canOpen ? ( - + {/* TODO: kill the process once command execution runs on a pty. */} + + + + ) : ( -
    - {rowContent} -
    + job.outputFile && ( + + + + ) ); - - // The hover reveals what the row is about, not what clicking it does: the - // command, like the panels in the message list, and how it ended, in the - // same words the notification uses. A running job has neither an ending nor, - // if its message was compacted away, a command: then there is nothing to add. - const statusLabel = - job.status === "running" - ? undefined - : getBackgroundJobStatusLabel(job.status, job.exitCode, t); - - if (!job.command && !statusLabel) return row; + const hasActions = isRunning || job.outputFile !== undefined; return ( - - {row} - - {job.command && ( - - {job.command} +
    + + {job.command || statusLabel ? ( + + {title} + + {job.command && ( + + {job.command} + + )} + {statusLabel && ( + + {statusLabel} + + )} + + + ) : ( + title + )} + {/* One cell at the end of the row, holding the number at rest and the + controls on hover. Stacking them means the title's truncation point + never moves, and the row keeps a single trailing column. */} + + {job.displayId && ( + + {job.displayId} )} - {statusLabel && ( - {statusLabel} + {hasActions && ( + // Quiet until asked, the way VS Code's own lists hold their inline + // actions back — and the keyboard gets them as soon as one is + // focused rather than having to hover. + + {actions} + )} - - + +
    ); } -/** - * The badge on a row. Purely decorative here: the whole row carries the - * interaction, so it must not be a nested button. - */ -function JobBadge({ - isActive, - inert, +/** A control at the end of a row, named by its tooltip. */ +function JobAction({ + label, + destructive, + onClick, children, }: { - isActive: boolean; - inert: boolean; + label: string; + destructive?: boolean; + onClick?: () => void; children: ReactNode; }) { return ( - - {children} - + + + + + {label} + ); } diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index 88deb3cf82..99cc10e001 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -597,7 +597,9 @@ "managePanel": { "title": "Background Jobs", "toggle": "Show background jobs", - "pochiGroup": "Commands", + "pochiGroup": "Background commands", + "openTerminal": "Open terminal", + "kill": "Kill process", "empty": "Nothing running in the background", "seeMore": "See more", "seeLess": "See less" diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index 00d17bd6e4..f289eed5d6 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -596,7 +596,9 @@ "managePanel": { "title": "バックグラウンドジョブ", "toggle": "バックグラウンドジョブを表示", - "pochiGroup": "コマンド", + "pochiGroup": "バックグラウンドコマンド", + "openTerminal": "ターミナルを開く", + "kill": "プロセスを終了", "empty": "バックグラウンドで実行中のものはありません", "seeMore": "もっと見る", "seeLess": "折りたたむ" diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index 88046c86a2..cbea16e13d 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -589,7 +589,9 @@ "managePanel": { "title": "백그라운드 작업", "toggle": "백그라운드 작업 보기", - "pochiGroup": "명령", + "pochiGroup": "백그라운드 명령", + "openTerminal": "터미널 열기", + "kill": "프로세스 종료", "empty": "백그라운드에서 실행 중인 항목이 없습니다", "seeMore": "더 보기", "seeLess": "간단히 보기" diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index 110c927287..8dbd554c68 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -594,7 +594,9 @@ "managePanel": { "title": "后台任务", "toggle": "查看后台任务", - "pochiGroup": "命令", + "pochiGroup": "后台命令", + "openTerminal": "打开终端", + "kill": "终止进程", "empty": "当前没有后台任务", "seeMore": "查看更多", "seeLess": "收起" diff --git a/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts b/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts index ead4e4680f..7e25f34742 100644 --- a/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts +++ b/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts @@ -22,18 +22,30 @@ export function useOpenBackgroundJob( const isTerminalClosed = terminals !== undefined && !liveTerminal; const canOpenOutputFile = isTerminalClosed && outputFile !== undefined; + const openTerminal = useCallback(() => { + openBackgroundJobTerminal?.(backgroundJobId); + }, [backgroundJobId, openBackgroundJobTerminal]); + + const openOutputFile = useCallback(() => { + if (outputFile) vscodeHost.openFile(outputFile); + }, [outputFile]); + const open = useCallback(() => { if (isTerminalClosed) { - if (outputFile) vscodeHost.openFile(outputFile); + openOutputFile(); return; } - openBackgroundJobTerminal?.(backgroundJobId); - }, [ - backgroundJobId, - isTerminalClosed, - openBackgroundJobTerminal, - outputFile, - ]); + openTerminal(); + }, [isTerminalClosed, openOutputFile, openTerminal]); - return { liveTerminal, isTerminalClosed, canOpenOutputFile, open }; + return { + liveTerminal, + isTerminalClosed, + canOpenOutputFile, + open, + // The two halves of `open`, for callers that offer them as separate + // controls rather than as one badge. + openTerminal, + openOutputFile, + }; } From fbba72b68e8bd4a462d35ec3e1d3851266e05f79 Mon Sep 17 00:00:00 2001 From: liangfung Date: Fri, 4 Sep 2026 17:36:30 +0800 Subject: [PATCH 6/8] update --- .../__stories__/manage-panel.stories.tsx | 31 +-- .../background-task-debug-panel.tsx | 49 ++-- .../chat/components/manage-panel.test.tsx | 195 ++++++++++--- .../features/chat/components/manage-panel.tsx | 257 +++++++++--------- .../chat/components/row-status-indicator.tsx | 30 ++ .../src/features/chat/hooks/use-job-list.ts | 11 +- .../features/chat/lib/build-job-list.test.ts | 96 ++++--- .../src/features/chat/lib/build-job-list.ts | 106 +++----- .../components/command-execution-panel.tsx | 48 ++-- .../vscode-webui/src/i18n/locales/en.json | 2 + .../vscode-webui/src/i18n/locales/jp.json | 2 + .../vscode-webui/src/i18n/locales/ko.json | 2 + .../vscode-webui/src/i18n/locales/zh.json | 2 + .../src/lib/hooks/use-open-background-job.ts | 51 ---- 14 files changed, 475 insertions(+), 407 deletions(-) create mode 100644 packages/vscode-webui/src/features/chat/components/row-status-indicator.tsx delete mode 100644 packages/vscode-webui/src/lib/hooks/use-open-background-job.ts diff --git a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx index 71019d8ed8..1079d5bde3 100644 --- a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx +++ b/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx @@ -1,11 +1,11 @@ import type { BackgroundJobNotification } from "@getpochi/common"; +import type { BackgroundCommands } from "@getpochi/common/vscode-webui-bridge"; import type { Message } from "@getpochi/livekit"; import { signal } from "@preact/signals-core"; import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, within } from "@storybook/test"; import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import type { TerminalSnapshot } from "../../lib/build-job-list"; import { ManagePanel } from "../manage-panel"; const meta = { @@ -17,8 +17,6 @@ const meta = { }, decorators: [ (Story) => ( - // The trigger lives in the chat toolbar in the app, so give it a - // stand-in row here.
    @@ -29,7 +27,6 @@ const meta = { export default meta; type Story = StoryObj; -/** Nothing running: the trigger stays put so the panel remains discoverable. */ export const Empty: Story = { play: openPanel, }; @@ -46,7 +43,7 @@ export const WithJobs: Story = { ]), ], }, - decorators: [withTerminals([terminal("bgjob-cmd-1")])], + decorators: [withRunningCommands({ "bgjob-cmd-1": { isVisible: true } })], play: openPanel, }; @@ -59,20 +56,20 @@ async function openPanel({ await expect(toggle).toHaveAttribute("data-state", "open"); } -/** - * Seeds the terminal query so the panel sees live terminals without a host. - */ -function withTerminals(terminals: TerminalSnapshot[]) { +/** Seeds the host query so the panel sees running commands without a host. */ +function withRunningCommands(backgroundCommands: BackgroundCommands) { + const noop = async () => {}; const data = { - terminals: signal(terminals), - openBackgroundJobTerminal: () => {}, + backgroundCommands: signal(backgroundCommands), + show: noop, + hide: noop, + close: noop, }; return (Story: React.ComponentType) => { const queryClient = useQueryClient(); - // Seed once, before the panel below mounts and fires the query. useState(() => { - queryClient.setQueryData(["visibleTerminals"], data); + queryClient.setQueryData(["backgroundCommands"], data); return null; }); return ; @@ -96,14 +93,6 @@ function notificationPart(data: BackgroundJobNotification) { return { type: "data-background-job-notification", data }; } -function terminal(backgroundJobId: string): TerminalSnapshot { - return { - isActive: false, - backgroundJobId, - outputFile: `/tmp/${backgroundJobId}.log`, - }; -} - function notification( backgroundJobId: string, status: BackgroundJobNotification["status"], diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index 4e4a19860e..67e4bd336a 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -1,14 +1,6 @@ /** - * Dev-mode background tasks, rendered as one group of the manage panel. - * - * `useBackgroundTasks` + `BackgroundTaskRow` make up the list; picking a row - * takes the drawer to `BackgroundTaskDetail`, which shows that task's messages - * and todos through the reusable . - * - * Nothing here is mounted unless dev mode is on, so the background tasks query - * never runs for anybody else. - * - * This is a developer-only surface, so the strings here are not translated. + * Dev-mode background tasks, rendered as one group of the manage panel. Being + * developer-only, the strings here are not translated. */ import { TaskThread, type TaskThreadSource } from "@/components/task-thread"; @@ -17,11 +9,11 @@ import { useBackgroundTaskState } from "@/lib/hooks/use-background-task-state"; import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; import { type Message, type Task, catalog } from "@getpochi/livekit"; -import { ArrowLeftIcon, Loader2 } from "lucide-react"; +import { ArrowLeftIcon } from "lucide-react"; import { useMemo } from "react"; import { formatTokens } from "../lib/format-tokens"; +import { RowStatusIndicator, type RowStatusTone } from "./row-status-indicator"; -/** The panel's own section titles are translated; this one is dev-only. */ export const BackgroundTasksLabel = "Background tasks"; export function useBackgroundTasks(): readonly Task[] { @@ -49,37 +41,29 @@ export function BackgroundTaskRow({ {task.title || "(Untitled)"} - + {formatRelative(task.updatedAt)} ); } -/** - * The same status language the command rows speak — spinner for work in - * progress, a dot for every resting state — over the task vocabulary. - */ function BackgroundTaskStatusIndicator({ status }: { status: Task["status"] }) { - const isRunning = status === "pending-model" || status === "pending-tool"; - return ( - - {isRunning ? ( - - ) : ( - - )} - + ); } +function statusTone(status: Task["status"]): RowStatusTone { + if (status === "completed") return "success"; + if (status === "failed") return "danger"; + if (status === "pending-input") return "warning"; + return "muted"; +} + export function BackgroundTaskDetail({ taskId, onBack, @@ -113,7 +97,6 @@ export function BackgroundTaskDetail({ return (
    - {/* The way back to the list, in the drawer's own header column. */} @@ -299,16 +266,10 @@ function PanelGroup({ type="button" onClick={() => setIsExpanded((prev) => !prev)} className={cn( - // Reads as a text link, not a row: only the label brightens on - // hover, so it never competes with the item rows for attention. - // Centring the label breaks the rows' left alignment, so it - // cannot be mistaken for one more item in the list. - "px-2 py-1 text-center text-muted-foreground text-xs", + "px-2 py-1 text-center text-muted-foreground text-sm", "transition-colors hover:text-foreground", )} > - {/* The section header already carries the true total, so the - toggle does not repeat it. */} {isExpanded ? t("managePanel.seeLess") : t("managePanel.seeMore")} )} @@ -320,16 +281,20 @@ function PanelGroup({ function JobRow({ job }: { job: JobListEntry }) { const { t } = useTranslation(); - const { isTerminalClosed, openTerminal, openOutputFile } = - useOpenBackgroundJob(job.backgroundJobId, job.outputFile); + const { backgroundCommands, show, hide, close } = useBackgroundCommands(); const isRunning = job.status === "running"; + const isVisible = backgroundCommands?.[job.backgroundJobId]?.isVisible; + const openOutputFile = () => { + if (job.outputFile) vscodeHost.openFile(job.outputFile); + }; + const open = isRunning + ? () => show?.(job.backgroundJobId) + : job.outputFile + ? openOutputFile + : undefined; - // The hover reveals what the row is about: the command, like the panels in - // the message list, and how it ended, in the same words the notification - // uses. A running job has neither an ending nor, if its message was - // compacted away, a command: then there is nothing to add. const statusLabel = - job.status === "running" + job.status === "running" || job.status === "finished" ? undefined : getBackgroundJobStatusLabel(job.status, job.exitCode, t); @@ -337,49 +302,80 @@ function JobRow({ job }: { job: JobListEntry }) { {job.title} ); - // What the row can be asked to do. A running command can be watched and - // stopped; a finished one can only be read back, and only if its transcript - // was kept. const actions = isRunning ? ( <> - {/* The process outlives its terminal tab, so the tab is only offered - while there is one to show. */} - {!isTerminalClosed && ( - - - - )} - {/* TODO: kill the process once command execution runs on a pty. */} - - + + isVisible ? hide?.(job.backgroundJobId) : show?.(job.backgroundJobId) + } + > + {isVisible ? ( + + ) : ( + + )} - - ) : ( - job.outputFile && ( close?.(job.backgroundJobId)} > - + - ) + + ) : ( + <> + {job.outputFile && ( + + + + )} + {job.command && } + ); - const hasActions = isRunning || job.outputFile !== undefined; + const hasActions = + isRunning || job.outputFile !== undefined || job.command !== undefined; return ( -
    - +
    { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + open(); + } + : undefined + } + className={cn( + "group flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1 transition-colors hover:bg-muted/60", + open && + "cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + )} + > + {job.command || statusLabel ? ( {title} {job.command && ( - + {job.command} )} {statusLabel && ( - + {statusLabel} )} @@ -388,15 +384,14 @@ function JobRow({ job }: { job: JobListEntry }) { ) : ( title )} - {/* One cell at the end of the row, holding the number at rest and the - controls on hover. Stacking them means the title's truncation point - never moves, and the row keeps a single trailing column. */} - + {job.displayId && ( )} {hasActions && ( - // Quiet until asked, the way VS Code's own lists hold their inline - // actions back — and the keyboard gets them as soon as one is - // focused rather than having to hover. - + {actions} )} @@ -417,7 +409,6 @@ function JobRow({ job }: { job: JobListEntry }) { ); } -/** A control at the end of a row, named by its tooltip. */ function JobAction({ label, destructive, @@ -437,10 +428,15 @@ function JobAction({ variant="ghost" size="icon" aria-label={label} - onClick={onClick} + onClick={(event) => { + event.stopPropagation(); + onClick?.(); + }} className={cn( - "size-5 rounded-sm text-muted-foreground hover:text-foreground", - destructive && "hover:text-destructive", + // The `dark:` twins displace the ghost variant's own dark hover. + "size-6 rounded-sm text-muted-foreground hover:bg-foreground/10 hover:text-foreground dark:hover:bg-foreground/10", + destructive && + "hover:bg-destructive/15 hover:text-destructive dark:hover:bg-destructive/25", )} > {children} @@ -451,25 +447,32 @@ function JobAction({ ); } -/** - * The status marker in front of a row. A dot carries every resting status; - * work in progress gets a spinner, which is the only state that has to be - * recognizable at a glance. Both sit in the same column so titles line up. - */ -function JobStatusIndicator({ status }: { status: JobStatus }) { +function CopyCommandAction({ command }: { command: string }) { + const { t } = useTranslation(); + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); + return ( - - {status === "running" ? ( - + { + if (!isCopied) copyToClipboard(command); + }} + > + {isCopied ? ( + ) : ( - + )} - + ); } + +function statusTone(status: JobStatus): RowStatusTone { + if (status === "completed") return "success"; + if (status === "failed") return "danger"; + return "muted"; +} diff --git a/packages/vscode-webui/src/features/chat/components/row-status-indicator.tsx b/packages/vscode-webui/src/features/chat/components/row-status-indicator.tsx new file mode 100644 index 0000000000..e687523a7c --- /dev/null +++ b/packages/vscode-webui/src/features/chat/components/row-status-indicator.tsx @@ -0,0 +1,30 @@ +/** The status marker shared by every manage-panel row. */ +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; + +export type RowStatusTone = "success" | "danger" | "warning" | "muted"; + +export function RowStatusIndicator({ + isRunning, + tone, +}: { + isRunning: boolean; + tone: RowStatusTone; +}) { + return ( + + {isRunning ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts index 96c56570b6..3ce18e24bc 100644 --- a/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts +++ b/packages/vscode-webui/src/features/chat/hooks/use-job-list.ts @@ -1,19 +1,16 @@ +import { useBackgroundCommands } from "@/lib/hooks/use-background-commands"; import { useBackgroundJobNotifications } from "@/lib/hooks/use-background-job-notifications"; -import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; import type { Message } from "@getpochi/livekit"; import { useMemo } from "react"; import { type JobList, buildJobList } from "../lib/build-job-list"; -/** - * The background work of this task. - */ /** @useSignals */ export function useJobList(taskId: string, messages: Message[]): JobList { - const { terminals } = useVisibleTerminals(); + const { backgroundCommands } = useBackgroundCommands(); const { notifications } = useBackgroundJobNotifications(taskId); return useMemo( - () => buildJobList({ messages, notifications, terminals }), - [messages, notifications, terminals], + () => buildJobList({ messages, notifications, backgroundCommands }), + [messages, notifications, backgroundCommands], ); } diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts index 53854aeea8..9e11ee5948 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts @@ -1,14 +1,14 @@ import type { BackgroundJobNotification } from "@getpochi/common"; -import type { Message } from "@getpochi/livekit"; import { describe, expect, it } from "vitest"; -import { type TerminalSnapshot, buildJobList } from "./build-job-list"; +import type { Message } from "@getpochi/livekit"; +import { buildJobList } from "./build-job-list"; describe("buildJobList", () => { - it("lists a running job from its live terminal", () => { + it("lists a command the host still has a process for as running", () => { const { pochi } = buildJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [], - terminals: [terminal("bgjob-cmd-1", { isActive: true })], + backgroundCommands: { "bgjob-cmd-1": { isVisible: true } }, }); expect(pochi).toEqual([ @@ -19,7 +19,6 @@ describe("buildJobList", () => { command: "bun run dev", status: "running", outputFile: "/tmp/bgjob-cmd-1.log", - isActive: true, }, ]); }); @@ -32,9 +31,8 @@ describe("buildJobList", () => { notificationPart(notification("bgjob-cmd-1", "failed")), ]), ], - // The host copy is dropped once it has been delivered as a message part. notifications: [], - terminals: [], + backgroundCommands: {}, }); expect(pochi).toEqual([ @@ -45,7 +43,6 @@ describe("buildJobList", () => { command: "bun run dev", status: "failed", outputFile: "/tmp/bgjob-cmd-1.log", - isActive: false, }, ]); }); @@ -54,7 +51,7 @@ describe("buildJobList", () => { const { pochi } = buildJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [notification("bgjob-cmd-1", "completed")], - terminals: [], + backgroundCommands: {}, }); expect(pochi).toMatchObject([ @@ -66,17 +63,51 @@ describe("buildJobList", () => { const { pochi } = buildJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [notification("bgjob-cmd-1", "failed", 127)], - terminals: [], + backgroundCommands: {}, }); expect(pochi).toMatchObject([{ status: "failed", exitCode: 127 }]); }); + it("still lists a gone command nothing reported an ending for", () => { + const { pochi } = buildJobList({ + messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + notifications: [], + backgroundCommands: {}, + }); + + expect(pochi).toMatchObject([ + { + backgroundJobId: "bgjob-cmd-1", + status: "finished", + exitCode: undefined, + outputFile: "/tmp/bgjob-cmd-1.log", + }, + ]); + }); + + it("lists a command promoted from the foreground, background flag or not", () => { + const { pochi } = buildJobList({ + messages: [ + message([ + { + ...executeCommandPart("bgjob-cmd-1", "bun run dev"), + input: { command: "bun run dev" }, + }, + ]), + ], + notifications: [], + backgroundCommands: { "bgjob-cmd-1": { isVisible: false } }, + }); + + expect(pochi).toMatchObject([{ status: "running", title: "bun run dev" }]); + }); + it("surfaces a notification whose executeCommand part is gone", () => { const { pochi } = buildJobList({ messages: [], notifications: [notification("bgjob-cmd-9", "stopped")], - terminals: [], + backgroundCommands: {}, }); expect(pochi).toEqual([ @@ -86,21 +117,10 @@ describe("buildJobList", () => { command: "run bgjob-cmd-9", status: "stopped", outputFile: "/tmp/bgjob-cmd-9.log", - isActive: false, }, ]); }); - it("drops a job that has neither a terminal nor a notification", () => { - const { pochi } = buildJobList({ - messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], - notifications: [], - terminals: [], - }); - - expect(pochi).toEqual([]); - }); - it("lists the newest job first, numbered like the badges in the message list", () => { const { pochi } = buildJobList({ messages: [ @@ -112,23 +132,28 @@ describe("buildJobList", () => { notifications: [ notification("bgjob-cmd-1", "completed"), notification("bgjob-cmd-2", "completed"), - // Started before both, but its message was compacted away. notification("bgjob-cmd-9", "completed"), ], - terminals: [], + backgroundCommands: {}, }); expect(pochi.map((job) => job.displayId)).toEqual(["%2", "%1", undefined]); }); - it("hides running jobs until the terminal list has loaded", () => { + it("falls back to what the notifications know while the host table loads", () => { const { pochi } = buildJobList({ - messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], + messages: [ + message([ + executeCommandPart("bgjob-cmd-1", "bun run dev"), + executeCommandPart("bgjob-cmd-2", "bun run build"), + notificationPart(notification("bgjob-cmd-2", "completed")), + ]), + ], notifications: [], - terminals: undefined, + backgroundCommands: undefined, }); - expect(pochi).toEqual([]); + expect(pochi.map((job) => job.status)).toEqual(["completed", "finished"]); }); }); @@ -141,7 +166,9 @@ function executeCommandPart(backgroundJobId: string, command: string) { type: "tool-executeCommand", state: "output-available", input: { command, background: true }, - output: { _meta: { backgroundJobId } }, + output: { + _meta: { backgroundJobId, outputFile: `/tmp/${backgroundJobId}.log` }, + }, }; } @@ -149,17 +176,6 @@ function notificationPart(data: BackgroundJobNotification) { return { type: "data-background-job-notification", data }; } -function terminal( - backgroundJobId: string, - { isActive = false }: { isActive?: boolean } = {}, -): TerminalSnapshot { - return { - isActive, - backgroundJobId, - outputFile: `/tmp/${backgroundJobId}.log`, - }; -} - function notification( backgroundJobId: string, status: BackgroundJobNotification["status"], diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts index e3ce458449..a9a3f810a3 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-job-list.ts @@ -1,55 +1,39 @@ import type { BackgroundJobNotification } from "@getpochi/common"; +import type { BackgroundCommands } from "@getpochi/common/vscode-webui-bridge"; import type { Message } from "@getpochi/livekit"; -export type JobStatus = "running" | "completed" | "failed" | "stopped"; +export type JobStatus = + | "running" + | "completed" + | "failed" + | "stopped" + | "finished"; export interface JobListEntry { backgroundJobId: string; - /** `%1`-style label, matching the badge shown inside the message list. */ displayId?: string; title: string; - /** The command behind the row, shown on hover. */ command?: string; status: JobStatus; - /** How a finished command ended, which its status colour cannot say. */ exitCode?: number; - /** Transcript to fall back to once the terminal is gone. */ - outputFile?: string; - isActive: boolean; -} - -/** The subset of `TerminalInfo` the list needs. */ -export interface TerminalSnapshot { - isActive: boolean; - backgroundJobId?: string; outputFile?: string; } export interface JobList { - /** Background commands Pochi started for this task. */ pochi: JobListEntry[]; } -/** - * Collects the background work worth surfacing in the manage panel. - * - * Pochi jobs are task-scoped, which falls out of only ever looking at this - * task's messages. Their lifecycle is split across two complementary sources: - * a notification waits in the host store until it has been delivered as a - * `data-background-job-notification` message part, at which point the host - * copy is dropped. Reading only one of them loses finished jobs, so both are - * merged here. - */ +/** Collects the background commands Pochi started for this task. */ export function buildJobList({ messages, notifications, - terminals, + backgroundCommands, }: { messages: readonly Message[]; notifications: readonly BackgroundJobNotification[]; - terminals: readonly TerminalSnapshot[] | undefined; + backgroundCommands: BackgroundCommands | undefined; }): JobList { - const commands = new Map(); + const commands = new Map(); const finished = new Map(); for (const message of messages) { @@ -57,14 +41,15 @@ export function buildJobList({ if ( part.type === "tool-executeCommand" && part.state !== "input-streaming" && - part.input?.background === true && part.output?._meta?.backgroundJobId ) { - const backgroundJobId = part.output._meta.backgroundJobId; - // First occurrence wins so the `%N` numbering stays stable, matching - // `useBackgroundJobDisplay`. + const { backgroundJobId, outputFile } = part.output._meta; + // First occurrence wins so the `%N` numbering stays stable. if (!commands.has(backgroundJobId)) { - commands.set(backgroundJobId, part.input.command); + commands.set(backgroundJobId, { + command: part.input?.command, + outputFile, + }); } } else if (part.type === "data-background-job-notification") { finished.set(part.data.backgroundJobId, part.data); @@ -75,48 +60,22 @@ export function buildJobList({ finished.set(notification.backgroundJobId, notification); } - const liveJobs = new Map(); - for (const terminal of terminals ?? []) { - if (terminal.backgroundJobId) { - liveJobs.set(terminal.backgroundJobId, terminal); - } - } - const pochi: JobListEntry[] = []; let index = 0; - for (const [backgroundJobId, command] of commands) { + for (const [backgroundJobId, meta] of commands) { index += 1; - const displayId = `%${index}`; const notification = finished.get(backgroundJobId); - if (notification) { - const resolvedCommand = command ?? notification.command; - pochi.push({ - backgroundJobId, - displayId, - title: resolvedCommand ?? backgroundJobId, - command: resolvedCommand, - status: notification.status, - exitCode: notification.exitCode, - outputFile: notification.outputFile, - isActive: false, - }); - continue; - } - - const live = liveJobs.get(backgroundJobId); - if (live) { - pochi.push({ - backgroundJobId, - displayId, - title: command ?? backgroundJobId, - command, - status: "running", - outputFile: live.outputFile, - isActive: live.isActive, - }); - } - // Otherwise the job left nothing to act on: its terminal is gone and no - // completion notification survived. Listing it would only offer a dead row. + const command = meta.command ?? notification?.command; + const isRunning = backgroundCommands?.[backgroundJobId] !== undefined; + pochi.push({ + backgroundJobId, + displayId: `%${index}`, + title: command ?? backgroundJobId, + command, + status: isRunning ? "running" : (notification?.status ?? "finished"), + exitCode: isRunning ? undefined : notification?.exitCode, + outputFile: meta.outputFile ?? notification?.outputFile, + }); } // A notification can outlive the `executeCommand` part that started it, @@ -131,13 +90,10 @@ export function buildJobList({ status: notification.status, exitCode: notification.exitCode, outputFile: notification.outputFile, - isActive: false, }); } - // Newest command first: the one just started is the one being watched. The - // `%N` labels keep counting from the start of the task, so the numbering - // still matches the badges in the message list. Jobs whose message was - // compacted away are the oldest, so they stay at the bottom. + // Newest command first, with the `%N` labels still counting from the start + // of the task. return { pochi: [...pochi.reverse(), ...orphaned] }; } diff --git a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx index b7f15c1659..e512ae2f58 100644 --- a/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx +++ b/packages/vscode-webui/src/features/tools/components/command-execution-panel.tsx @@ -7,9 +7,10 @@ import { } from "@/components/ui/tooltip"; import { useBackgroundJobInfo } from "@/features/chat"; import { getBackgroundJobStatusLabel } from "@/lib/background-job-status-label"; +import { useBackgroundCommands } from "@/lib/hooks/use-background-commands"; import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; import { useDebounceState } from "@/lib/hooks/use-debounce-state"; -import { useOpenBackgroundJob } from "@/lib/hooks/use-open-background-job"; +import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; import { formatTerminalDisplayName } from "@/lib/terminal-display-name"; import { cn } from "@/lib/utils"; import { isVSCodeEnvironment, vscodeHost } from "@/lib/vscode"; @@ -238,12 +239,26 @@ export const BackgroundJobPanel: FC<{ const [expanded, setExpanded] = useState(false); const toggleExpanded = () => setExpanded((prev) => !prev); const info = useBackgroundJobInfo(backgroundJobId); - const { - liveTerminal, - isTerminalClosed, - canOpenOutputFile, - open: openTerminalOrOutputFile, - } = useOpenBackgroundJob(backgroundJobId, outputFile); + const { terminals, openBackgroundJobTerminal } = useVisibleTerminals(); + const { backgroundCommands, show } = useBackgroundCommands(); + const liveTerminal = terminals?.find( + (tm) => tm.backgroundJobId === backgroundJobId, + ); + // A background command runs on a pty, so it outlives its terminal tab: the + // host lists it for exactly as long as the process lives. + const isRunning = backgroundCommands?.[backgroundJobId] !== undefined; + const canOpenTerminal = isRunning || liveTerminal !== undefined; + const canOpenOutputFile = !isRunning && outputFile !== undefined; + const isResolved = + terminals !== undefined && backgroundCommands !== undefined; + const openTerminalOrOutputFile = () => { + if (!canOpenTerminal) { + if (outputFile) vscodeHost.openFile(outputFile); + return; + } + if (isRunning) show?.(backgroundJobId); + else openBackgroundJobTerminal?.(backgroundJobId); + }; const isUserTerminal = backgroundJobId.startsWith("term-"); const isNotification = appearance === "notification"; const recoveredNotificationCommand = isNotification @@ -266,22 +281,21 @@ export const BackgroundJobPanel: FC<{ : (resolvedCommand ?? backgroundJobId); const isActive = liveTerminal?.isActive ?? false; - const closedLabel = canOpenOutputFile ? t("commandExecutionPanel.terminalClosedOpenOutput") : t("commandExecutionPanel.terminalClosed"); const jobControl = isUserTerminal - ? (liveTerminal || isTerminalClosed) && ( + ? isResolved && ( @@ -291,14 +305,14 @@ export const BackgroundJobPanel: FC<{ info?.displayId && (
    diff --git a/packages/vscode-webui/src/i18n/locales/en.json b/packages/vscode-webui/src/i18n/locales/en.json index 99cc10e001..e53c8ec872 100644 --- a/packages/vscode-webui/src/i18n/locales/en.json +++ b/packages/vscode-webui/src/i18n/locales/en.json @@ -599,6 +599,8 @@ "toggle": "Show background jobs", "pochiGroup": "Background commands", "openTerminal": "Open terminal", + "hideTerminal": "Hide terminal", + "copyCommand": "Copy command", "kill": "Kill process", "empty": "Nothing running in the background", "seeMore": "See more", diff --git a/packages/vscode-webui/src/i18n/locales/jp.json b/packages/vscode-webui/src/i18n/locales/jp.json index f289eed5d6..fdd0e6f3ba 100644 --- a/packages/vscode-webui/src/i18n/locales/jp.json +++ b/packages/vscode-webui/src/i18n/locales/jp.json @@ -598,6 +598,8 @@ "toggle": "バックグラウンドジョブを表示", "pochiGroup": "バックグラウンドコマンド", "openTerminal": "ターミナルを開く", + "hideTerminal": "ターミナルを非表示", + "copyCommand": "コマンドをコピー", "kill": "プロセスを終了", "empty": "バックグラウンドで実行中のものはありません", "seeMore": "もっと見る", diff --git a/packages/vscode-webui/src/i18n/locales/ko.json b/packages/vscode-webui/src/i18n/locales/ko.json index cbea16e13d..534bcda4d9 100644 --- a/packages/vscode-webui/src/i18n/locales/ko.json +++ b/packages/vscode-webui/src/i18n/locales/ko.json @@ -591,6 +591,8 @@ "toggle": "백그라운드 작업 보기", "pochiGroup": "백그라운드 명령", "openTerminal": "터미널 열기", + "hideTerminal": "터미널 숨기기", + "copyCommand": "명령 복사", "kill": "프로세스 종료", "empty": "백그라운드에서 실행 중인 항목이 없습니다", "seeMore": "더 보기", diff --git a/packages/vscode-webui/src/i18n/locales/zh.json b/packages/vscode-webui/src/i18n/locales/zh.json index 8dbd554c68..2022015774 100644 --- a/packages/vscode-webui/src/i18n/locales/zh.json +++ b/packages/vscode-webui/src/i18n/locales/zh.json @@ -596,6 +596,8 @@ "toggle": "查看后台任务", "pochiGroup": "后台命令", "openTerminal": "打开终端", + "hideTerminal": "隐藏终端", + "copyCommand": "复制命令", "kill": "终止进程", "empty": "当前没有后台任务", "seeMore": "查看更多", diff --git a/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts b/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts deleted file mode 100644 index 7e25f34742..0000000000 --- a/packages/vscode-webui/src/lib/hooks/use-open-background-job.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useVisibleTerminals } from "@/lib/hooks/use-visible-terminals"; -import { vscodeHost } from "@/lib/vscode"; -import { useCallback, useMemo } from "react"; - -/** - * Resolves what a background job / terminal control can open: the live - * terminal while it exists, its recorded output file once the terminal is - * gone. - */ -export function useOpenBackgroundJob( - backgroundJobId: string, - outputFile: string | undefined, -) { - const { terminals, openBackgroundJobTerminal } = useVisibleTerminals(); - const liveTerminal = useMemo( - () => terminals?.find((tm) => tm.backgroundJobId === backgroundJobId), - [backgroundJobId, terminals], - ); - - // `terminals === undefined` means "not loaded yet", which must not be - // reported as a closed terminal. - const isTerminalClosed = terminals !== undefined && !liveTerminal; - const canOpenOutputFile = isTerminalClosed && outputFile !== undefined; - - const openTerminal = useCallback(() => { - openBackgroundJobTerminal?.(backgroundJobId); - }, [backgroundJobId, openBackgroundJobTerminal]); - - const openOutputFile = useCallback(() => { - if (outputFile) vscodeHost.openFile(outputFile); - }, [outputFile]); - - const open = useCallback(() => { - if (isTerminalClosed) { - openOutputFile(); - return; - } - openTerminal(); - }, [isTerminalClosed, openOutputFile, openTerminal]); - - return { - liveTerminal, - isTerminalClosed, - canOpenOutputFile, - open, - // The two halves of `open`, for callers that offer them as separate - // controls rather than as one badge. - openTerminal, - openOutputFile, - }; -} From f9c7461a4ce015254099db0cb24a12311f0e3ed4 Mon Sep 17 00:00:00 2001 From: liangfung Date: Fri, 4 Sep 2026 18:17:35 +0800 Subject: [PATCH 7/8] update: rename --- ...> background-job-manage-panel.stories.tsx} | 10 +- ...x => background-job-manage-panel.test.tsx} | 236 +++++++++--------- ...el.tsx => background-job-manage-panel.tsx} | 61 +++-- .../chat/components/chat-toolbar.test.tsx | 4 +- .../features/chat/components/chat-toolbar.tsx | 8 +- .../chat/components/row-status-indicator.tsx | 2 +- ...job-list.ts => use-background-job-list.ts} | 13 +- ...t.ts => build-background-job-list.test.ts} | 51 ++-- ...b-list.ts => build-background-job-list.ts} | 18 +- 9 files changed, 210 insertions(+), 193 deletions(-) rename packages/vscode-webui/src/features/chat/components/__stories__/{manage-panel.stories.tsx => background-job-manage-panel.stories.tsx} (91%) rename packages/vscode-webui/src/features/chat/components/{manage-panel.test.tsx => background-job-manage-panel.test.tsx} (76%) rename packages/vscode-webui/src/features/chat/components/{manage-panel.tsx => background-job-manage-panel.tsx} (88%) rename packages/vscode-webui/src/features/chat/hooks/{use-job-list.ts => use-background-job-list.ts} (61%) rename packages/vscode-webui/src/features/chat/lib/{build-job-list.test.ts => build-background-job-list.test.ts} (80%) rename packages/vscode-webui/src/features/chat/lib/{build-job-list.ts => build-background-job-list.ts} (91%) diff --git a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx b/packages/vscode-webui/src/features/chat/components/__stories__/background-job-manage-panel.stories.tsx similarity index 91% rename from packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx rename to packages/vscode-webui/src/features/chat/components/__stories__/background-job-manage-panel.stories.tsx index 1079d5bde3..48b21a596d 100644 --- a/packages/vscode-webui/src/features/chat/components/__stories__/manage-panel.stories.tsx +++ b/packages/vscode-webui/src/features/chat/components/__stories__/background-job-manage-panel.stories.tsx @@ -6,11 +6,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, within } from "@storybook/test"; import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; -import { ManagePanel } from "../manage-panel"; +import { BackgroundJobManagePanel } from "../background-job-manage-panel"; const meta = { - title: "Features/Chat/ManagePanel", - component: ManagePanel, + title: "Features/Chat/BackgroundJobManagePanel", + component: BackgroundJobManagePanel, args: { taskId: "story-task", messages: [], @@ -22,7 +22,7 @@ const meta = {
    ), ], -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; @@ -51,7 +51,7 @@ async function openPanel({ canvasElement, }: { canvasElement: HTMLElement }): Promise { const canvas = within(canvasElement); - const toggle = canvas.getByTestId("manage-panel-toggle"); + const toggle = canvas.getByTestId("background-job-manage-panel-toggle"); await userEvent.click(toggle); await expect(toggle).toHaveAttribute("data-state", "open"); } diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx similarity index 76% rename from packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx rename to packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx index 823c8dd06d..5e458ca390 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx @@ -2,15 +2,15 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { JobList } from "../lib/build-job-list"; -import { ManagePanel } from "./manage-panel"; +import type { BackgroundJobEntry } from "../lib/build-background-job-list"; +import { BackgroundJobManagePanel } from "./background-job-manage-panel"; const show = vi.fn(); const hide = vi.fn(); const close = vi.fn(); const openFile = vi.fn(); const copyToClipboard = vi.fn(); -let jobList: JobList = { pochi: [] }; +let backgroundJobs: BackgroundJobEntry[] = []; let backgroundCommands: Record | undefined = {}; let isDevMode = false; let backgroundTasks: Array<{ id: string; title: string }> = []; @@ -41,8 +41,8 @@ vi.mock("@/components/ui/sheet", () => ({ SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}, })); -vi.mock("../hooks/use-job-list", () => ({ - useJobList: () => jobList, +vi.mock("../hooks/use-background-job-list", () => ({ + useBackgroundJobList: () => backgroundJobs, })); vi.mock("@/lib/hooks/use-background-commands", () => ({ @@ -94,7 +94,8 @@ vi.mock("./background-task-debug-panel", () => ({ ), })); -const renderPanel = () => render(); +const renderBackgroundJobManagePanel = () => + render(); const rowTitles = () => screen.getAllByRole("listitem").map((row) => row.textContent); @@ -119,23 +120,23 @@ const finishedRow = { status: "completed" as const, }; -describe("ManagePanel", () => { +describe("BackgroundJobManagePanel", () => { beforeEach(() => { show.mockClear(); hide.mockClear(); close.mockClear(); openFile.mockClear(); copyToClipboard.mockClear(); - jobList = { pochi: [] }; + backgroundJobs = []; backgroundCommands = { "bgjob-cmd-1": { isVisible: true } }; isDevMode = false; backgroundTasks = []; }); it("keeps the trigger bare when there is nothing running", () => { - const { container } = renderPanel(); + const { container } = renderBackgroundJobManagePanel(); - const trigger = screen.getByTestId("manage-panel-toggle"); + const trigger = screen.getByTestId("background-job-manage-panel-toggle"); expect(trigger.textContent).toBe(""); expect(trigger.getAttribute("aria-label")).toBe("managePanel.toggle"); expect(trigger.querySelector("svg")?.classList.contains("size-4.5")).toBe( @@ -148,11 +149,11 @@ describe("ManagePanel", () => { }); it("badges the trigger while a command is running", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - const { container } = renderPanel(); + const { container } = renderBackgroundJobManagePanel(); - const trigger = screen.getByTestId("manage-panel-toggle"); + const trigger = screen.getByTestId("background-job-manage-panel-toggle"); expect(trigger.textContent).toBe("1"); expect(trigger.querySelector(".bg-blue-500")?.textContent).toBe("1"); expect(container.querySelector(".animate-spin")).not.toBeNull(); @@ -161,23 +162,25 @@ describe("ManagePanel", () => { }); it("drops the badge once every command has finished", () => { - jobList = { pochi: [{ ...runningJob, status: "completed" }] }; + backgroundJobs = [{ ...runningJob, status: "completed" }]; - const { container } = renderPanel(); + const { container } = renderBackgroundJobManagePanel(); expect( - screen.getByTestId("manage-panel-toggle").querySelector(".bg-blue-500"), + screen + .getByTestId("background-job-manage-panel-toggle") + .querySelector(".bg-blue-500"), ).toBeNull(); expect(container.querySelector(".animate-spin")).toBeNull(); }); it("collapses a section from its title", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); const title = screen.getByText("managePanel.pochiGroup"); - expect(title.classList.contains("text-base")).toBe(true); + expect(title.classList.contains("text-sm")).toBe(true); expect(title.classList.contains("font-medium")).toBe(true); expect(title.classList.contains("text-muted-foreground")).toBe(true); @@ -202,16 +205,14 @@ describe("ManagePanel", () => { }); it("holds a long category back behind a see-more toggle", () => { - jobList = { - pochi: Array.from({ length: 7 }, (_, index) => ({ - backgroundJobId: `bgjob-cmd-${index}`, - displayId: `%${index}`, - title: `bun run dev ${index}`, - status: "completed" as const, - })), - }; + backgroundJobs = Array.from({ length: 7 }, (_, index) => ({ + backgroundJobId: `bgjob-cmd-${index}`, + displayId: `%${index}`, + title: `bun run dev ${index}`, + status: "completed" as const, + })); - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.getByText("bun run dev 4")).toBeDefined(); expect(screen.queryByText("bun run dev 5")).toBeNull(); @@ -224,19 +225,17 @@ describe("ManagePanel", () => { }); it("explains a row by its command, and stays quiet without one", () => { - jobList = { - pochi: [ - runningJob, - { - ...runningJob, - backgroundJobId: "bgjob-cmd-2", - title: "bgjob-cmd-2", - command: undefined, - }, - ], - }; - - renderPanel(); + backgroundJobs = [ + runningJob, + { + ...runningJob, + backgroundJobId: "bgjob-cmd-2", + title: "bgjob-cmd-2", + command: undefined, + }, + ]; + + renderBackgroundJobManagePanel(); expect(screen.getByText("bun run dev").dataset.slot).toBe( "tooltip-trigger", @@ -245,11 +244,11 @@ describe("ManagePanel", () => { }); it("says how a command ended on hover, exit code included", async () => { - jobList = { - pochi: [{ ...runningJob, status: "failed" as const, exitCode: 127 }], - }; + backgroundJobs = [ + { ...runningJob, status: "failed" as const, exitCode: 127 }, + ]; - renderPanel(); + renderBackgroundJobManagePanel(); fireEvent.pointerMove(screen.getByText("bun run dev"), { pointerType: "mouse", @@ -265,9 +264,9 @@ describe("ManagePanel", () => { }); it("numbers a row without asking to be clicked", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); const displayId = screen.getByText("%1"); expect(displayId.tagName).toBe("SPAN"); @@ -276,13 +275,11 @@ describe("ManagePanel", () => { }); it("keeps the number for a row that has nothing to press", () => { - jobList = { - pochi: [ - { ...runningJob, status: "stopped" as const, command: undefined }, - ], - }; + backgroundJobs = [ + { ...runningJob, status: "stopped" as const, command: undefined }, + ]; - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.getByText("%1").className).not.toContain( "group-hover:opacity-0", @@ -290,20 +287,20 @@ describe("ManagePanel", () => { }); it("frames the number, and lights it up while the command runs", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - const { rerender } = renderPanel(); + const { rerender } = renderBackgroundJobManagePanel(); expect(screen.getByText("%1").className).toContain("ring-1"); - jobList = { pochi: [{ ...runningJob, status: "completed" as const }] }; - rerender(); + backgroundJobs = [{ ...runningJob, status: "completed" as const }]; + rerender(); expect(screen.getByText("%1").className).not.toContain("ring-1"); }); it("puts a running command's terminal on screen by clicking its row", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); const row = screen.getByText("bun run dev").closest('[role="button"]'); expect(row).not.toBeNull(); @@ -312,17 +309,15 @@ describe("ManagePanel", () => { }); it("reads a finished command back by clicking its row", () => { - jobList = { - pochi: [ - { - ...runningJob, - status: "completed" as const, - outputFile: "/tmp/bgjob-cmd-1.log", - }, - ], - }; - - renderPanel(); + backgroundJobs = [ + { + ...runningJob, + status: "completed" as const, + outputFile: "/tmp/bgjob-cmd-1.log", + }, + ]; + + renderBackgroundJobManagePanel(); const row = screen.getByText("bun run dev").closest('[role="button"]'); expect(row).not.toBeNull(); @@ -332,9 +327,9 @@ describe("ManagePanel", () => { }); it("leaves a row with nothing to open unclickable", () => { - jobList = { pochi: [{ ...runningJob, status: "stopped" as const }] }; + backgroundJobs = [{ ...runningJob, status: "stopped" as const }]; - renderPanel(); + renderBackgroundJobManagePanel(); expect( screen.getByText("bun run dev").closest('[role="button"]'), @@ -342,9 +337,9 @@ describe("ManagePanel", () => { }); it("keeps a row control from also firing the row", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); fireEvent.click(screen.getByLabelText("managePanel.kill")); expect(close).toHaveBeenCalledWith("bgjob-cmd-1"); @@ -352,9 +347,9 @@ describe("ManagePanel", () => { }); it("offers a running command a way to put its terminal away and to stop it", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); fireEvent.click(screen.getByLabelText("managePanel.hideTerminal")); expect(hide).toHaveBeenCalledWith("bgjob-cmd-1"); @@ -366,9 +361,9 @@ describe("ManagePanel", () => { it("offers back the terminal of a running command whose tab was put away", () => { backgroundCommands = { "bgjob-cmd-1": { isVisible: false } }; - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); fireEvent.click(screen.getByLabelText("managePanel.openTerminal")); expect(show).toHaveBeenCalledWith("bgjob-cmd-1"); @@ -377,17 +372,15 @@ describe("ManagePanel", () => { }); it("offers a finished command its output file", () => { - jobList = { - pochi: [ - { - ...runningJob, - status: "completed" as const, - outputFile: "/tmp/bgjob-cmd-1.log", - }, - ], - }; - - renderPanel(); + backgroundJobs = [ + { + ...runningJob, + status: "completed" as const, + outputFile: "/tmp/bgjob-cmd-1.log", + }, + ]; + + renderBackgroundJobManagePanel(); const openOutput = screen.getByLabelText( "backgroundJobNotifications.openOutput", @@ -406,17 +399,15 @@ describe("ManagePanel", () => { }); it("hands a finished command back to the clipboard", () => { - jobList = { - pochi: [ - { - ...runningJob, - status: "completed" as const, - outputFile: "/tmp/bgjob-cmd-1.log", - }, - ], - }; - - renderPanel(); + backgroundJobs = [ + { + ...runningJob, + status: "completed" as const, + outputFile: "/tmp/bgjob-cmd-1.log", + }, + ]; + + renderBackgroundJobManagePanel(); // Beside the transcript, not instead of it. expect( @@ -429,21 +420,19 @@ describe("ManagePanel", () => { }); it("keeps the clipboard control away from a running command", () => { - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.queryByLabelText("managePanel.copyCommand")).toBeNull(); }); it("leaves a finished command without a transcript with nothing to press", () => { - jobList = { - pochi: [ - { ...runningJob, status: "stopped" as const, command: undefined }, - ], - }; + backgroundJobs = [ + { ...runningJob, status: "stopped" as const, command: undefined }, + ]; - renderPanel(); + renderBackgroundJobManagePanel(); expect( screen.queryByLabelText("backgroundJobNotifications.openOutput"), @@ -452,23 +441,24 @@ describe("ManagePanel", () => { }); it("lists running commands first", () => { - jobList = { pochi: [finishedRow, runningRow] }; + backgroundJobs = [finishedRow, runningRow]; - renderPanel(); + renderBackgroundJobManagePanel(); expect(rowTitles()).toEqual(["running", "done"]); }); it("keeps a command in place once it stops", () => { - jobList = { pochi: [finishedRow, runningRow] }; + backgroundJobs = [finishedRow, runningRow]; - const { rerender } = renderPanel(); + const { rerender } = renderBackgroundJobManagePanel(); // Killing the top row must not drop it to the bottom under the pointer. - jobList = { - pochi: [finishedRow, { ...runningRow, status: "stopped" as const }], - }; - rerender(); + backgroundJobs = [ + finishedRow, + { ...runningRow, status: "stopped" as const }, + ]; + rerender(); expect(rowTitles()).toEqual(["running", "done"]); }); @@ -476,7 +466,7 @@ describe("ManagePanel", () => { it("keeps background tasks out of the panel outside dev mode", () => { backgroundTasks = [{ id: "task-1", title: "A background task" }]; - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.queryByText("Background tasks")).toBeNull(); expect(screen.getByText("managePanel.empty")).toBeDefined(); @@ -485,7 +475,7 @@ describe("ManagePanel", () => { it("hides the task section in dev mode while there is no task", () => { isDevMode = true; - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.queryByText("Background tasks")).toBeNull(); expect(screen.getByText("managePanel.empty")).toBeDefined(); @@ -495,7 +485,7 @@ describe("ManagePanel", () => { isDevMode = true; backgroundTasks = [{ id: "task-1", title: "A background task" }]; - renderPanel(); + renderBackgroundJobManagePanel(); expect(screen.getByText("Background tasks")).toBeDefined(); expect(screen.getByText("A background task")).toBeDefined(); @@ -505,9 +495,9 @@ describe("ManagePanel", () => { it("takes the drawer to a task and back again", () => { isDevMode = true; backgroundTasks = [{ id: "task-1", title: "A background task" }]; - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); fireEvent.click(screen.getByText("A background task")); // The detail covers the list rather than replacing it. @@ -527,9 +517,9 @@ describe("ManagePanel", () => { it("keeps the list as it was left while a task is open", () => { isDevMode = true; backgroundTasks = [{ id: "task-1", title: "A background task" }]; - jobList = { pochi: [runningJob] }; + backgroundJobs = [runningJob]; - renderPanel(); + renderBackgroundJobManagePanel(); // Fold the commands, then take a detour through a task detail. fireEvent.click(screen.getByText("managePanel.pochiGroup")); diff --git a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx similarity index 88% rename from packages/vscode-webui/src/features/chat/components/manage-panel.tsx rename to packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx index 7d368f4300..b0bf9d99f6 100644 --- a/packages/vscode-webui/src/features/chat/components/manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx @@ -21,17 +21,20 @@ import type { Message, Task } from "@getpochi/livekit"; import { CheckIcon, ChevronRightIcon, - CircleStopIcon, CopyIcon, EyeIcon, EyeOffIcon, FileTextIcon, ListIcon, + XIcon, } from "lucide-react"; import { Children, type ReactNode, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useJobList } from "../hooks/use-job-list"; -import type { JobListEntry, JobStatus } from "../lib/build-job-list"; +import { useBackgroundJobList } from "../hooks/use-background-job-list"; +import type { + BackgroundJobEntry, + JobStatus, +} from "../lib/build-background-job-list"; import { BackgroundTaskDetail, BackgroundTaskRow, @@ -40,7 +43,7 @@ import { } from "./background-task-debug-panel"; import { RowStatusIndicator, type RowStatusTone } from "./row-status-indicator"; -export function ManagePanel({ +export function BackgroundJobManagePanel({ taskId, messages, }: { @@ -52,9 +55,11 @@ export function ManagePanel({ const [isOpen, setIsOpen] = useState(false); const [detailTaskId, setDetailTaskId] = useState(null); const [isDetailOpen, setIsDetailOpen] = useState(false); - const { pochi } = useJobList(taskId, messages); + const backgroundJobs = useBackgroundJobList(taskId, messages); - const runningCount = pochi.filter((job) => job.status === "running").length; + const runningCount = backgroundJobs.filter( + (job) => job.status === "running", + ).length; return ( {runningCount > 0 && ( - + {runningCount} )} @@ -97,14 +102,14 @@ export function ManagePanel({
    {isDevMode === true ? ( { setDetailTaskId(id); setIsDetailOpen(true); }} /> ) : ( - + )}
    void; }) { const tasks = useBackgroundTasks(); - return ; + return ( + + ); } function PanelBody({ - pochi, + backgroundJobs, tasks, onSelectTask, }: { - pochi: JobListEntry[]; + backgroundJobs: BackgroundJobEntry[]; tasks: readonly Task[]; onSelectTask?: (taskId: string) => void; }) { const { t } = useTranslation(); - const commands = useRunningFirst(pochi); + const commands = useRunningFirst(backgroundJobs); - if (pochi.length === 0 && tasks.length === 0) { + if (backgroundJobs.length === 0 && tasks.length === 0) { return (
    {t("managePanel.empty")} @@ -200,10 +211,10 @@ function PanelBody({ * ranked by the status it had when it first appeared, so a command that stops * keeps its place instead of dropping away under the pointer. */ -function useRunningFirst(jobs: JobListEntry[]): JobListEntry[] { +function useRunningFirst(jobs: BackgroundJobEntry[]): BackgroundJobEntry[] { const ranks = useRef(new Map()); - const rankOf = (job: JobListEntry) => { + const rankOf = (job: BackgroundJobEntry) => { const known = ranks.current.get(job.backgroundJobId); if (known !== undefined) return known; const rank = job.status === "running" ? 0 : 1; @@ -241,7 +252,7 @@ function PanelGroup({ )} > - + {label} close?.(job.backgroundJobId)} > - + ) : ( @@ -384,13 +395,13 @@ function JobRow({ job }: { job: JobListEntry }) { ) : ( title )} - + {job.displayId && ( ({ vi.mock("./error-message-view", () => ({ ErrorMessageView: () => null, })); -vi.mock("./manage-panel", () => ({ - ManagePanel: () => null, +vi.mock("./background-job-manage-panel", () => ({ + BackgroundJobManagePanel: () => null, })); vi.mock("./submit-review-button", () => ({ SubmitReviewsButton: () => null, diff --git a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx index 57222dc7be..abc6a2660c 100644 --- a/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx +++ b/packages/vscode-webui/src/features/chat/components/chat-toolbar.tsx @@ -62,15 +62,17 @@ import { enqueueBackgroundJobNotifications, getBackgroundJobNotificationIds, } from "../lib/background-job-notification-queue"; +import { BackgroundJobManagePanel } from "./background-job-manage-panel"; import { ChatInputForm, type ChatInputFormHandle } from "./chat-input-form"; import { ErrorMessageView } from "./error-message-view"; -import { ManagePanel } from "./manage-panel"; import { SubmitReviewsButton } from "./submit-review-button"; import { CompleteSubtaskButton } from "./subtask"; const PopupContainerClassName = tw`-translate-y-full -top-2 absolute left-0 w-full px-4 pt-1`; const PopupContentClassName = tw`flex w-full flex-col bg-background`; -const FooterContainerClassName = tw`my-2 flex shrink-0 justify-between gap-5 overflow-x-hidden`; +// `overflow-x-hidden` clips vertically too, so the row carries padding to keep +// room for anything hanging outside a control, such as the manage panel badge. +const FooterContainerClassName = tw`my-1 flex shrink-0 justify-between gap-5 overflow-x-hidden py-1`; const FooterLeftClassName = tw`flex items-center gap-2 overflow-x-hidden truncate`; const FooterRightClassName = tw`flex shrink-0 items-center gap-1`; @@ -581,7 +583,7 @@ export const ChatToolbar: React.FC = ({ todos={todos} getSystemPrompt={getSystemPrompt} /> - + buildJobList({ messages, notifications, backgroundCommands }), + () => + buildBackgroundJobList({ messages, notifications, backgroundCommands }), [messages, notifications, backgroundCommands], ); } diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts b/packages/vscode-webui/src/features/chat/lib/build-background-job-list.test.ts similarity index 80% rename from packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts rename to packages/vscode-webui/src/features/chat/lib/build-background-job-list.test.ts index 9e11ee5948..924fb32064 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.test.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-background-job-list.test.ts @@ -1,17 +1,17 @@ import type { BackgroundJobNotification } from "@getpochi/common"; import { describe, expect, it } from "vitest"; import type { Message } from "@getpochi/livekit"; -import { buildJobList } from "./build-job-list"; +import { buildBackgroundJobList } from "./build-background-job-list"; -describe("buildJobList", () => { +describe("buildBackgroundJobList", () => { it("lists a command the host still has a process for as running", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [], backgroundCommands: { "bgjob-cmd-1": { isVisible: true } }, }); - expect(pochi).toEqual([ + expect(backgroundJobs).toEqual([ { backgroundJobId: "bgjob-cmd-1", displayId: "%1", @@ -24,7 +24,7 @@ describe("buildJobList", () => { }); it("keeps a finished job whose notification was already delivered", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [ message([ executeCommandPart("bgjob-cmd-1", "bun run dev"), @@ -35,7 +35,7 @@ describe("buildJobList", () => { backgroundCommands: {}, }); - expect(pochi).toEqual([ + expect(backgroundJobs).toEqual([ { backgroundJobId: "bgjob-cmd-1", displayId: "%1", @@ -48,35 +48,37 @@ describe("buildJobList", () => { }); it("keeps a finished job whose notification is still undelivered", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [notification("bgjob-cmd-1", "completed")], backgroundCommands: {}, }); - expect(pochi).toMatchObject([ + expect(backgroundJobs).toMatchObject([ { backgroundJobId: "bgjob-cmd-1", status: "completed" }, ]); }); it("carries the exit code a finished job reported", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [notification("bgjob-cmd-1", "failed", 127)], backgroundCommands: {}, }); - expect(pochi).toMatchObject([{ status: "failed", exitCode: 127 }]); + expect(backgroundJobs).toMatchObject([ + { status: "failed", exitCode: 127 }, + ]); }); it("still lists a gone command nothing reported an ending for", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [message([executeCommandPart("bgjob-cmd-1", "bun run dev")])], notifications: [], backgroundCommands: {}, }); - expect(pochi).toMatchObject([ + expect(backgroundJobs).toMatchObject([ { backgroundJobId: "bgjob-cmd-1", status: "finished", @@ -87,7 +89,7 @@ describe("buildJobList", () => { }); it("lists a command promoted from the foreground, background flag or not", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [ message([ { @@ -100,17 +102,19 @@ describe("buildJobList", () => { backgroundCommands: { "bgjob-cmd-1": { isVisible: false } }, }); - expect(pochi).toMatchObject([{ status: "running", title: "bun run dev" }]); + expect(backgroundJobs).toMatchObject([ + { status: "running", title: "bun run dev" }, + ]); }); it("surfaces a notification whose executeCommand part is gone", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [], notifications: [notification("bgjob-cmd-9", "stopped")], backgroundCommands: {}, }); - expect(pochi).toEqual([ + expect(backgroundJobs).toEqual([ { backgroundJobId: "bgjob-cmd-9", title: "run bgjob-cmd-9", @@ -122,7 +126,7 @@ describe("buildJobList", () => { }); it("lists the newest job first, numbered like the badges in the message list", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [ message([ executeCommandPart("bgjob-cmd-1", "first"), @@ -137,11 +141,15 @@ describe("buildJobList", () => { backgroundCommands: {}, }); - expect(pochi.map((job) => job.displayId)).toEqual(["%2", "%1", undefined]); + expect(backgroundJobs.map((job) => job.displayId)).toEqual([ + "%2", + "%1", + undefined, + ]); }); it("falls back to what the notifications know while the host table loads", () => { - const { pochi } = buildJobList({ + const backgroundJobs = buildBackgroundJobList({ messages: [ message([ executeCommandPart("bgjob-cmd-1", "bun run dev"), @@ -153,7 +161,10 @@ describe("buildJobList", () => { backgroundCommands: undefined, }); - expect(pochi.map((job) => job.status)).toEqual(["completed", "finished"]); + expect(backgroundJobs.map((job) => job.status)).toEqual([ + "completed", + "finished", + ]); }); }); diff --git a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts b/packages/vscode-webui/src/features/chat/lib/build-background-job-list.ts similarity index 91% rename from packages/vscode-webui/src/features/chat/lib/build-job-list.ts rename to packages/vscode-webui/src/features/chat/lib/build-background-job-list.ts index a9a3f810a3..a7b6f475fe 100644 --- a/packages/vscode-webui/src/features/chat/lib/build-job-list.ts +++ b/packages/vscode-webui/src/features/chat/lib/build-background-job-list.ts @@ -9,7 +9,7 @@ export type JobStatus = | "stopped" | "finished"; -export interface JobListEntry { +export interface BackgroundJobEntry { backgroundJobId: string; displayId?: string; title: string; @@ -19,12 +19,8 @@ export interface JobListEntry { outputFile?: string; } -export interface JobList { - pochi: JobListEntry[]; -} - /** Collects the background commands Pochi started for this task. */ -export function buildJobList({ +export function buildBackgroundJobList({ messages, notifications, backgroundCommands, @@ -32,7 +28,7 @@ export function buildJobList({ messages: readonly Message[]; notifications: readonly BackgroundJobNotification[]; backgroundCommands: BackgroundCommands | undefined; -}): JobList { +}): BackgroundJobEntry[] { const commands = new Map(); const finished = new Map(); @@ -60,14 +56,14 @@ export function buildJobList({ finished.set(notification.backgroundJobId, notification); } - const pochi: JobListEntry[] = []; + const backgroundJobs: BackgroundJobEntry[] = []; let index = 0; for (const [backgroundJobId, meta] of commands) { index += 1; const notification = finished.get(backgroundJobId); const command = meta.command ?? notification?.command; const isRunning = backgroundCommands?.[backgroundJobId] !== undefined; - pochi.push({ + backgroundJobs.push({ backgroundJobId, displayId: `%${index}`, title: command ?? backgroundJobId, @@ -80,7 +76,7 @@ export function buildJobList({ // A notification can outlive the `executeCommand` part that started it, // because compaction rewrites older messages. - const orphaned: JobListEntry[] = []; + const orphaned: BackgroundJobEntry[] = []; for (const notification of finished.values()) { if (commands.has(notification.backgroundJobId)) continue; orphaned.push({ @@ -95,5 +91,5 @@ export function buildJobList({ // Newest command first, with the `%N` labels still counting from the start // of the task. - return { pochi: [...pochi.reverse(), ...orphaned] }; + return [...backgroundJobs.reverse(), ...orphaned]; } From 6e11afaee49f62adf07f76627f47a8ca5982e0e8 Mon Sep 17 00:00:00 2001 From: liangfung Date: Fri, 4 Sep 2026 18:38:07 +0800 Subject: [PATCH 8/8] update: ui bug --- .../background-job-manage-panel.test.tsx | 21 ++++++++++-- .../background-job-manage-panel.tsx | 32 ++++++++++++------- .../background-task-debug-panel.test.tsx | 8 +++++ .../background-task-debug-panel.tsx | 10 +++++- .../command-execution-panel.test.tsx | 22 +++++++++++++ .../components/command-execution-panel.tsx | 16 ++++++---- 6 files changed, 88 insertions(+), 21 deletions(-) diff --git a/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx index 5e458ca390..83ccf33943 100644 --- a/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.test.tsx @@ -286,13 +286,13 @@ describe("BackgroundJobManagePanel", () => { ); }); - it("frames the number, and lights it up while the command runs", () => { + it("lights the number only while the command terminal is visible", () => { backgroundJobs = [runningJob]; const { rerender } = renderBackgroundJobManagePanel(); expect(screen.getByText("%1").className).toContain("ring-1"); - backgroundJobs = [{ ...runningJob, status: "completed" as const }]; + backgroundCommands = { "bgjob-cmd-1": { isVisible: false } }; rerender(); expect(screen.getByText("%1").className).not.toContain("ring-1"); }); @@ -346,6 +346,17 @@ describe("BackgroundJobManagePanel", () => { expect(show).not.toHaveBeenCalled(); }); + it("keeps a row control's keyboard event from firing the row", () => { + backgroundJobs = [runningJob]; + + renderBackgroundJobManagePanel(); + + fireEvent.keyDown(screen.getByLabelText("managePanel.kill"), { + key: "Enter", + }); + expect(show).not.toHaveBeenCalled(); + }); + it("offers a running command a way to put its terminal away and to stop it", () => { backgroundJobs = [runningJob]; @@ -507,11 +518,17 @@ describe("BackgroundJobManagePanel", () => { expect(screen.getByTestId("background-task-layer").dataset.state).toBe( "open", ); + expect( + screen.getByTestId("background-job-list-layer").hasAttribute("inert"), + ).toBe(true); fireEvent.click(screen.getByText("back")); expect(screen.getByTestId("background-task-layer").dataset.state).toBe( "closed", ); + expect( + screen.getByTestId("background-job-list-layer").hasAttribute("inert"), + ).toBe(false); }); it("keeps the list as it was left while a task is open", () => { diff --git a/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx index b0bf9d99f6..9defa6ec02 100644 --- a/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-job-manage-panel.tsx @@ -100,17 +100,23 @@ export function BackgroundJobManagePanel({ > {t("managePanel.title")}
    - {isDevMode === true ? ( - { - setDetailTaskId(id); - setIsDetailOpen(true); - }} - /> - ) : ( - - )} +
    + {isDevMode === true ? ( + { + setDetailTaskId(id); + setIsDetailOpen(true); + }} + /> + ) : ( + + )} +
    setIsDetailOpen(false)} /> )} @@ -363,6 +370,7 @@ function JobRow({ job }: { job: BackgroundJobEntry }) { onKeyDown={ open ? (event) => { + if (event.target !== event.currentTarget) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); open(); @@ -402,7 +410,7 @@ function JobRow({ job }: { job: BackgroundJobEntry }) { // An inline box paints over the controls sharing its grid cell, // so it has to opt out of hit-testing. "pointer-events-none col-start-1 row-start-1 inline-flex h-4 min-w-4 items-center justify-center rounded-sm bg-secondary px-1 font-bold font-mono text-secondary-foreground text-xs", - isRunning && "ring-1 ring-primary", + isVisible && "ring-1 ring-primary", hasActions && "transition-opacity group-focus-within:opacity-0 group-hover:opacity-0", )} diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx index 6abc25924b..96df2556dd 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.test.tsx @@ -105,6 +105,14 @@ describe("BackgroundTaskDetail", () => { messageRows = []; }); + it("focuses the back button when the detail opens", () => { + openTaskDetail(); + + expect(document.activeElement).toBe( + screen.getByLabelText("Back to the background job list"), + ); + }); + it("uses a single borderless scroll area that fills the remaining height", () => { openTaskDetail(); diff --git a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx index 67e4bd336a..525ef48a4e 100644 --- a/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx +++ b/packages/vscode-webui/src/features/chat/components/background-task-debug-panel.tsx @@ -10,7 +10,7 @@ import { useDefaultStore } from "@/lib/use-default-store"; import { cn } from "@/lib/utils"; import { type Message, type Task, catalog } from "@getpochi/livekit"; import { ArrowLeftIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { formatTokens } from "../lib/format-tokens"; import { RowStatusIndicator, type RowStatusTone } from "./row-status-indicator"; @@ -66,11 +66,14 @@ function statusTone(status: Task["status"]): RowStatusTone { export function BackgroundTaskDetail({ taskId, + isOpen = true, onBack, }: { taskId: string; + isOpen?: boolean; onBack: () => void; }) { + const backButtonRef = useRef(null); const store = useDefaultStore(); const task = store.useQuery(catalog.queries.makeTaskQuery(taskId)); const messageRows = store.useQuery(catalog.queries.makeMessagesQuery(taskId)); @@ -94,10 +97,15 @@ export function BackgroundTaskDetail({ ? latestAssistantMessage.metadata : undefined; + useEffect(() => { + if (isOpen) backButtonRef.current?.focus(); + }, [isOpen]); + return (