mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 15:06:16 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca3b20396 | ||
|
|
0cc3716ef0 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { expectPath, SERVER, sessionHref } from "../utils/app"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { assistantMessage, session, sessionID, setupTimeline, textPart, userMessage } from "../utils/timeline"
|
||||
@@ -38,6 +38,32 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
})
|
||||
|
||||
test("keeps the parent title anchored when opening a subagent", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))
|
||||
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
const breadcrumb = page.locator('[data-slot="session-title-parent"]')
|
||||
|
||||
await expect(breadcrumb).toHaveText(parentTitle)
|
||||
await expect.poll(() => titleInlineStart(breadcrumb)).toBeCloseTo(start, 0)
|
||||
await breadcrumb.click()
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await expect
|
||||
.poll(() => titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle })))
|
||||
.toBeCloseTo(start, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const runningChildID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
@@ -316,6 +342,19 @@ async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function titleInlineStart(title: Locator) {
|
||||
return title.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(element)
|
||||
|
||||
const bounds = range.getBoundingClientRect()
|
||||
|
||||
return document.documentElement.dir === "rtl" ? bounds.right : bounds.left
|
||||
})
|
||||
}
|
||||
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
@@ -1012,6 +1012,153 @@ test.describe("background shortcut", () => {
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
|
||||
const childID = "ses_only_running_child"
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Only running task", agent: "explore" }),
|
||||
],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "1 working", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitem", { name: /Only running task/ }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(header.getByRole("heading")).toHaveText("Only running task")
|
||||
await expect(trigger).toHaveCount(0)
|
||||
|
||||
const working = header.getByRole("status", { name: "Working", exact: true })
|
||||
|
||||
await expect(working.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
const gap = await working.evaluate((element) => {
|
||||
const title = document.createRange()
|
||||
|
||||
title.selectNodeContents(element.nextElementSibling!)
|
||||
|
||||
return title.getBoundingClientRect().left - element.getBoundingClientRect().right
|
||||
})
|
||||
|
||||
expect(gap).toBe(8)
|
||||
|
||||
const before = await header.getByRole("heading").boundingBox()
|
||||
const exit = await pauseExitAnimations(working)
|
||||
|
||||
await timeline.transport.send(status("idle", 1, childID))
|
||||
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 100)))
|
||||
await expect(working).toBeAttached()
|
||||
|
||||
const midway = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(midway!.x).toBeLessThan(before!.x)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const after = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(after!.x).toBeLessThan(midway!.x)
|
||||
expect(after!.x).toBeCloseTo(before!.x - 24, 0)
|
||||
await exit.dispose()
|
||||
await header.locator('[data-slot="session-title-parent"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the running menu closed when work restarts after all subagents finish", async ({ page }) => {
|
||||
const childID = "ses_restarted_child"
|
||||
const otherID = "ses_counted_child"
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Restarted task", agent: "explore" }),
|
||||
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
|
||||
],
|
||||
sessionStatus: {},
|
||||
})
|
||||
|
||||
const trigger = page.locator("[data-session-title]").getByRole("button", { name: /^[12] working$/ })
|
||||
const menu = page.getByRole("menu", { name: "1 working", exact: true })
|
||||
|
||||
await timeline.transport.send(status("busy", 1, childID))
|
||||
await expect(trigger).toHaveAccessibleName("1 working")
|
||||
await timeline.transport.send(status("busy", 1, otherID))
|
||||
await expect(trigger).toHaveAccessibleName("2 working")
|
||||
await expect(trigger.locator("..")).toHaveCSS("animation-name", "none")
|
||||
await timeline.transport.send(status("idle", 1, otherID))
|
||||
await expect(trigger).toHaveAccessibleName("1 working")
|
||||
// Inspect the update itself, not the settled state after a replayed fade finishes.
|
||||
expect(await trigger.evaluate((element) => getComputedStyle(element.parentElement!).animationName)).toBe("none")
|
||||
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
|
||||
await timeline.transport.send(status("idle", 1, childID))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
await expect(menu).toHaveCount(0)
|
||||
await timeline.transport.send(status("busy", 1, childID))
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(menu).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("slides the subagent title left when its running switcher disappears", async ({ page }) => {
|
||||
const childID = "ses_collapsing_viewed"
|
||||
const otherID = "ses_collapsing_sibling"
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Viewed task", agent: "explore" }),
|
||||
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
|
||||
],
|
||||
sessionStatus: { [childID]: { type: "busy" }, [otherID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "2 working", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitem", { name: /Viewed task/ }).click()
|
||||
await expect(header.getByRole("heading")).toHaveText("Viewed task")
|
||||
await expect(trigger).toBeEnabled()
|
||||
|
||||
const before = await header.getByRole("heading").boundingBox()
|
||||
const width = await trigger.evaluate((element) => element.parentElement!.getBoundingClientRect().width)
|
||||
const exit = await pauseExitAnimations(trigger.locator(".."))
|
||||
|
||||
await timeline.transport.send(status("idle", 1, otherID))
|
||||
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
|
||||
expect(
|
||||
await exit.evaluate((animations) =>
|
||||
animations.some(
|
||||
(animation) =>
|
||||
animation.effect instanceof KeyframeEffect && animation.effect.getKeyframes().some((frame) => "width" in frame),
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 75)))
|
||||
|
||||
const midway = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(midway!.x).toBeLessThan(before!.x)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
|
||||
const after = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(after!.x).toBeLessThan(midway!.x)
|
||||
expect(after!.x).toBeCloseTo(before!.x - width - 2, 0)
|
||||
await exit.dispose()
|
||||
})
|
||||
|
||||
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
|
||||
const siblings = [
|
||||
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
|
||||
@@ -1099,6 +1246,15 @@ test.describe("background shortcut", () => {
|
||||
await trigger.click()
|
||||
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
|
||||
await expect(list).toHaveCount(0)
|
||||
await expect(header.getByRole("heading")).toHaveText("Draft TUI proposal")
|
||||
await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveCSS("font-variant-numeric", "tabular-nums")
|
||||
|
||||
const breadcrumbOrder = await header
|
||||
.locator('[data-slot="session-title-parent"], button[aria-label="3 running"], [data-slot="session-title-child"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-slot") ?? "running"))
|
||||
|
||||
expect(breadcrumbOrder).toEqual(["session-title-parent", "running", "session-title-child"])
|
||||
|
||||
await trigger.click()
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
@@ -1108,8 +1264,26 @@ test.describe("background shortcut", () => {
|
||||
])
|
||||
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
|
||||
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
|
||||
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session/ses_sibling_two/message",
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_two$/)
|
||||
await requested.promise
|
||||
await Promise.all([
|
||||
expect(page).toHaveURL(/\/session\/ses_sibling_two$/),
|
||||
expect(header.getByRole("heading")).toHaveText("Fix context controls"),
|
||||
expect(trigger).toBeEnabled(),
|
||||
]).finally(() => release.resolve())
|
||||
await expect(list).toHaveCount(0)
|
||||
|
||||
// The shell call lives in the parent: the row opens it there, expanded.
|
||||
await trigger.click()
|
||||
@@ -1531,6 +1705,22 @@ test.describe("shell completion", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function pauseExitAnimations(locator: Locator) {
|
||||
return locator.evaluateHandle((element) => {
|
||||
const animations: Animation[] = []
|
||||
const pause = (event: AnimationEvent) => {
|
||||
if (event.target !== element || event.animationName !== "exit") return
|
||||
|
||||
animations.push(...element.getAnimations())
|
||||
animations.forEach((animation) => animation.pause())
|
||||
element.removeEventListener("animationstart", pause)
|
||||
}
|
||||
|
||||
element.addEventListener("animationstart", pause)
|
||||
return animations
|
||||
})
|
||||
}
|
||||
|
||||
function runningSubagent(): SessionMessageAssistant {
|
||||
return {
|
||||
...completed,
|
||||
|
||||
@@ -524,11 +524,11 @@ export function messageUpdated(info: SessionMessageAssistant) {
|
||||
})
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
if (type === "busy") return makeEvent("session.execution.started", { sessionID })
|
||||
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
|
||||
export function status(type: SessionStatus["type"], attempt = 1, id = sessionID) {
|
||||
if (type === "busy") return makeEvent("session.execution.started", { sessionID: id })
|
||||
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID: id })
|
||||
return makeEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
sessionID: id,
|
||||
assistantMessageID: assistantID,
|
||||
attempt,
|
||||
at: 1700000010000,
|
||||
|
||||
@@ -516,8 +516,8 @@ export const dict = {
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.running.working.one": "{{count}} working…",
|
||||
"session.running.working.other": "{{count}} working…",
|
||||
"session.running.working.one": "{{count}} working",
|
||||
"session.running.working.other": "{{count}} working",
|
||||
"session.running.running.one": "{{count}} running",
|
||||
"session.running.running.other": "{{count}} running",
|
||||
"session.running.stop.subagent": "Interrupt subagent",
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -30,13 +31,19 @@ export function SessionRunningMenu(props: {
|
||||
owner?: string
|
||||
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
|
||||
tasks: readonly BackgroundTask[]
|
||||
onReveal: (target: string) => void
|
||||
onReveal?: (target: string) => void
|
||||
separator?: "before" | "after"
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const openRoute = useOpenSessionRoute()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [appearance, setAppearance] = createStore<{
|
||||
group?: HTMLDivElement
|
||||
collapse?: Animation
|
||||
entered: boolean
|
||||
}>({ entered: false })
|
||||
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
|
||||
|
||||
// Foreground shells stay out: the timeline already shows them at the bottom.
|
||||
@@ -62,16 +69,58 @@ export function SessionRunningMenu(props: {
|
||||
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
|
||||
])
|
||||
|
||||
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
|
||||
|
||||
const label = createMemo(() => {
|
||||
const count = items().length
|
||||
|
||||
if (!count || (count === 1 && viewing(items()[0]))) return undefined
|
||||
|
||||
if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count)
|
||||
|
||||
return language.plural("session.running.working", count)
|
||||
})
|
||||
|
||||
// Inside a subagent, its own row is listed among its siblings.
|
||||
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
|
||||
const presence = createAnimatedPresence(
|
||||
label,
|
||||
() => appearance.group ?? null,
|
||||
() => props.sessionID,
|
||||
)
|
||||
// Label updates change presence state without changing visibility.
|
||||
const visible = createMemo(presence.show)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
visible,
|
||||
(show) => {
|
||||
if (show) {
|
||||
appearance.collapse?.cancel()
|
||||
setAppearance({ collapse: undefined, entered: false })
|
||||
return
|
||||
}
|
||||
|
||||
setMenu("open", false)
|
||||
|
||||
const group = appearance.group
|
||||
|
||||
if (!group || props.separator !== "after" || matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
||||
|
||||
setAppearance(
|
||||
"collapse",
|
||||
group.animate(
|
||||
[
|
||||
{ width: `${group.getBoundingClientRect().width}px`, marginInlineEnd: "0px" },
|
||||
// Cancel the header's 2px gap so removing the collapsed slot doesn't nudge the title.
|
||||
{ width: "0px", marginInlineEnd: "-2px" },
|
||||
],
|
||||
{ duration: 150, easing: "cubic-bezier(0, 0, 0.2, 1)", fill: "forwards" },
|
||||
),
|
||||
)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(() => appearance.collapse?.cancel())
|
||||
|
||||
const open = (item: RunningItem) => {
|
||||
const current = props.sessionID
|
||||
@@ -83,7 +132,9 @@ export function SessionRunningMenu(props: {
|
||||
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
|
||||
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
|
||||
|
||||
props.onReveal(item.target)
|
||||
if (props.onReveal) return props.onReveal(item.target)
|
||||
|
||||
openRoute(current, current, item.target)
|
||||
}
|
||||
|
||||
// A subagent can only be interrupted once its child session exists.
|
||||
@@ -106,99 +157,133 @@ export function SessionRunningMenu(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={items().length > 0}>
|
||||
<Menu gutter={6} placement="bottom-start" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={label()}
|
||||
// 8px from the ··· menu, or from the padded title inside a subagent, which has no menu.
|
||||
class={`${props.owner === props.sessionID ? "ms-1.5" : "ms-0.5"} flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover`}
|
||||
<Show when={presence.present()}>
|
||||
<div
|
||||
ref={(element) => setAppearance("group", element)}
|
||||
inert={!presence.show()}
|
||||
class="flex shrink-0 items-center gap-0.5 duration-150 ease-out motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": presence.animate() && presence.show() && !appearance.entered,
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
"overflow-hidden": props.separator === "after" && !presence.show(),
|
||||
}}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
|
||||
}}
|
||||
>
|
||||
<Show when={props.separator !== "after"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="shrink-0 ps-2 pe-1 text-[11px] font-medium leading-text-compact text-v2-text-text-faint"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-start"
|
||||
open={menu.open && presence.show()}
|
||||
onOpenChange={(open) => setMenu("open", open)}
|
||||
>
|
||||
<TextShimmer text={label()} active />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-60" aria-label={label()}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Menu.Item
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
open(item)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={presence.value()}
|
||||
class="flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] tabular-nums whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover"
|
||||
>
|
||||
<TextShimmer text={presence.value() ?? ""} active={presence.show()} />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-60" aria-label={presence.value()}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Menu.Item
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
open(item)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
|
||||
event.preventDefault()
|
||||
stop(item)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "subagent"}
|
||||
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
|
||||
<SessionProgressIndicatorV2
|
||||
class="shrink-0"
|
||||
style={{
|
||||
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.type === "shell"
|
||||
? language.t("ui.tool.shell")
|
||||
: item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
|
||||
<span
|
||||
dir="auto"
|
||||
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{
|
||||
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
|
||||
stoppable(item),
|
||||
event.preventDefault()
|
||||
stop(item)
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={stoppable(item)}>
|
||||
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
|
||||
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
|
||||
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
tabIndex={-1}
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t(
|
||||
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
stop(item)
|
||||
<Show
|
||||
when={item.type === "subagent"}
|
||||
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
|
||||
<SessionProgressIndicatorV2
|
||||
class="shrink-0"
|
||||
style={{
|
||||
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.type === "shell"
|
||||
? language.t("ui.tool.shell")
|
||||
: item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
|
||||
<span
|
||||
dir="auto"
|
||||
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{
|
||||
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
|
||||
stoppable(item),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={stoppable(item)}>
|
||||
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
|
||||
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
|
||||
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
tabIndex={-1}
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t(
|
||||
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
stop(item)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={props.separator === "after"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="shrink-0 ps-2 pe-1 text-[11px] font-medium leading-text-compact text-v2-text-text-faint"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
export function SessionWorkingIndicator(props: { sessionID?: string }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [elements, setElements] = createStore<{ indicator?: HTMLSpanElement }>({})
|
||||
const presence = createAnimatedPresence(
|
||||
() => {
|
||||
const id = props.sessionID
|
||||
|
||||
return id && server.ctx.data.session.get(id)?.parentID && server.ctx.data.session.status(id) === "running"
|
||||
? true
|
||||
: undefined
|
||||
},
|
||||
() => elements.indicator ?? null,
|
||||
() => props.sessionID,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<span
|
||||
ref={(element) => setElements("indicator", element)}
|
||||
role="status"
|
||||
aria-label={language.t("session.timeline.working")}
|
||||
data-visible={presence.show()}
|
||||
// Cancel the header's 2px gap at the end of the collapse so unmounting doesn't nudge the title.
|
||||
class="ms-1 me-0.5 flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden text-v2-icon-icon-muted duration-200 ease-out data-[visible=false]:ms-0 data-[visible=false]:-me-0.5 data-[visible=false]:w-0 data-[visible=false]:opacity-0 motion-reduce:animate-none motion-reduce:transition-none"
|
||||
classList={{
|
||||
"transition-[width,margin,opacity]": presence.animate(),
|
||||
// Presence waits for animationend; transitions alone would unmount the icon immediately.
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
</span>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { SessionRunningMenu } from "./header/session-running-menu"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
|
||||
@@ -333,7 +334,15 @@ function SessionScreenContent(props: {
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()}>
|
||||
<SessionRunningMenu
|
||||
sessionID={session.identity.params.id}
|
||||
owner={composer.requests.background.running.sessionID()}
|
||||
blocking={composer.requests.background.running.blocking()}
|
||||
tasks={composer.requests.background.running.tasks()}
|
||||
separator={session.data.parentID() ? "after" : "before"}
|
||||
/>
|
||||
</SessionIdentityHeader>
|
||||
</Show>
|
||||
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
|
||||
</Match>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import { SessionWorkingIndicator } from "./header/session-working-indicator"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
@@ -253,16 +254,16 @@ export function SessionAncestorTrail(props: {
|
||||
data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"}
|
||||
data-session-id={ancestor.id}
|
||||
title={ancestor.title}
|
||||
dir="auto"
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
classList={{ "ps-1": index() === 0, "ps-2": index() > 0 }}
|
||||
onClick={() => open(props.sessionID, ancestor.id)}
|
||||
>
|
||||
{ancestor.title}
|
||||
<bdi dir="auto">{ancestor.title}</bdi>
|
||||
</button>
|
||||
<Show when={index() < ancestors().length - 1}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -275,7 +276,7 @@ export function SessionAncestorTrail(props: {
|
||||
<Show when={props.trailing}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -305,7 +306,7 @@ export function useOpenSessionRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; session?: SessionInfo }>) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
@@ -394,6 +395,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID()}>{props.children}</Show>
|
||||
<SessionWorkingIndicator sessionID={props.sessionID} />
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
@@ -406,6 +409,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
</h1>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>{props.children}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import { SessionRunningMenu } from "@/session/header/session-running-menu"
|
||||
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
|
||||
|
||||
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
|
||||
|
||||
@@ -391,6 +392,17 @@ function MessageTimelineView(
|
||||
})
|
||||
})
|
||||
|
||||
const runningMenu = (separator: "before" | "after") => (
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
separator={separator}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
@@ -456,6 +468,8 @@ function MessageTimelineView(
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID()}>{runningMenu("after")}</Show>
|
||||
<SessionWorkingIndicator sessionID={sessionID()} />
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
@@ -506,6 +520,7 @@ function MessageTimelineView(
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!parentID()}>{runningMenu("before")}</Show>
|
||||
<Show when={!parentID() && sessionID()} keyed>
|
||||
{(id) => (
|
||||
<Menu
|
||||
@@ -559,13 +574,6 @@ function MessageTimelineView(
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
|
||||
Reference in new issue
Block a user