mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
refactor(app): move running work controls into the activity capsule
Remove the "N running" indicator and menu from the session header; the composer's activity capsule already lists running subagents and shells. Subagent sessions now show a plain title with the working indicator. Each capsule panel row gets a trailing × that interrupts the subagent or kills the shell (also Delete/Backspace on the highlighted row), reusing the existing stop strings and requests. Picking a shell or a starting subagent call now reveals it in the current timeline instead of re-navigating to the same route, which never opened it. Drop the now-unused session.running count strings and the parent-owned background state that only the header menu read.
This commit is contained in:
@@ -957,12 +957,14 @@ test.describe("background shortcut", () => {
|
||||
|
||||
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await page.getByRole("button", { name: "3 running", exact: true }).click()
|
||||
const list = page.getByRole("menu", { name: "3 running", exact: true })
|
||||
const activity = page.getByRole("button", { name: "2 subagents · 1 shell", exact: true })
|
||||
const list = page.getByRole("menu", { name: "2 subagents · 1 shell", exact: true })
|
||||
|
||||
await activity.click()
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
/^Build\s*Foreground task$/,
|
||||
/^Explore\s*Background task$/,
|
||||
/^Shell\s*sleep 120$/,
|
||||
/^sleep 120$/,
|
||||
])
|
||||
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
|
||||
await expect(list).toHaveCount(0)
|
||||
@@ -971,7 +973,7 @@ test.describe("background shortcut", () => {
|
||||
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellCall).toBeInViewport()
|
||||
|
||||
await page.getByRole("button", { name: "3 running", exact: true }).click()
|
||||
await activity.click()
|
||||
|
||||
const interrupt = page.waitForRequest(
|
||||
(request) =>
|
||||
@@ -1083,9 +1085,9 @@ test.describe("background shortcut", () => {
|
||||
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
|
||||
).toBeInViewport()
|
||||
await expect(shellTrigger).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "1 running", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 shell", exact: true }).click()
|
||||
await page
|
||||
.getByRole("menu", { name: "1 running", exact: true })
|
||||
.getByRole("menu", { name: "1 shell", exact: true })
|
||||
.getByRole("menuitem", { name: /sleep 120/ })
|
||||
.click()
|
||||
|
||||
@@ -1105,7 +1107,7 @@ test.describe("background shortcut", () => {
|
||||
})
|
||||
}
|
||||
|
||||
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
|
||||
test("shows a running subagent's working indicator beside its title", async ({ page }) => {
|
||||
const childID = "ses_only_running_child"
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
@@ -1118,13 +1120,11 @@ test.describe("background shortcut", () => {
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "1 working", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("button", { name: "1 subagent", exact: true }).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 })
|
||||
|
||||
@@ -1140,7 +1140,7 @@ test.describe("background shortcut", () => {
|
||||
|
||||
expect(gap).toBe(8)
|
||||
|
||||
const title = header.getByRole("heading").locator('span[dir="auto"]')
|
||||
const title = header.getByRole("heading")
|
||||
const before = await title.boundingBox()
|
||||
const exit = await pauseExitAnimations(working)
|
||||
|
||||
@@ -1162,243 +1162,6 @@ test.describe("background shortcut", () => {
|
||||
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("keeps the subagent title anchored when its additional count 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]")
|
||||
await header.getByRole("button", { name: "2 working", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: /Viewed task/ }).click()
|
||||
await expect(header.getByRole("heading")).toHaveAccessibleName("Viewed task")
|
||||
|
||||
const trigger = header.getByRole("button", { name: "Viewed task +1 working", exact: true })
|
||||
const title = header.getByRole("heading").locator('span[dir="auto"]')
|
||||
|
||||
await expect(trigger).toBeEnabled()
|
||||
|
||||
const before = await title.boundingBox()
|
||||
const exit = await pauseExitAnimations(trigger.locator('[data-component="text-shimmer"]').locator(".."))
|
||||
|
||||
await timeline.transport.send(status("idle", 1, otherID))
|
||||
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 75)))
|
||||
|
||||
const midway = await title.boundingBox()
|
||||
|
||||
expect(midway!.x).toBeCloseTo(before!.x, 0)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
|
||||
const after = await title.boundingBox()
|
||||
|
||||
expect(after!.x).toBeCloseTo(before!.x, 0)
|
||||
await expect(header.getByRole("button", { name: "Viewed task", exact: true })).toBeDisabled()
|
||||
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" },
|
||||
{ id: "ses_sibling_two", agent: "build", description: "Fix context controls" },
|
||||
]
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: { timelineDetail: detailed },
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_sibling_shell",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_sibling_shell",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "sleep 120" },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { shellID: "shell_sibling", status: "running" },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_siblings",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: siblings.map((item) => ({
|
||||
type: "tool" as const,
|
||||
id: `call_${item.id}`,
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running" as const,
|
||||
input: { description: item.description, agent: item.agent },
|
||||
metadata: { sessionID: item.id },
|
||||
},
|
||||
time: { created: 2 },
|
||||
})),
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
session(),
|
||||
...siblings.map((item) =>
|
||||
session({ id: item.id, parentID: sessionID, title: item.description, agent: item.agent }),
|
||||
),
|
||||
],
|
||||
sessionStatus: Object.fromEntries(
|
||||
[sessionID, ...siblings.map((item) => item.id)].map((id) => [id, { type: "busy" }]),
|
||||
),
|
||||
})
|
||||
|
||||
await timeline.transport.send({
|
||||
id: "evt_sibling_shell_created",
|
||||
created: 3,
|
||||
type: "shell.created",
|
||||
location: { directory },
|
||||
data: {
|
||||
info: {
|
||||
id: "shell_sibling",
|
||||
status: "running",
|
||||
command: "sleep 120",
|
||||
cwd: directory,
|
||||
shell: "bash",
|
||||
file: "/tmp/sibling.out",
|
||||
metadata: { sessionID },
|
||||
time: { started: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: /(?:^3 running$|\+2 running$)/ })
|
||||
const list = page.getByRole("menu", { name: /(?:^3 running$|\+2 running$)/ })
|
||||
|
||||
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")).toHaveAccessibleName("Draft TUI proposal")
|
||||
await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveCSS("font-variant-numeric", "tabular-nums")
|
||||
|
||||
const countGap = await trigger.evaluate((element) => {
|
||||
const title = document.createRange()
|
||||
|
||||
title.selectNodeContents(element.querySelector('span[dir="auto"]')!)
|
||||
|
||||
return (
|
||||
element.querySelector('[data-component="text-shimmer"]')!.getBoundingClientRect().left -
|
||||
title.getBoundingClientRect().right
|
||||
)
|
||||
})
|
||||
|
||||
expect(countGap).toBe(8)
|
||||
|
||||
const breadcrumbOrder = await header
|
||||
.locator('[data-slot="session-title-parent"], [data-slot="session-title-child"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-slot")))
|
||||
|
||||
expect(breadcrumbOrder).toEqual(["session-title-parent", "session-title-child"])
|
||||
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveCount(1)
|
||||
await expect(header.getByRole("heading").getByRole("button", { name: "Draft TUI proposal +2 running" })).toBeEnabled()
|
||||
|
||||
await trigger.click()
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
/^Explore\s*Draft TUI proposal/,
|
||||
/^Build\s*Fix context controls/,
|
||||
/^Shell\s*sleep 120/,
|
||||
])
|
||||
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 requested.promise
|
||||
await Promise.all([
|
||||
expect(page).toHaveURL(/\/session\/ses_sibling_two$/),
|
||||
expect(header.getByRole("heading")).toHaveAccessibleName("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()
|
||||
await list.getByRole("menuitem", { name: /sleep 120/ }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
|
||||
const shellCall = page.locator('[data-timeline-part-id="call_sibling_shell"]')
|
||||
|
||||
await expect(shellCall.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shellCall).toBeInViewport()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -536,14 +536,6 @@ 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.running.one": "{{count}} running",
|
||||
"session.running.running.other": "{{count}} running",
|
||||
"session.running.additionalWorking.one": "+{{count}} working",
|
||||
"session.running.additionalWorking.other": "+{{count}} working",
|
||||
"session.running.additionalRunning.one": "+{{count}} running",
|
||||
"session.running.additionalRunning.other": "+{{count}} running",
|
||||
"session.running.stop.subagent": "Interrupt subagent",
|
||||
"session.running.stop.shell": "Kill shell command",
|
||||
|
||||
|
||||
@@ -94,6 +94,26 @@
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* A trailing stop button ends 4px in, mirroring the glyph's inset at the start. */
|
||||
[data-component="menu-v2-item"][data-stoppable] {
|
||||
padding-inline-end: 4px;
|
||||
}
|
||||
|
||||
/* Hidden rather than removed, so revealing it on hover does not resize the panel. */
|
||||
[data-slot="session-activity-stop"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-component="menu-v2-item"]:is(:hover, [data-highlighted]) [data-slot="session-activity-stop"] {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-slot="session-activity-stop"] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="menu-v2-group-label"] {
|
||||
height: 24px;
|
||||
padding-inline: 4px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { For, Index, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -18,12 +19,51 @@ export function SessionActivityChips(props: {
|
||||
subagents: readonly SessionActivityItem[]
|
||||
shells: readonly SessionActivityItem[]
|
||||
onOpen: (item: SessionActivityItem) => void
|
||||
onStop: (item: SessionActivityItem) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
// With one kind running, the capsule row already names it.
|
||||
const both = () => props.subagents.length > 0 && props.shells.length > 0
|
||||
|
||||
// A subagent can only be interrupted once its child session exists.
|
||||
const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID
|
||||
|
||||
const row = (item: SessionActivityItem) => ({
|
||||
"data-stoppable": stoppable(item) || undefined,
|
||||
onSelect: () => props.onOpen(item),
|
||||
onKeyDown: (event: KeyboardEvent) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
|
||||
event.preventDefault()
|
||||
props.onStop(item)
|
||||
},
|
||||
})
|
||||
|
||||
const stop = (item: SessionActivityItem) => (
|
||||
<Show when={stoppable(item)}>
|
||||
{/* 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}
|
||||
data-slot="session-activity-stop"
|
||||
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()
|
||||
props.onStop(item)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const label = () => language.activitySummary({ subagents: props.subagents.length, shells: props.shells.length })
|
||||
|
||||
return (
|
||||
@@ -48,16 +88,21 @@ export function SessionActivityChips(props: {
|
||||
</Show>
|
||||
<For each={props.subagents}>
|
||||
{(item) => (
|
||||
<Menu.Item onSelect={() => props.onOpen(item)}>
|
||||
<Menu.Item {...row(item)}>
|
||||
<SessionActivityGlyph class="text-v2-icon-icon-muted" />
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
<span dir="auto" class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted">
|
||||
<span
|
||||
dir="auto"
|
||||
class="min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{ "me-1.5": !stoppable(item) }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{stop(item)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
@@ -73,14 +118,12 @@ export function SessionActivityChips(props: {
|
||||
</Show>
|
||||
<For each={props.shells}>
|
||||
{(item) => (
|
||||
<Menu.Item onSelect={() => props.onOpen(item)}>
|
||||
<Menu.Item {...row(item)}>
|
||||
<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span
|
||||
dir="ltr"
|
||||
class="me-1.5 min-w-0 flex-1 truncate font-mono text-[12px] text-v2-text-text-base"
|
||||
>
|
||||
<span dir="ltr" class="min-w-0 flex-1 truncate font-mono text-[12px] text-v2-text-text-base">
|
||||
{item.label}
|
||||
</span>
|
||||
{stop(item)}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { errorMessage } from "@/shell/layout/helpers"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -295,9 +296,12 @@ export function ActiveSessionComposerRegion(props: {
|
||||
session: SessionModel
|
||||
model: SessionComposerController
|
||||
suggestionBoundary: () => HTMLElement | undefined
|
||||
onReveal: (target: string) => void
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const openRoute = useOpenSessionRoute()
|
||||
|
||||
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
|
||||
@@ -351,8 +355,26 @@ export function ActiveSessionComposerRegion(props: {
|
||||
|
||||
if (!current) return
|
||||
|
||||
// Shells and starting subagent calls live in this timeline, which reveals them once opened.
|
||||
openRoute(current, item.sessionID ?? current, item.sessionID ? undefined : item.target)
|
||||
if (item.sessionID) return openRoute(current, item.sessionID)
|
||||
|
||||
// Shells and starting subagent calls live in this timeline.
|
||||
props.onReveal(item.target)
|
||||
}
|
||||
|
||||
const stop = (item: SessionActivityItem) => {
|
||||
const request = item.sessionID
|
||||
? server.api.session.interrupt({ sessionID: item.sessionID })
|
||||
: server.api.shell.remove({
|
||||
id: item.target,
|
||||
location: { directory: data.shell.get(item.target)?.location.directory },
|
||||
})
|
||||
|
||||
void request.catch((error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -364,7 +386,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} />
|
||||
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} onStop={stop} />
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { BackgroundTask } from "@opencode/gui-extensions/sdk"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
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 { createEffect, createMemo, createUniqueId, For, on, 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"
|
||||
import { useOpenSessionRoute } from "@/session/session-identity-header"
|
||||
import { errorMessage } from "@/shell/layout/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionWorkingIndicator } from "./session-working-indicator"
|
||||
|
||||
const neutral = "light-dark(var(--v2-text-text-base), #ffffff)"
|
||||
|
||||
type RunningItem = {
|
||||
key: string
|
||||
type: "subagent" | "shell"
|
||||
label?: string
|
||||
agent?: string
|
||||
sessionID?: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export function SessionRunningMenu(props: {
|
||||
sessionID?: string
|
||||
// The session whose running work is listed: this one, or its parent inside a subagent.
|
||||
owner?: string
|
||||
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
|
||||
tasks: readonly BackgroundTask[]
|
||||
onReveal?: (target: string) => void
|
||||
title?: string
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const openRoute = useOpenSessionRoute()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const id = createUniqueId()
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
|
||||
const [appearance, setAppearance] = createStore<{
|
||||
group?: HTMLElement
|
||||
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.
|
||||
const items = createMemo<RunningItem[]>(() => [
|
||||
...props.blocking.flatMap((task) =>
|
||||
task.type === "subagent"
|
||||
? [
|
||||
{
|
||||
...task,
|
||||
key: task.id ?? task.partID,
|
||||
agent: sessionAgent(task.id),
|
||||
sessionID: task.id,
|
||||
target: task.partID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...props.tasks.flatMap((task) =>
|
||||
task.type === "subagent"
|
||||
? [{ ...task, key: task.id, agent: task.agent ?? sessionAgent(task.id), sessionID: task.id, target: task.id }]
|
||||
: [],
|
||||
),
|
||||
...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 = props.title ? items().filter((item) => !viewing(item)).length : items().length
|
||||
|
||||
if (!count || (!props.title && count === 1 && viewing(items()[0]))) return undefined
|
||||
|
||||
if (items().some((item) => item.type === "shell")) {
|
||||
return language.plural(props.title ? "session.running.additionalRunning" : "session.running.running", count)
|
||||
}
|
||||
|
||||
return language.plural(props.title ? "session.running.additionalWorking" : "session.running.working", count)
|
||||
})
|
||||
|
||||
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) {
|
||||
setAppearance("entered", false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setMenu("open", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const open = (item: RunningItem) => {
|
||||
const current = props.sessionID
|
||||
|
||||
if (!current || viewing(item)) return
|
||||
|
||||
if (item.sessionID) return openRoute(current, item.sessionID)
|
||||
|
||||
// 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)
|
||||
|
||||
if (props.onReveal) return props.onReveal(item.target)
|
||||
|
||||
openRoute(current, current, item.target)
|
||||
}
|
||||
|
||||
// A subagent can only be interrupted once its child session exists.
|
||||
const stoppable = (item: RunningItem) => item.type === "shell" || !!item.sessionID
|
||||
|
||||
const stop = (item: RunningItem) => {
|
||||
const request = item.sessionID
|
||||
? sdk.api.session.interrupt({ sessionID: item.sessionID })
|
||||
: sdk.api.shell.remove({
|
||||
id: item.target,
|
||||
location: { directory: server.ctx.data.shell.get(item.target)?.location.directory },
|
||||
})
|
||||
|
||||
void request.catch((error) =>
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(error, language.t("common.requestFailed")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.title || presence.present()}>
|
||||
<div
|
||||
ref={(element) => {
|
||||
if (!props.title) setAppearance("group", element)
|
||||
}}
|
||||
inert={!props.title && !presence.show()}
|
||||
class="flex min-w-0 shrink-0 items-center gap-0.5 duration-150 ease-out motion-reduce:animate-none"
|
||||
classList={{
|
||||
"max-w-[45%]": !!props.title,
|
||||
"animate-in fade-in": !props.title && presence.animate() && presence.show() && !appearance.entered,
|
||||
"animate-out fade-out fill-mode-forwards": !props.title && presence.animate() && !presence.show(),
|
||||
}}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
|
||||
}}
|
||||
>
|
||||
<Show when={!props.title}>
|
||||
<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)}
|
||||
>
|
||||
<Show
|
||||
when={props.title}
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
aria-label={props.title}
|
||||
class="min-w-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={!visible()}
|
||||
aria-labelledby={`${id}-title${visible() ? ` ${id}-count` : ""}`}
|
||||
class="flex h-7 max-w-full items-center gap-0.5 rounded-[6px] outline-none enabled: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"
|
||||
>
|
||||
<SessionWorkingIndicator sessionID={props.sessionID} />
|
||||
<span id={`${id}-title`} dir="auto" class="min-w-0 truncate px-1">
|
||||
{props.title}
|
||||
</span>
|
||||
<Show when={presence.present()}>
|
||||
<span
|
||||
id={`${id}-count`}
|
||||
ref={(element) => setAppearance("group", element)}
|
||||
class="shrink-0 ps-0.5 pe-1 tabular-nums text-v2-text-text-muted 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(),
|
||||
}}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={presence.value() ?? ""} active={presence.show()} />
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Trigger>
|
||||
</h1>
|
||||
</Show>
|
||||
<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),
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -92,17 +92,8 @@ export function createSessionRequestModel() {
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
|
||||
// Inside a subagent, running work is the parent's, so the header lists its siblings.
|
||||
const owner = createMemo(() => {
|
||||
const id = params.id
|
||||
|
||||
if (!id) return
|
||||
|
||||
return data.session.get(id)?.parentID ?? id
|
||||
})
|
||||
|
||||
const running = createSessionBackground({
|
||||
sessionID: owner,
|
||||
sessionID: () => params.id,
|
||||
messages: data.session.message.list,
|
||||
sessions: data.session.list,
|
||||
status: data.session.status,
|
||||
@@ -166,7 +157,6 @@ export function createSessionRequestModel() {
|
||||
background: {
|
||||
blocking,
|
||||
tasks,
|
||||
running: { sessionID: owner, blocking: running.blocking, tasks: running.tasks },
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
|
||||
@@ -36,7 +36,6 @@ 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"
|
||||
|
||||
@@ -221,6 +220,7 @@ function SessionScreenContent(props: {
|
||||
setContentRef={timeline.view.setContentRef}
|
||||
anchor={timeline.view.anchor}
|
||||
setRevealMessage={timeline.view.setRevealMessage}
|
||||
setRevealPart={timeline.view.setRevealPart}
|
||||
reveal={timeline.view.reveal}
|
||||
setScrollToEnd={timeline.view.setScrollToEnd}
|
||||
search={
|
||||
@@ -304,15 +304,7 @@ function SessionScreenContent(props: {
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<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()}
|
||||
title={session.data.parentID() ? session.data.info()?.title : undefined}
|
||||
/>
|
||||
</SessionIdentityHeader>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
|
||||
</Match>
|
||||
@@ -321,7 +313,12 @@ function SessionScreenContent(props: {
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
|
||||
<ActiveSessionComposerRegion
|
||||
session={session}
|
||||
model={model}
|
||||
suggestionBoundary={timeline.scroller}
|
||||
onReveal={timeline.actions.revealPart}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
|
||||
|
||||
@@ -310,7 +310,7 @@ export function useOpenSessionRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; session?: SessionInfo }>) {
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
@@ -399,11 +399,8 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID() && props.children}>{props.children}</Show>
|
||||
<Show when={!parentID() || !props.children}>
|
||||
<SessionWorkingIndicator sessionID={props.sessionID} />
|
||||
</Show>
|
||||
<Show when={(!parentID() || !props.children) && title()}>
|
||||
<SessionWorkingIndicator sessionID={props.sessionID} />
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
@@ -415,7 +412,6 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
|
||||
</h1>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>{props.children}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +90,7 @@ export type SessionPreviewProps = {
|
||||
subagents: SessionActivityItem[]
|
||||
shells: SessionActivityItem[]
|
||||
onOpen?: (item: SessionActivityItem) => void
|
||||
onStop?: (item: SessionActivityItem) => void
|
||||
}
|
||||
timelineDetail?: TimelineDetail
|
||||
}
|
||||
@@ -294,6 +295,11 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
? props.activity.onOpen(item)
|
||||
: setState("activity", `Opened ${item.label ?? item.key}`)
|
||||
}
|
||||
onStop={(item) =>
|
||||
props.activity?.onStop
|
||||
? props.activity.onStop(item)
|
||||
: setState("activity", `Stopped ${item.label ?? item.key}`)
|
||||
}
|
||||
/>
|
||||
<div class="relative z-10">
|
||||
<Composer model={prompt.controller} borderUnderlay />
|
||||
|
||||
@@ -59,6 +59,8 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
// The active timeline's navigation, registered by its virtualizer.
|
||||
let revealMessage = (_id: string, _partID?: string) => {}
|
||||
|
||||
let revealPart = (_target: string) => false
|
||||
|
||||
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
|
||||
|
||||
let scrollMark = 0
|
||||
@@ -362,6 +364,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
actions: {
|
||||
navigateMessage,
|
||||
revealMessage: (id: string, partID?: string) => revealMessage(id, partID),
|
||||
revealPart: (target: string) => revealPart(target),
|
||||
resume,
|
||||
setActiveMessage,
|
||||
},
|
||||
@@ -389,6 +392,9 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
setRevealMessage: (reveal: (id: string, partID?: string) => void) => {
|
||||
revealMessage = reveal
|
||||
},
|
||||
setRevealPart: (reveal: (target: string) => boolean) => {
|
||||
revealPart = reveal
|
||||
},
|
||||
// The tool call the route opened the session on, revealed once per history entry.
|
||||
reveal: {
|
||||
target: () => (revealed === location.key ? undefined : location.state?.reveal),
|
||||
|
||||
@@ -27,18 +27,13 @@ import { parseVoiceDelegation } from "./voice-delegation"
|
||||
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 }
|
||||
|
||||
type SessionBackground = {
|
||||
blocking: Accessor<BlockingTask[]>
|
||||
tasks: Accessor<readonly BackgroundTask[]>
|
||||
running: {
|
||||
sessionID: Accessor<string | undefined>
|
||||
blocking: Accessor<BlockingTask[]>
|
||||
tasks: Accessor<readonly BackgroundTask[]>
|
||||
}
|
||||
move: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -87,6 +82,7 @@ type MessageTimelineProps = {
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setRevealPart?: (fn: (target: string) => boolean) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
|
||||
reveal?: { target: () => string | undefined; done: () => void }
|
||||
search?: JSX.Element
|
||||
@@ -232,6 +228,7 @@ function MessageTimelineView(
|
||||
)
|
||||
},
|
||||
setRevealMessage: props.setRevealMessage,
|
||||
setRevealPart: props.setRevealPart,
|
||||
setScrollToEnd: props.setScrollToEnd,
|
||||
reveal: props.reveal,
|
||||
})
|
||||
@@ -431,17 +428,6 @@ function MessageTimelineView(
|
||||
</Show>
|
||||
)
|
||||
|
||||
const runningMenu = (title?: string) => (
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
@@ -481,7 +467,20 @@ function MessageTimelineView(
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID()}>{runningMenu(childTitle())}</Show>
|
||||
<Show when={parentID() && childTitle()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<SessionWorkingIndicator sessionID={sessionID()} />
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
class="max-w-[45%] shrink-0 w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID() && (childTitle() || title.editing)}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
@@ -532,7 +531,6 @@ function MessageTimelineView(
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!parentID()}>{runningMenu()}</Show>
|
||||
<Show when={!parentID() && sessionID()} keyed>
|
||||
{(id) => (
|
||||
<Menu
|
||||
|
||||
@@ -77,6 +77,7 @@ type Input = {
|
||||
disclosure: Readonly<Record<string, boolean | undefined>>,
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setRevealPart?: (fn: (target: string) => boolean) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
|
||||
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
|
||||
reveal?: { target: () => string | undefined; done: () => void }
|
||||
@@ -512,6 +513,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
reportOffset?.(root.scrollTop, false)
|
||||
settleColdBottom()
|
||||
})
|
||||
input.setRevealPart?.(revealPart)
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
if (!active()) return
|
||||
|
||||
@@ -946,6 +948,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (active()) {
|
||||
input.setScrollRef(undefined)
|
||||
input.setRevealMessage?.(() => {})
|
||||
input.setRevealPart?.(() => false)
|
||||
input.setScrollToEnd?.(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user