Compare commits

...
9 changed files with 492 additions and 114 deletions

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,
+4 -4
View File
@@ -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,
+2 -2
View File
@@ -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>
)
}
+10 -1
View File
@@ -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>