diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 366513d046..686b01f843 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -81,7 +81,25 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => { await expect(card).not.toContainText("(background)") await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0) await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0) - await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background") + const hint = page.locator('[data-component="session-background-hint"]') + const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]') + const thinking = page.locator('[data-slot="session-turn-thinking"]') + await expect(hint).toBeVisible() + await expect + .poll(async () => { + const [cardBox, hintBox, prefixBox, thinkingBox] = await Promise.all([ + card.boundingBox(), + hint.boundingBox(), + hintPrefix.boundingBox(), + thinking.boundingBox(), + ]) + if (!cardBox || !hintBox || !prefixBox || !thinkingBox) return undefined + return { + aligned: Math.abs(cardBox.x - prefixBox.x) < 2, + ordered: cardBox.y < hintBox.y && hintBox.y < thinkingBox.y, + } + }) + .toEqual({ aligned: true, ordered: true }) const request = page.waitForRequest( (request) => @@ -104,10 +122,10 @@ test("navigates from a running subagent card and hides background controls in th sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } }, }) - await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background") + await expect(page.getByText(/move running work to the background/i)).toBeVisible() await page.locator('[data-component="task-tool-card"]').click() await expect(page).toHaveURL(new RegExp(`/session/${childID}$`)) - await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0) + await expect(page.getByText(/move running work to the background/i)).toHaveCount(0) }) test("shows a badge for active background work", async ({ page }) => { @@ -118,7 +136,14 @@ test("shows a badge for active background work", async ({ page }) => { sessionStatus: { [childID]: { type: "busy" } }, }) - await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background") + await page.getByRole("button", { name: "Session details" }).click() + const summary = page.getByRole("button", { name: "1 item running in background" }) + await expect(summary).toContainText("1") + await expect(summary).toContainText("Running work in background") + await summary.click() + await expect( + page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }), + ).toBeVisible() }) test("separates blocking and already-backgrounded work into two rows", async ({ page }) => { @@ -193,10 +218,15 @@ test("separates blocking and already-backgrounded work into two rows", async ({ }, }) - const dock = page.locator('[data-component="session-background-dock"]') const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]') - await expect(dock).toContainText("Move 1 subagent to background") - await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible() + await expect(page.getByText(/move running work to the background/i)).toBeVisible() + await page.getByRole("button", { name: "Session details" }).click() + const summary = page.getByRole("button", { name: "2 items running in background" }) + await expect(summary).toContainText("2") + await summary.click() + const list = page.locator('[data-component="session-background-list"]') + await expect(list).toContainText("Background task") + await expect(list).toContainText("sleep 120") await expect(backgroundCard).toContainText("Background task (background)") await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() await expect( diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index a26b8f5b0b..ec745c44b8 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -127,19 +127,16 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => { ], }) - await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute( - "aria-label", - "sample-skill", - ) - await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute( - "aria-label", - "OpenCode", - ) - for (const id of [pending, completed]) { + for (const [id, name] of [ + [pending, "sample-skill"], + [completed, "OpenCode"], + ] as const) { const skill = page.locator(`[data-timeline-part-id="${id}"]`) - await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill") - await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("ยท") - await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible() + const loaded = skill.locator('[data-component="tool-loaded-item"]') + await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`) + await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded") + await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill") + await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name) } }) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 7bf558c786..88dcb990e2 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -544,7 +544,6 @@ export const dict = { "toast.context.noLineSelection.title": "No line selection", "toast.context.noLineSelection.description": "Select a line range in a file tab first.", - "toast.session.unshare.success.title": "Session unshared", "toast.session.unshare.success.description": "Session unshared successfully!", "toast.session.unshare.failed.title": "Failed to unshare session", @@ -657,6 +656,10 @@ export const dict = { "{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.", "session.background.moveTasks": "Move {{tasks}} to background", "session.background.inBackground": "Running {{tasks}} in background", + "session.background.moveInline": "Press {{keybind}} to move running work to the background", + "session.background.running": "Running work in background", + "session.background.runningCount.one": "{{count}} item running in background", + "session.background.runningCount.other": "{{count}} items running in background", "session.background.combine": "{{first}} and {{second}}", "session.background.shell.one": "{{count}} shell", "session.background.shell.other": "{{count}} shells", diff --git a/packages/app/src/session/composer/session-composer-region.tsx b/packages/app/src/session/composer/session-composer-region.tsx index 423e791f94..1c25e9f43f 100644 --- a/packages/app/src/session/composer/session-composer-region.tsx +++ b/packages/app/src/session/composer/session-composer-region.tsx @@ -2,15 +2,12 @@ import { Show, type JSX } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { SessionPermissionDock } from "@/session/requests/session-permission-dock" import { SessionQuestionDock } from "@/session/requests/session-question-dock" -import { SessionBackgroundDock } from "@/session/requests/session-background-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" type SessionComposerRegionState = Pick< SessionComposerRegionController["state"], "questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked" -> & { - background: Pick -} +> export type SessionComposerRegionViewController = Pick< SessionComposerRegionController, @@ -32,9 +29,6 @@ export function SessionComposerRegion(props: { }) { const language = useLanguage() const controller = props.controller - const background = () => - controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0 - return (
} > - -
- void controller.state.background.move()} - /> -
-
void -}) { - const language = useLanguage() - const command = useCommand() - const [store, setStore] = createStore({ collapsed: true }) - const describe = (shells: number, subagents: number) => { - const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined - const subagent = subagents - ? language.plural("session.background.subagent", subagents, { count: subagents }) - : undefined - if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent }) - return shell ?? subagent ?? "" - } - const summary = createMemo(() => { - const shells = props.tasks.filter((task) => task.type === "shell").length - return describe(shells, props.tasks.length - shells) - }) - const moving = createMemo(() => { - const shells = props.blocking.filter((task) => task.type === "shell").length - const subagents = props.blocking.length - shells - const tasks = describe(shells, subagents) - return tasks ? language.t("session.background.moveTasks", { tasks }) : "" - }) - const background = createMemo(() => - summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "", - ) - const blocking = () => props.blocking.length > 0 - const toggle = () => { - if (blocking()) { - props.onBackground() - return - } - setStore("collapsed", (value) => !value) - } - - return ( - - {blocking() && ( - - {moving()} - - - - - )} - {!!props.tasks.length && {background()}} - - } - ariaLabel={[moving(), background()].filter(Boolean).join(". ")} - multiline={blocking() && props.tasks.length > 0} - collapsed={blocking() || store.collapsed} - collapsible={!blocking()} - onToggle={toggle} - collapseLabel={language.t("session.todo.collapse")} - expandLabel={language.t("session.todo.expand")} - > -
- - {(task) => ( -
- - {language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")} - - {task.label} -
- )} -
-
-
- ) -} diff --git a/packages/app/src/session/requests/session-background-pullout.tsx b/packages/app/src/session/requests/session-background-pullout.tsx deleted file mode 100644 index 02a8baa129..0000000000 --- a/packages/app/src/session/requests/session-background-pullout.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Icon } from "@opencode-ai/ui/icon" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { createEffect, createMemo, type JSX } from "solid-js" -import { createStore } from "solid-js/store" - -export function SessionBackgroundPullout(props: { - label: JSX.Element - ariaLabel: string - multiline?: boolean - collapsed: boolean - collapsible?: boolean - onToggle: () => void - collapseLabel: string - expandLabel: string - children: JSX.Element -}) { - const [store, setStore] = createStore({ height: 78, header: 42 }) - const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const off = createMemo(() => value() > 0.98) - const base = createMemo(() => Math.max(78, store.header + 36)) - const full = createMemo(() => Math.max(base(), store.height)) - let contentRef: HTMLDivElement | undefined - let headerRef: HTMLDivElement | undefined - - createEffect(() => { - const element = contentRef - const header = headerRef - if (!element || !header) return - const update = () => { - setStore("height", (height) => Math.max(height, element.scrollHeight)) - setStore("header", header.getBoundingClientRect().height) - } - update() - createResizeObserver([element, header], update) - }) - - return ( -
-
-
{ - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - props.onToggle() - }} - > - - {props.label} - - {props.collapsible !== false && ( -
- } - size="normal" - variant="ghost" - style={{ transform: `rotate(${value() * 180}deg)` }} - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - props.onToggle() - }} - aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} - /> -
- )} -
-
0.1 }} - style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - value())}` }} - > - {props.children} -
-
-
- ) -} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 104dff3311..7544d99768 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -72,6 +72,7 @@ export function SessionScreen(props: { session: SessionModel }) { {(_id) => ( void setState("request", undefined) setState("activity", `Permission response: ${response}`) }, - background: { - blocking: () => [], - tasks: () => props.backgroundTasks ?? [], - move: async () => { - setState("activity", "Requested background execution") - }, - }, blocked: () => state.request !== undefined, }, centered: () => true, diff --git a/packages/app/src/session/timeline/background-work.stories.tsx b/packages/app/src/session/timeline/background-work.stories.tsx new file mode 100644 index 0000000000..5d21f87e75 --- /dev/null +++ b/packages/app/src/session/timeline/background-work.stories.tsx @@ -0,0 +1,36 @@ +import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline" + +const tasks = [ + { id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" }, + { id: "task_status", type: "shell" as const, label: "opencode2 service status" }, + { id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" }, + { id: "task_tests", type: "shell" as const, label: "bun test packages/app" }, +] + +export default { + title: "OpenCode/Session/Background work", + id: "session-background-work", + parameters: { + docs: { + description: { + component: "Production controls for moving blocking work and inspecting active background tasks.", + }, + }, + }, +} + +export const InlineMoveHint = { + render: () => ( +
+ +
+ ), +} + +export const SummaryPanelEntry = { + render: () => ( +
+ +
+ ), +} diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 567e5e2c77..238f3b312c 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -1,11 +1,14 @@ -import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import type { SessionUserActions } from "@opencode-ai/session-ui/actions" +import { Badge } from "@opencode-ai/ui/badge" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Keybind } from "@opencode-ai/ui/keybind" import { Menu } from "@opencode-ai/ui/menu" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProjectAvatar } from "@opencode-ai/ui/project-avatar" import type { Project } from "@/runtime/server/types" @@ -15,7 +18,7 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage" import { useLanguage } from "@/runtime/i18n/language" import { useData } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" -import { Timeline } from "@opencode-ai/session-ui/timeline/projection" +import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection" import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row" import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller" import { createTimelineVirtualizer } from "./virtualizer" @@ -24,6 +27,98 @@ import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu" import { getProjectAvatarVariant } from "@/shell/state/layout" import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" +import { useCommand } from "@/shell/commands/command" + +type BackgroundTask = { + id: string + type: "shell" | "subagent" + label: string + agent?: string +} + +type SessionBackground = { + blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]> + tasks: Accessor + move: () => Promise +} + +export function BackgroundMoveHint(props: { keybind?: string[] }) { + const language = useLanguage() + const command = useCommand() + const marker = "__OPENCODE_BACKGROUND_KEYBIND__" + const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker)) + const keys = () => props.keybind ?? command.keybindParts("session.background") + const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background") + + return ( +
+ + {parts()[0].trim()} + + + {parts()[1].trim()} +
+ ) +} + +export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) { + const language = useLanguage() + const [open, setOpen] = createSignal(false) + const taskType = (task: BackgroundTask) => { + if (task.type === "shell") return language.t("ui.tool.shell") + if (!task.agent) return language.t("ui.tool.agent.default") + return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1) + } + + return ( + + + + {props.tasks.length} + + + + + + + {(task) => ( +
+ {taskType(task)} + {task.label} +
+ )} +
+
+
+
+ ) +} function WorkspaceMoveAction(props: { variant: "inline" | "panel" @@ -94,6 +189,7 @@ function SessionSummaryPanel(props: { moveDismissed: boolean onMoveDismiss: () => void onReview: () => void + backgroundTasks: BackgroundTask[] }) { const language = useLanguage() const location = () => { @@ -168,6 +264,9 @@ function SessionSummaryPanel(props: { )}
+ 0}> + +
0 && props.moveEligible}> void @@ -350,6 +450,18 @@ function MessageTimelineView( padding: turnPadding, anchor: props.anchor, }) + const backgroundHintPartID = createMemo(() => { + const blocking = new Set(props.background.blocking().map((task) => task.partID)) + const row = projection + .rows() + .findLast( + (row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID), + ) + if (row?._tag !== "AssistantPart" || row.group.type !== "part") return + return row.group.ref.partID + }) + const backgroundHint = (row: TimelineRow.TimelineRow) => + row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === backgroundHintPartID() return ( } + renderRow={(row, onSizeChange) => ( + <> + + +
+
+ +
+
+
+ + )} header={
diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 16ff08bef4..3389e512f2 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -6,6 +6,26 @@ const keybinds: Record = { "agent.cycle": "mod+.", "model.choose": "mod+m", "model.variant.cycle": "mod+shift+m", + "session.background": "ctrl+b", +} + +export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" + +export function parseKeybind(config: string) { + if (!config || config === "none") return [] + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + return { + key: + parts.find( + (part) => !["ctrl", "control", "meta", "cmd", "command", "mod", "alt", "option", "shift"].includes(part), + ) ?? "", + ctrl: parts.includes("ctrl") || parts.includes("control") || parts.includes("mod"), + meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"), + shift: parts.includes("shift"), + alt: parts.includes("alt") || parts.includes("option"), + } + }) } export function formatKeybind(config: string) { diff --git a/packages/storybook/.storybook/mocks/app/context/layout.ts b/packages/storybook/.storybook/mocks/app/context/layout.ts index 998c62dc40..b0bce76146 100644 --- a/packages/storybook/.storybook/mocks/app/context/layout.ts +++ b/packages/storybook/.storybook/mocks/app/context/layout.ts @@ -25,6 +25,10 @@ const [all, setAll] = createSignal([]) const [active, setActive] = createSignal(undefined) const [reviewOpen, setReviewOpen] = createSignal(false) +export function useCurrentRoute() { + return () => ({ type: "home" as const }) +} + const tabs = { all, active,