feat(app): show v2 timeline notices and background work
This commit is contained in:
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
@@ -90,6 +90,8 @@ export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
currentMessages?: SessionMessageInfo[]
|
||||
sessionStatus?: Record<string, SessionStatus>
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
@@ -102,13 +104,22 @@ export async function setupTimeline(
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages = validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||
const messages =
|
||||
input.currentMessages ??
|
||||
validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) =>
|
||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
||||
)
|
||||
const initialStatus = decodeStatus(
|
||||
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||
active &&
|
||||
("info" in active
|
||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
||||
: active.type === "assistant" && active.time.completed === undefined)
|
||||
? { type: "busy" }
|
||||
: { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
@@ -122,7 +133,7 @@ export async function setupTimeline(
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: { [sessionID]: initialStatus },
|
||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
||||
({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
}) satisfies SessionMessageInfo
|
||||
|
||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
assistant(true),
|
||||
{
|
||||
id: "msg_subagent",
|
||||
type: "synthetic",
|
||||
text: "done",
|
||||
description: "Search code",
|
||||
metadata: { source: "subagent", agent: "explore", state: "completed" },
|
||||
time: { created: 4 },
|
||||
},
|
||||
{
|
||||
id: "msg_restart",
|
||||
type: "synthetic",
|
||||
text: "continue",
|
||||
description: "Continuing after restart",
|
||||
time: { created: 5 },
|
||||
},
|
||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
|
||||
],
|
||||
})
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
await expect(notices).toHaveCount(4)
|
||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible()
|
||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText(
|
||||
"Move 1 subagent to background",
|
||||
)
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
||||
)
|
||||
await page.keyboard.press("Control+b")
|
||||
await request
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText(
|
||||
"Move 1 subagent to background",
|
||||
)
|
||||
await page.locator('[data-component="task-tool-card"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
const childID = "ses_background_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(true)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
|
||||
})
|
||||
|
||||
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_backgrounded",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_backgrounded",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: "Background task" },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { sessionID: backgroundID, status: "running" },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_shell_backgrounded",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "sleep 120" },
|
||||
content: [{ type: "text", text: "working" }],
|
||||
metadata: { shellID: "shell_backgrounded", status: "running" },
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_blocking",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_blocking",
|
||||
name: "subagent",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { description: "Foreground task" },
|
||||
metadata: { sessionID: blockingID },
|
||||
},
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
time: { created: 4 },
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
|
||||
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
|
||||
],
|
||||
sessionStatus: {
|
||||
[sessionID]: { type: "busy" },
|
||||
[backgroundID]: { type: "busy" },
|
||||
[blockingID]: { type: "busy" },
|
||||
},
|
||||
})
|
||||
|
||||
const dock = page.locator('[data-component="session-background-dock"]')
|
||||
await expect(dock).toContainText("Move 1 subagent to background")
|
||||
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||
).toHaveAttribute("data-active", "true")
|
||||
})
|
||||
@@ -391,6 +391,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST")
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||
return json(route, { data: [] })
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
|
||||
@@ -33,6 +33,8 @@ type PluralKey =
|
||||
| "session.question.pending"
|
||||
| "session.followupDock.summary"
|
||||
| "session.revertDock.summary"
|
||||
| "session.background.shell"
|
||||
| "session.background.subagent"
|
||||
type Source = { dict: Record<string, string> }
|
||||
|
||||
function cookie(locale: Locale) {
|
||||
|
||||
@@ -673,6 +673,17 @@ export const dict = {
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.combine": "{{first}} and {{second}}",
|
||||
"session.background.shell.one": "{{count}} shell",
|
||||
"session.background.shell.other": "{{count}} shells",
|
||||
"session.background.subagent.one": "{{count}} subagent",
|
||||
"session.background.subagent.other": "{{count}} subagents",
|
||||
"command.session.background": "Move to background",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
"session.timeline.notice.cancelled": "{{actor}} cancelled",
|
||||
"session.error.serverConnection": "Can't connect to this server",
|
||||
"session.review.filesChanged": "Files Changed {{count}}",
|
||||
"session.review.change.one": "Change",
|
||||
|
||||
@@ -25,6 +25,7 @@ export function DirectoryDataProvider(
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const language = useLanguage()
|
||||
const directory = () => props.directory
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
const href = (sessionID: string) => {
|
||||
|
||||
@@ -1118,6 +1118,10 @@ export default function Page() {
|
||||
useComposerCommands()
|
||||
useSessionCommands({
|
||||
session: controller,
|
||||
background: {
|
||||
blocking: () => composer.background.blocking().length > 0,
|
||||
move: composer.background.move,
|
||||
},
|
||||
navigateMessageByOffset,
|
||||
setActiveMessage,
|
||||
focusInput,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { For, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { SessionComposerPullout } from "./session-composer-pullout"
|
||||
|
||||
export function SessionBackgroundDock(props: {
|
||||
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
|
||||
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
|
||||
onBackground: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const [store, setStore] = createStore({ collapsed: true })
|
||||
const describe = (shells: number, subagents: number) => {
|
||||
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
|
||||
const subagent = subagents
|
||||
? language.plural("session.background.subagent", subagents, { count: subagents })
|
||||
: undefined
|
||||
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
|
||||
return shell ?? subagent ?? ""
|
||||
}
|
||||
const summary = createMemo(() => {
|
||||
const shells = props.tasks.filter((task) => task.type === "shell").length
|
||||
return describe(shells, props.tasks.length - shells)
|
||||
})
|
||||
const moving = createMemo(() => {
|
||||
const shells = props.blocking.filter((task) => task.type === "shell").length
|
||||
const subagents = props.blocking.length - shells
|
||||
const tasks = describe(shells, subagents)
|
||||
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
|
||||
})
|
||||
const background = createMemo(() =>
|
||||
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
|
||||
)
|
||||
const blocking = () => props.blocking.length > 0
|
||||
const toggle = () => {
|
||||
if (blocking()) {
|
||||
props.onBackground()
|
||||
return
|
||||
}
|
||||
setStore("collapsed", (value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionComposerPullout
|
||||
name="background"
|
||||
label={
|
||||
<span class="flex flex-col items-start">
|
||||
{blocking() && (
|
||||
<span>
|
||||
<span class="text-v2-text-text-muted">{moving()}</span>
|
||||
<span class="pl-2">
|
||||
<KeybindV2 keys={command.keybindParts("session.background")} variant="neutral" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
|
||||
</span>
|
||||
}
|
||||
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
|
||||
multiline={blocking() && props.tasks.length > 0}
|
||||
collapsed={blocking() || store.collapsed}
|
||||
collapsible={!blocking()}
|
||||
onToggle={toggle}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
>
|
||||
<div class="px-4 pb-11 flex flex-col gap-1.5">
|
||||
<For each={props.tasks}>
|
||||
{(task) => (
|
||||
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
|
||||
<span class="shrink-0 text-13-medium text-text-strong">
|
||||
{language.t(
|
||||
task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default",
|
||||
)}
|
||||
</span>
|
||||
<span class="truncate text-text-weak">{task.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</SessionComposerPullout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, createMemo, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
export function SessionComposerPullout(props: {
|
||||
name: "todo" | "background"
|
||||
label: JSX.Element
|
||||
ariaLabel: string
|
||||
preview?: string
|
||||
multiline?: boolean
|
||||
collapsed: boolean
|
||||
collapsible?: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const [store, setStore] = createStore({ height: 78, header: 42 })
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const base = createMemo(() => Math.max(78, store.header + 36))
|
||||
const full = createMemo(() => Math.max(base(), store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const element = contentRef
|
||||
const header = headerRef
|
||||
if (!element || !header) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, element.scrollHeight))
|
||||
setStore("header", header.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
createResizeObserver([element, header], update)
|
||||
})
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={settings.general.newLayoutDesigns() ? "div" : DockTray}
|
||||
data-component={`session-${props.name}-dock`}
|
||||
classList={{
|
||||
"w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
ref={headerRef}
|
||||
data-action={`session-${props.name}-toggle`}
|
||||
classList={{
|
||||
"flex items-center gap-2 overflow-visible": true,
|
||||
"pl-4 pr-2": settings.general.newLayoutDesigns(),
|
||||
"h-[42px]": settings.general.newLayoutDesigns() && !props.multiline,
|
||||
"min-h-[42px] py-2": settings.general.newLayoutDesigns() && props.multiline,
|
||||
"pl-3 pr-2 py-2": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
classList={{
|
||||
"cursor-default inline-flex items-baseline shrink-0 overflow-visible": true,
|
||||
"font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"text-14-regular text-text-strong": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
aria-label={props.ariaLabel}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
"white-space": "pre",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<div
|
||||
data-slot={`session-${props.name}-preview`}
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{ flex: "1 1 auto", "max-width": "100%", transform: "translateY(1px)" }}
|
||||
>
|
||||
<TextReveal
|
||||
class={
|
||||
settings.general.newLayoutDesigns()
|
||||
? "cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"
|
||||
: "text-14-regular text-text-base cursor-default"
|
||||
}
|
||||
text={props.preview}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
{props.collapsible !== false && (
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action={`session-${props.name}-toggle-button`}
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${value() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
data-slot={`session-${props.name}-list`}
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{ "pointer-events-none": hide() > 0.1 }}
|
||||
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - hide())}` }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock"
|
||||
import type { SessionComposerRegionController } from "./session-composer-region-controller"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
@@ -15,6 +16,8 @@ export function SessionComposerRegion(props: {
|
||||
const language = useLanguage()
|
||||
const controller = props.controller
|
||||
const settings = useSettings()
|
||||
const background = () =>
|
||||
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
|
||||
const rolled = () => {
|
||||
const revert = controller.revert()
|
||||
return revert?.items.length ? revert : undefined
|
||||
@@ -123,12 +126,21 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={background()}>
|
||||
<div style={{ "margin-top": `${-controller.lift()}px` }}>
|
||||
<SessionBackgroundDock
|
||||
blocking={controller.state.background.blocking()}
|
||||
tasks={controller.state.background.tasks()}
|
||||
onBackground={() => void controller.state.background.move()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-controller.lift()}px`,
|
||||
"margin-top": `${background() ? -36 : -controller.lift()}px`,
|
||||
}}
|
||||
>
|
||||
<Show when={controller.followup()?.items.length}>
|
||||
|
||||
@@ -5,11 +5,13 @@ import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
@@ -31,8 +33,30 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const queryClient = useQueryClient()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const
|
||||
const shells = createQuery(() => ({
|
||||
queryKey: shellKey(),
|
||||
enabled: !!params.id && serverSDK().connection.status() === "connected",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.shell.list({ location: { directory: sdk().directory } })
|
||||
.then((result) => result.data),
|
||||
}))
|
||||
onCleanup(
|
||||
sdk().event.listen((event) => {
|
||||
if (
|
||||
event.details.type !== "shell.created" &&
|
||||
event.details.type !== "shell.exited" &&
|
||||
event.details.type !== "shell.deleted"
|
||||
)
|
||||
return
|
||||
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
||||
}),
|
||||
)
|
||||
|
||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id)
|
||||
@@ -61,6 +85,113 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
const primary = () => {
|
||||
const id = params.id
|
||||
return !!id && !serverSync().session.get(id)?.parentID
|
||||
}
|
||||
const backgroundBlocking = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = (serverSync().session.data.session_message[id] ?? []).findLast(
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
return assistant.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return []
|
||||
if (part.name !== "shell" && part.name !== "subagent") return []
|
||||
const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID
|
||||
const label = part.name === "shell" ? part.state.input.command : part.state.input.description
|
||||
return [
|
||||
{
|
||||
type: part.name as "shell" | "subagent",
|
||||
id: typeof value === "string" ? value : undefined,
|
||||
label: typeof label === "string" ? label : undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const backgroundTasks = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const blocking = backgroundBlocking()
|
||||
const messages = serverSync().session.data.session_message[id] ?? []
|
||||
const completed = new Set(
|
||||
messages.flatMap((message) => {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
|
||||
return [message.metadata.jobID]
|
||||
return []
|
||||
}),
|
||||
)
|
||||
const backgrounded = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "subagent") return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const sessionID = part.state.metadata.sessionID
|
||||
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
|
||||
const description = part.state.input.description
|
||||
return [{ id: sessionID, type: "subagent" as const, label: typeof description === "string" ? description : sessionID }]
|
||||
})
|
||||
})
|
||||
const active = Object.values(serverSync().session.data.info).flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }]
|
||||
})
|
||||
const backgroundShells = messages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
id: typeof shellID === "string" ? shellID : part.id,
|
||||
type: "shell" as const,
|
||||
label: typeof command === "string" ? command : part.id,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)),
|
||||
)
|
||||
)
|
||||
return []
|
||||
return [{ id: shell.id, type: "shell" as const, label: shell.command }]
|
||||
})
|
||||
return [
|
||||
...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(),
|
||||
]
|
||||
})
|
||||
const moveToBackground = async () => {
|
||||
if (!primary()) return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
await sdk()
|
||||
.api.session.background({ sessionID })
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
sessionID: params.id,
|
||||
@@ -191,6 +322,11 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
questionRequest,
|
||||
permissionRequest,
|
||||
permissionResponding,
|
||||
background: {
|
||||
blocking: backgroundBlocking,
|
||||
tasks: backgroundTasks,
|
||||
move: moveToBackground,
|
||||
},
|
||||
decide,
|
||||
todos,
|
||||
dock: () =>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import type { Todo } from "@/types"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Index, Match, Switch, createEffect, createMemo } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Index, Match, Switch, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SessionComposerPullout } from "./session-composer-pullout"
|
||||
|
||||
const doneToken = "\u0000done\u0000"
|
||||
const totalToken = "\u0000total\u0000"
|
||||
@@ -50,10 +44,6 @@ export function SessionTodoDock(props: {
|
||||
dockProgress: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const [store, setStore] = createStore({
|
||||
height: 78,
|
||||
})
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
@@ -73,147 +63,33 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const full = createMemo(() => Math.max(78, store.height))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setStore("height", (height) => Math.max(height, el.scrollHeight))
|
||||
}
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={settings.general.newLayoutDesigns() ? "div" : DockTray}
|
||||
data-component="session-todo-dock"
|
||||
classList={{
|
||||
"w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01":
|
||||
settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
<SessionComposerPullout
|
||||
name="todo"
|
||||
label={
|
||||
<Index each={progress()}>
|
||||
{(item) => (
|
||||
<Switch fallback={<span>{item()}</span>}>
|
||||
<Match when={item() === doneToken}>
|
||||
<AnimatedNumber value={done()} />
|
||||
</Match>
|
||||
<Match when={item() === totalToken}>
|
||||
<AnimatedNumber value={total()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Index>
|
||||
}
|
||||
ariaLabel={label()}
|
||||
preview={props.collapsed ? preview() : undefined}
|
||||
collapsed={props.collapsed}
|
||||
onToggle={props.onToggle}
|
||||
collapseLabel={props.collapseLabel}
|
||||
expandLabel={props.expandLabel}
|
||||
dockProgress={props.dockProgress}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
classList={{
|
||||
"flex items-center gap-2 overflow-visible": true,
|
||||
"h-[42px] pl-4 pr-2": settings.general.newLayoutDesigns(),
|
||||
"pl-3 pr-2 py-2": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
classList={{
|
||||
"cursor-default inline-flex items-baseline shrink-0 overflow-visible": true,
|
||||
"font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"text-14-regular text-text-strong": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": "600ms",
|
||||
"--tool-motion-mask": "18%",
|
||||
"--tool-motion-mask-height": "0px",
|
||||
"--tool-motion-spring-ms": "560ms",
|
||||
"white-space": "pre",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
}}
|
||||
>
|
||||
<Index each={progress()}>
|
||||
{(item) => (
|
||||
<Switch fallback={<span>{item()}</span>}>
|
||||
<Match when={item() === doneToken}>
|
||||
<AnimatedNumber value={done()} />
|
||||
</Match>
|
||||
<Match when={item() === totalToken}>
|
||||
<AnimatedNumber value={total()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Index>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class={
|
||||
settings.general.newLayoutDesigns()
|
||||
? "cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"
|
||||
: "text-14-regular text-text-base cursor-default"
|
||||
}
|
||||
text={props.collapsed ? preview() : undefined}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} />
|
||||
</div>
|
||||
</div>
|
||||
</Dynamic>
|
||||
<TodoList todos={props.todos} />
|
||||
</SessionComposerPullout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
@@ -262,10 +263,48 @@ function MessageTimelineView(
|
||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||
const messageByID = projection.messageByID
|
||||
const sessionMessageByID = projection.sessionMessageByID
|
||||
const messageLastRowIndex = projection.messageLastRowIndex
|
||||
const messageRowIndex = projection.messageRowIndex
|
||||
const timelineRowByKey = projection.rowByKey
|
||||
const timelineRows = projection.rows
|
||||
const noticeContent = (message: SessionMessageInfo) => {
|
||||
if (message.type === "agent-switched")
|
||||
return {
|
||||
label: language.t("ui.tool.agent.default"),
|
||||
data: message.previous ? `${message.previous} → ${message.agent}` : message.agent,
|
||||
}
|
||||
if (message.type === "model-switched")
|
||||
return {
|
||||
label: language.t("command.category.model"),
|
||||
data: `${message.model.providerID}/${message.model.id}`,
|
||||
}
|
||||
if (message.type === "location-switched")
|
||||
return { label: language.t("ui.patch.action.moved"), data: message.location.directory }
|
||||
if (message.type === "skill") return { label: language.t("ui.tool.skill"), data: message.name }
|
||||
if (message.type === "system") return { label: message.description ?? message.text }
|
||||
if (message.type === "compaction")
|
||||
return { label: language.t("ui.messagePart.compaction"), data: message.status }
|
||||
if (message.type !== "synthetic") return
|
||||
if (message.description === "Continuing after restart") return { label: message.description }
|
||||
const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined
|
||||
const state = typeof message.metadata?.state === "string" ? message.metadata.state : undefined
|
||||
if (source === "subagent" || source === "shell") {
|
||||
const agent = typeof message.metadata?.agent === "string" ? message.metadata.agent : undefined
|
||||
const actor =
|
||||
source === "shell" ? language.t("ui.tool.shell") : (agent ?? language.t("ui.tool.agent.default"))
|
||||
const label = language.t(
|
||||
state === "error"
|
||||
? "session.timeline.notice.failed"
|
||||
: state === "cancelled"
|
||||
? "session.timeline.notice.cancelled"
|
||||
: "session.timeline.notice.finished",
|
||||
{ actor },
|
||||
)
|
||||
return { label, data: message.description }
|
||||
}
|
||||
return { label: message.description ?? message.text }
|
||||
}
|
||||
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
@@ -816,6 +855,25 @@ function MessageTimelineView(
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Notice": {
|
||||
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
|
||||
const content = createMemo(() => {
|
||||
const message = sessionMessageByID().get(noticeRow().messageID)
|
||||
return message ? noticeContent(message) : undefined
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={noticeRow()}>
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<div data-slot="session-timeline-notice" class="w-full px-4 py-1 md:px-5 text-13-regular">
|
||||
<span class="text-13-medium text-text-strong">{content().label}</span>
|
||||
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,9 @@ export function createTimelineProjection(input: {
|
||||
inlineComments: Accessor<boolean>
|
||||
}) {
|
||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
)
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
const result = new Map<string, AssistantMessage[]>()
|
||||
input.messages().forEach((message) => {
|
||||
@@ -77,5 +80,6 @@ export function createTimelineProjection(input: {
|
||||
messageLastRowIndex,
|
||||
rowByKey,
|
||||
rows,
|
||||
sessionMessageByID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,84 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps CLI notice messages between the assistant steps they surround", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "run", time: { created: 1 } },
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
{
|
||||
id: "msg_assistant_1",
|
||||
type: "assistant",
|
||||
agent: "explore",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "started" }],
|
||||
time: { created: 3, completed: 4 },
|
||||
},
|
||||
{
|
||||
id: "msg_background",
|
||||
type: "synthetic",
|
||||
text: "result",
|
||||
description: "Search code",
|
||||
metadata: { source: "subagent", agent: "explore", state: "completed" },
|
||||
time: { created: 5 },
|
||||
},
|
||||
{
|
||||
id: "msg_model",
|
||||
type: "model-switched",
|
||||
model: { id: "next", providerID: "provider" },
|
||||
time: { created: 6 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant_2",
|
||||
type: "assistant",
|
||||
agent: "explore",
|
||||
model: { id: "next", providerID: "provider" },
|
||||
content: [{ type: "text", text: "finished" }],
|
||||
time: { created: 7, completed: 8 },
|
||||
},
|
||||
{
|
||||
id: "msg_restart",
|
||||
type: "synthetic",
|
||||
text: "continue",
|
||||
description: "Continuing after restart",
|
||||
time: { created: 9 },
|
||||
},
|
||||
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 10 } },
|
||||
{
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: 11 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"idle",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
|
||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||
"user-message:msg_user",
|
||||
"notice:msg_agent",
|
||||
"assistant-part:msg_user:msg_assistant_1:text:0",
|
||||
"notice:msg_background",
|
||||
"notice:msg_model",
|
||||
"assistant-part:msg_user:msg_assistant_2:text:0",
|
||||
"notice:msg_restart",
|
||||
"notice:msg_skill",
|
||||
"notice:msg_compaction",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a projected parent missing from the source page before newer turns", () => {
|
||||
const source = [
|
||||
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TimelineRowMap = {
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
}
|
||||
Notice: { userMessageID: string; messageID: string }
|
||||
TurnDivider: {
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
@@ -42,39 +43,57 @@ export namespace Timeline {
|
||||
inlineComments: boolean,
|
||||
projectedUserMessages: UserMessage[],
|
||||
) {
|
||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
|
||||
type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice }
|
||||
const turns: { user: UserMessage; entries: Entry[] }[] = []
|
||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||
const leading: Notice[] = []
|
||||
let current: (typeof turns)[number] | undefined
|
||||
messages.forEach((message) => {
|
||||
if (isNotice(message)) {
|
||||
if (current) current.entries.push({ type: "notice", message })
|
||||
if (!current) leading.push(message)
|
||||
return
|
||||
}
|
||||
const projected = getMessage(message.id)
|
||||
if (message.type === "shell" && projected?.role === "user") {
|
||||
const assistant = getMessage(`${message.id}:assistant`)
|
||||
const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }
|
||||
const turn = {
|
||||
user: projected,
|
||||
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
|
||||
}
|
||||
turns.push(turn)
|
||||
turnByUserID.set(projected.id, turn)
|
||||
current = turn
|
||||
return
|
||||
}
|
||||
if (projected?.role === "user") {
|
||||
if (turnByUserID.has(projected.id)) return
|
||||
const turn = { user: projected, assistants: [] }
|
||||
const turn = { user: projected, entries: [] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(projected.id, turn)
|
||||
current = turn
|
||||
return
|
||||
}
|
||||
if (projected?.role !== "assistant") return
|
||||
const existing = turnByUserID.get(projected.parentID)
|
||||
const existing = current ?? turnByUserID.get(projected.parentID)
|
||||
if (existing) {
|
||||
existing.assistants.push(projected)
|
||||
existing.entries.push({ type: "assistant", message: projected })
|
||||
current = existing
|
||||
return
|
||||
}
|
||||
const user = getMessage(projected.parentID)
|
||||
if (user?.role !== "user") return
|
||||
const turn = { user, assistants: [projected] }
|
||||
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
|
||||
turns.push(turn)
|
||||
turnByUserID.set(user.id, turn)
|
||||
current = turn
|
||||
})
|
||||
const notices = new Set(messages.filter(isNotice).map((message) => message.id))
|
||||
projectedUserMessages.forEach((user) => {
|
||||
if (notices.has(user.id)) return
|
||||
if (turnByUserID.has(user.id)) return
|
||||
const turn = { user, assistants: [] }
|
||||
const turn = { user, entries: [] }
|
||||
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
|
||||
if (index < 0) turns.push(turn)
|
||||
if (index >= 0) turns.splice(index, 0, turn)
|
||||
@@ -83,25 +102,34 @@ export namespace Timeline {
|
||||
const activeMessageID = turns.at(-1)?.user.id
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.assistants,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
rows: [
|
||||
...leading.map(
|
||||
(message) =>
|
||||
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
|
||||
),
|
||||
),
|
||||
...turns.flatMap((turn, index) =>
|
||||
constructMessageRows(
|
||||
turn.user,
|
||||
getMessageParts,
|
||||
turn.entries,
|
||||
index,
|
||||
showReasoning,
|
||||
status,
|
||||
turn.user.id === activeMessageID,
|
||||
inlineComments,
|
||||
),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function constructMessageRows(
|
||||
userMessage: UserMessage,
|
||||
getMessageParts: (messageID: string) => Part[],
|
||||
assistantMessages: AssistantMessage[],
|
||||
entries: Array<
|
||||
| { type: "assistant"; message: AssistantMessage }
|
||||
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
|
||||
>,
|
||||
index: number,
|
||||
showReasoning: boolean,
|
||||
status: SessionStatus["type"],
|
||||
@@ -110,13 +138,14 @@ export namespace Timeline {
|
||||
inlineComments: boolean,
|
||||
) {
|
||||
const rows: TimelineRow.TimelineRow[] = []
|
||||
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
|
||||
|
||||
const previousUserMessage = index > 0
|
||||
const userParts = getMessageParts(userMessage.id)
|
||||
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
|
||||
const compaction = userParts.some((p) => p.type === "compaction")
|
||||
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
|
||||
const interrupted = interruptedMessageIndex !== -1
|
||||
const compaction =
|
||||
userParts.some((p) => p.type === "compaction") &&
|
||||
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
|
||||
const latestError = assistantMessages.at(-1)?.error
|
||||
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
|
||||
|
||||
@@ -125,24 +154,6 @@ export namespace Timeline {
|
||||
.filter((part) => renderable(part, showReasoning))
|
||||
.map((part) => ({ messageID: message.id, messageIndex, part })),
|
||||
)
|
||||
const assistantItems =
|
||||
interrupted && !compaction
|
||||
? [
|
||||
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
|
||||
(group) => ({
|
||||
type: "part" as const,
|
||||
group,
|
||||
}),
|
||||
),
|
||||
{ type: "interrupted" as const },
|
||||
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
|
||||
(group) => ({
|
||||
type: "part" as const,
|
||||
group,
|
||||
}),
|
||||
),
|
||||
]
|
||||
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
||||
|
||||
if (comments.length > 0 && !inlineComments)
|
||||
@@ -169,26 +180,41 @@ export namespace Timeline {
|
||||
}
|
||||
|
||||
let assistantGroupIndex = 0
|
||||
assistantItems.forEach((item) => {
|
||||
if (item.type === "interrupted") {
|
||||
rows.push(
|
||||
new TimelineRow.TurnDivider({
|
||||
userMessageID: userMessage.id,
|
||||
label: "interrupted",
|
||||
}),
|
||||
)
|
||||
const appendAssistants = (messages: AssistantMessage[]) => {
|
||||
const ids = new Set(messages.map((message) => message.id))
|
||||
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
|
||||
const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError")
|
||||
const interruptedID = messages[interruptedAt]?.id
|
||||
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
|
||||
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
|
||||
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
|
||||
const appendGroups = (items: typeof refs) =>
|
||||
groupParts(items).forEach((group) => {
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: userMessage.id,
|
||||
group,
|
||||
previousAssistantPart: assistantGroupIndex > 0,
|
||||
}),
|
||||
)
|
||||
assistantGroupIndex += 1
|
||||
})
|
||||
appendGroups(before)
|
||||
if (interruptedAt >= 0 && !compaction)
|
||||
rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" }))
|
||||
appendGroups(after)
|
||||
}
|
||||
let assistantSegment: AssistantMessage[] = []
|
||||
entries.forEach((entry) => {
|
||||
if (entry.type === "assistant") {
|
||||
assistantSegment.push(entry.message)
|
||||
return
|
||||
}
|
||||
|
||||
rows.push(
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID: userMessage.id,
|
||||
group: item.group,
|
||||
previousAssistantPart: assistantGroupIndex > 0,
|
||||
}),
|
||||
)
|
||||
assistantGroupIndex += 1
|
||||
appendAssistants(assistantSegment)
|
||||
assistantSegment = []
|
||||
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
|
||||
})
|
||||
appendAssistants(assistantSegment)
|
||||
|
||||
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
|
||||
const heading = assistantMessages
|
||||
@@ -316,6 +342,14 @@ export namespace Timeline {
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNotice(
|
||||
message: SessionMessageInfo,
|
||||
): message is Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> {
|
||||
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
|
||||
if (message.type !== "synthetic") return true
|
||||
return !!message.description?.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MessageComment {
|
||||
|
||||
@@ -15,6 +15,10 @@ export namespace TimelineRow {
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
}> {}
|
||||
export class Notice extends Data.TaggedClass("Notice")<{
|
||||
userMessageID: string
|
||||
messageID: string
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
label: "compaction" | "interrupted"
|
||||
@@ -44,6 +48,7 @@ export namespace TimelineRow {
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| Notice
|
||||
| TurnDivider
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
@@ -59,6 +64,8 @@ export namespace TimelineRow {
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
return `user-message:${row.userMessageID}`
|
||||
case "Notice":
|
||||
return `notice:${row.messageID}`
|
||||
case "TurnDivider":
|
||||
return `turn-divider:${row.userMessageID}:${row.label}`
|
||||
case "AssistantPart":
|
||||
|
||||
@@ -30,6 +30,10 @@ type SessionCommandSource = {
|
||||
|
||||
export type SessionCommandContext = {
|
||||
session: SessionCommandSource
|
||||
background: {
|
||||
blocking: () => boolean
|
||||
move: () => Promise<void>
|
||||
}
|
||||
navigateMessageByOffset: (offset: number) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
focusInput: () => void
|
||||
@@ -434,6 +438,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
onSelect: compact,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.background",
|
||||
title: language.t("command.session.background"),
|
||||
keybind: "ctrl+b",
|
||||
disabled: !actions.background.blocking(),
|
||||
onSelect: actions.background.move,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.fork",
|
||||
title: language.t("command.session.fork"),
|
||||
|
||||
@@ -115,7 +115,7 @@ export const Plugin = {
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: { source: "shell", state },
|
||||
metadata: { source: "shell", jobID: id, state },
|
||||
})
|
||||
}),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -300,6 +300,7 @@ export const Plugin = {
|
||||
output,
|
||||
content,
|
||||
metadata: {
|
||||
status: output.status,
|
||||
truncated: output.truncated,
|
||||
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
|
||||
@@ -515,11 +515,10 @@ export function getToolInfo(
|
||||
title: webSearchProviderLabel(metadata?.provider, i18n),
|
||||
subtitle: input.query,
|
||||
}
|
||||
case "task": {
|
||||
const type =
|
||||
typeof input.subagent_type === "string" && input.subagent_type
|
||||
? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1)
|
||||
: undefined
|
||||
case "task":
|
||||
case "subagent": {
|
||||
const raw = input.agent ?? input.subagent_type
|
||||
const type = typeof raw === "string" && raw ? raw[0]!.toUpperCase() + raw.slice(1) : undefined
|
||||
return {
|
||||
icon: "task",
|
||||
title: agentTitle(i18n, type),
|
||||
@@ -594,19 +593,12 @@ function sessionLink(id: string | undefined, href?: (id: string) => string | und
|
||||
return href?.(id)
|
||||
}
|
||||
|
||||
function taskSession(
|
||||
input: Record<string, any>,
|
||||
parentID: string | undefined,
|
||||
sessions: SessionSummary[] | undefined,
|
||||
agents?: readonly { name: string; color?: string }[],
|
||||
) {
|
||||
function taskSession(input: Record<string, any>, parentID: string | undefined, sessions: SessionSummary[] | undefined) {
|
||||
if (!parentID) return
|
||||
const description = typeof input.description === "string" ? input.description : ""
|
||||
const agent = taskAgent(input.subagent_type, agents).name
|
||||
return (sessions ?? [])
|
||||
.filter((session) => session.parentID === parentID && !session.time?.archived)
|
||||
.filter((session) => (description ? session.title?.startsWith(description) : true))
|
||||
.filter((session) => (agent ? session.title?.includes(`@${agent}`) : true))
|
||||
.sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id
|
||||
}
|
||||
|
||||
@@ -1553,16 +1545,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
// @ts-expect-error
|
||||
const partMetadata = () => part().state?.metadata ?? emptyMetadata
|
||||
const taskId = createMemo(() => {
|
||||
if (part().tool !== "task") return
|
||||
const value = partMetadata().sessionId
|
||||
if (part().tool !== "task" && part().tool !== "subagent") return
|
||||
const value = partMetadata().sessionID ?? partMetadata().sessionId
|
||||
if (typeof value === "string" && value) return value
|
||||
})
|
||||
const taskHref = createMemo(() => {
|
||||
if (part().tool !== "task") return
|
||||
if (part().tool !== "task" && part().tool !== "subagent") return
|
||||
return sessionLink(taskId(), data.sessionHref)
|
||||
})
|
||||
const taskSubtitle = createMemo(() => {
|
||||
if (part().tool !== "task") return undefined
|
||||
if (part().tool !== "task" && part().tool !== "subagent") return undefined
|
||||
const value = input().description
|
||||
if (typeof value === "string" && value) return value
|
||||
return taskId()
|
||||
@@ -1979,18 +1971,17 @@ ToolRegistry.register({
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "task",
|
||||
render(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const childSessionId = createMemo(() => {
|
||||
const value = props.metadata.sessionId
|
||||
const value = props.metadata.sessionID ?? props.metadata.sessionId
|
||||
if (typeof value === "string" && value) return value
|
||||
return taskSession(props.input, data.sessionID, data.store.session, data.store.agent)
|
||||
return taskSession(props.input, data.sessionID, data.store.session)
|
||||
})
|
||||
const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent))
|
||||
const agent = createMemo(() => taskAgent(props.input.agent ?? props.input.subagent_type, data.store.agent))
|
||||
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
|
||||
const tone = createMemo(() => agent().color)
|
||||
const v2Tone = createMemo(() => agent().v2Color)
|
||||
@@ -2000,7 +1991,8 @@ ToolRegistry.register({
|
||||
? props.input.description
|
||||
: childSessionId()
|
||||
if (!value) return value
|
||||
if (props.metadata.background === true) return `${value} (background)`
|
||||
if (props.input.background === true || props.metadata.background === true || props.metadata.status === "running")
|
||||
return `${value} (background)`
|
||||
return value
|
||||
})
|
||||
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
@@ -2087,11 +2079,14 @@ ToolRegistry.register({
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "shell",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const pending = () =>
|
||||
props.status === "pending" || props.status === "running" || props.metadata.status === "running"
|
||||
const sawPending = pending()
|
||||
const text = createMemo(() => {
|
||||
const cmd = props.input.command ?? props.metadata.command ?? ""
|
||||
|
||||
Reference in New Issue
Block a user