Compare commits

..
Author SHA1 Message Date
opencode-agent[bot] aafc5eded4 fix(app): wait for session route id 2026-08-22 03:11:44 +00:00
64 changed files with 753 additions and 1813 deletions
+5 -4
View File
@@ -212,6 +212,7 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
@@ -579,6 +580,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
@@ -618,9 +620,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
const id = `tool_${nextToolCallId++}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
@@ -649,6 +649,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
@@ -672,7 +673,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
+26 -52
View File
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "provider_call",
id: "tool_0",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
@@ -862,14 +862,14 @@ describe("Gemini route", () => {
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({
id: "provider_call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "provider_call",
id: "tool_0",
name: "lookup",
result: "done",
resultType: "text",
@@ -1101,17 +1101,21 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "weather" },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: response.toolCalls[0].id,
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1154,8 +1158,7 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
}),
)
@@ -1195,7 +1198,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1209,20 +1212,16 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
id: "call_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "call_0" } },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1230,31 +1229,6 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe(
@@ -259,18 +259,6 @@ export function event(
return makeEvent(type, data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
export function toolInputEnded(data: Extract<OpenCodeEvent, { type: "session.tool.input.ended" }>["data"]) {
return makeEvent("session.tool.input.ended", data)
}
export function toolCalled(data: Extract<OpenCodeEvent, { type: "session.tool.called" }>["data"]) {
return makeEvent("session.tool.called", data)
}
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -14,63 +13,10 @@ import {
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
type TimelineMessage,
} from "./fixture"
test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => {
const toolID = "prt_bottom_follow_patch"
const input = { patchText: "Update src/edit.ts" }
const timeline = await setupTimeline(page, {
messages: [
...history(20),
userMessage(),
assistantMessage([textPart("prt_bottom_follow_text", "Working")], { completed: false }),
],
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 300)
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 300 }))
element.scrollTop = element.scrollHeight
})
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
await timeline.send(partUpdated(toolPart(toolID, "patch", "running", input)))
await timeline.send(
partUpdated(
toolPart(toolID, "patch", "completed", input, {
metadata: {
files: [
{
file: "src/edit.ts",
status: "modified",
patch: createTwoFilesPatch(
"a/src/edit.ts",
"b/src/edit.ts",
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index}\n`).join(""),
Array.from({ length: 40 }, (_, index) => `export const value${index} = ${index + 1}\n`).join(""),
),
additions: 40,
deletions: 40,
},
],
},
}),
),
)
await timeline.waitForPart(toolID)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
const shellID = "prt_wheel_01_shell"
const followingID = "prt_wheel_02_following"
@@ -47,10 +47,5 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const file = wrapper.locator('[data-scope="apply-patch"]')
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
})
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
@@ -21,17 +21,15 @@ test("keeps patch file disclosures independent", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "false")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "true")
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
messageUpdated,
@@ -9,13 +8,9 @@ import {
renderedPartID,
setupTimeline,
shell,
sessionID,
status,
stepStarted,
textPart,
toolCalled,
toolInputEnded,
toolInputStarted,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -39,55 +34,6 @@ for (const expanded of [false, true]) {
})
}
test("transitions a streaming shell from writing through command execution", async ({ page }) => {
const id = "prt_shell_streaming_input"
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const titleShimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(titleShimmer).toHaveAttribute("aria-label", "Shell")
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
const input = JSON.stringify({ command })
await timeline.send(toolInputEnded({ sessionID, assistantMessageID: assistantID, id, text: input }))
await expect(titleShimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText(command)
await expect(tool).not.toContainText("Writing command...")
await timeline.send(
toolCalled({
sessionID,
assistantMessageID: assistantID,
id,
input: { command },
executed: true,
}),
)
await expect(titleShimmer).toHaveAttribute("data-active", "false")
await expect(subtitle).toHaveText(command)
})
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
const id = "prt_shell_running_command"
const command = "sleep 10 && echo done"
@@ -97,11 +43,9 @@ test("shows and expands a running shell command without shimmering it", async ({
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await tool.locator('[data-slot="collapsible-trigger"]').click()
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,81 +73,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [toolPart("call_subagent", "subagent", "streaming", {})],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "13px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "13px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
@@ -156,24 +81,7 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
.toEqual({ aligned: true, ordered: true })
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const request = page.waitForRequest(
(request) =>
@@ -196,10 +104,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
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.getByText(/move running work to the background/i)).toHaveCount(0)
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
@@ -210,14 +118,7 @@ test("shows a badge for active background work", async ({ page }) => {
sessionStatus: { [childID]: { type: "busy" } },
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
).toBeVisible()
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 }) => {
@@ -292,15 +193,10 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const dock = page.locator('[data-component="session-background-dock"]')
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -69,43 +69,9 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls into one file group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(first, "patch", "completed", { patchText: "Update src/first.ts" }, {
metadata: { files: [patchFile("src/first.ts", "modified")] },
}),
toolPart(second, "patch", "completed", { patchText: "Update src/second.ts" }, {
metadata: { files: [patchFile("src/second.ts", "added")] },
}),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group).toBeVisible()
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByRole("button", { name: "Patch 2 files" })).toHaveCount(0)
await expect(group.getByRole("button")).toHaveCount(2)
await expect(group.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -230,7 +196,11 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
},
},
)
@@ -127,16 +127,19 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
for (const [id, name] of [
[pending, "sample-skill"],
[completed, "OpenCode"],
] as const) {
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
for (const id of [pending, completed]) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
}
})
+1 -3
View File
@@ -61,9 +61,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
})
}
if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
// Lazy chunks can import the entry chunk back under a distinct URL, so claim the root before async startup.
root.dataset.opencodeMounted = ""
if (root instanceof HTMLElement) {
void loadInitialLocale().then((locale) => {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
+3 -1
View File
@@ -1,5 +1,6 @@
import { createPromptProjectController } from "@/new-session/project/selector"
import { useSettingsDialog } from "@/settings/command"
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { useSettings } from "@/settings/model"
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { useSearchParams } from "@solidjs/router"
@@ -14,6 +15,7 @@ import { useNewSessionCommands } from "./commands"
/** The draft-only Session page. Submitting promotes the draft into a real Session. */
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
@@ -67,7 +69,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus visible={settings.visibility.status()} />
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>
+14 -9
View File
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Wordmark } from "@opencode-ai/ui/wordmark"
import { Show, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import createPresence from "solid-presence"
import { Composer } from "@/composer/composer"
import type { ComposerModel } from "@/composer/model"
@@ -14,7 +15,6 @@ import {
type PromptProjectController,
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -87,16 +87,21 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { visible: boolean }) {
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
const language = useLanguage()
return (
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
<Show when={props.mount} keyed>
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</Portal>
)}
</Show>
)
}
+1 -10
View File
@@ -30,7 +30,6 @@ export const dict = {
"command.project.previous": "Previous project",
"command.project.next": "Next project",
"command.project.index": "Switch to project {{index}}",
"command.project.copyID": "Copy Project ID",
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
@@ -94,7 +93,6 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
@@ -546,6 +544,7 @@ export const dict = {
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
"toast.session.unshare.success.title": "Session unshared",
"toast.session.unshare.success.description": "Session unshared successfully!",
"toast.session.unshare.failed.title": "Failed to unshare session",
@@ -555,10 +554,6 @@ export const dict = {
"toast.session.export.success.description": "Saved session to {{filename}}",
"toast.session.export.failed.title": "Failed to export session",
"toast.session.export.failed.description": "An error occurred while exporting the session",
"toast.session.copyID.failed.title": "Failed to copy session ID",
"toast.session.copyID.failed.description": "An error occurred while copying the session ID",
"toast.project.copyID.failed.title": "Failed to copy project ID",
"toast.project.copyID.failed.description": "An error occurred while copying the project ID",
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
@@ -662,10 +657,6 @@ export const dict = {
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -89,7 +89,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
const projectCommand = withCategory(language.t("command.category.project"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
@@ -127,46 +126,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
}
const copySessionID = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
try {
await navigator.clipboard.writeText(sessionID)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: sessionID,
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.copyID.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.copyID.failed.description"),
})
}
}
const copyProjectID = async () => {
const projectID = actions.session.data.info()?.projectID
if (!projectID) return
try {
await navigator.clipboard.writeText(projectID)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: projectID,
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.project.copyID.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.project.copyID.failed.description"),
})
}
}
const openFile = () => {
void openDialog(
() => import("@/shell/commands/dialog"),
@@ -312,12 +271,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !actions.session.identity.params.id,
onSelect: exportSession,
}),
sessionCommand({
id: "session.copyID",
title: language.t("command.session.copyID"),
disabled: !actions.session.identity.params.id,
onSelect: copySessionID,
}),
]
const fileCmds = () => {
@@ -341,15 +294,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
].filter((v) => !!v)
}
const projectCmds = () => [
projectCommand({
id: "project.copyID",
title: language.t("command.project.copyID"),
disabled: !actions.session.data.info()?.projectID,
onSelect: copyProjectID,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
@@ -463,7 +407,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
command.register("session", () => [
...sessionCmds(),
...projectCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
@@ -2,12 +2,15 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionBackgroundDock } from "@/session/requests/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
>
> & {
background: Pick<SessionComposerRegionController["state"]["background"], "blocking" | "tasks" | "move">
}
export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
@@ -29,6 +32,9 @@ export function SessionComposerRegion(props: {
}) {
const language = useLanguage()
const controller = props.controller
const background = () =>
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
return (
<div
ref={controller.setDockRef}
@@ -75,10 +81,22 @@ export function SessionComposerRegion(props: {
</>
}
>
<Show when={background()}>
<div>
<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": `${background() ? -36 : 0}px`,
}}
>
<Show
when={controller.child()}
@@ -1,12 +1,13 @@
import { createMemo } from "solid-js"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useSessionLayout } from "@/session/session-layout"
import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
@@ -27,9 +28,15 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const rightMount = useTitlebarRightMount()
return (
<TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderActions state={actions()} />
</Portal>
)}
</Show>
)
}
@@ -63,7 +63,6 @@ export function createSessionRequestModel() {
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
@@ -94,13 +93,11 @@ export function createSessionRequestModel() {
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
const agent = part.state.input.agent
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
},
]
})
@@ -0,0 +1,83 @@
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { Keybind } from "@opencode-ai/ui/keybind"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionBackgroundPullout } from "./session-background-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 (
<SessionBackgroundPullout
label={
<span class="flex flex-col items-start">
{blocking() && (
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<Keybind 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>
</SessionBackgroundPullout>
)
}
@@ -0,0 +1,114 @@
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
export function SessionBackgroundPullout(props: {
label: JSX.Element
ariaLabel: string
multiline?: boolean
collapsed: boolean
collapsible?: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
children: JSX.Element
}) {
const [store, setStore] = createStore({ height: 78, header: 42 })
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const off = createMemo(() => value() > 0.98)
const base = createMemo(() => Math.max(78, store.header + 36))
const full = createMemo(() => Math.max(base(), store.height))
let contentRef: HTMLDivElement | undefined
let headerRef: HTMLDivElement | undefined
createEffect(() => {
const element = contentRef
const header = headerRef
if (!element || !header) return
const update = () => {
setStore("height", (height) => Math.max(height, element.scrollHeight))
setStore("header", header.getBoundingClientRect().height)
}
update()
createResizeObserver([element, header], update)
})
return (
<div
data-component="session-background-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
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-background-toggle"
class="flex items-center gap-2 overflow-visible pl-4 pr-2"
classList={{
"h-[42px]": !props.multiline,
"min-h-[42px] py-2": props.multiline,
}}
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
class="cursor-default inline-flex items-baseline shrink-0 overflow-visible font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
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",
}}
>
{props.label}
</span>
{props.collapsible !== false && (
<div class="ml-auto">
<IconButton
data-action="session-background-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon={<Icon name="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-background-list"
aria-hidden={props.collapsed || off()}
classList={{ "pointer-events-none": value() > 0.1 }}
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - value())}` }}
>
{props.children}
</div>
</div>
</div>
)
}
-1
View File
@@ -72,7 +72,6 @@ export function SessionScreen(props: { session: SessionModel }) {
{(_id) => (
<MessageTimeline
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
+8
View File
@@ -84,6 +84,7 @@ export type SessionPreviewProps = {
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
}
@@ -195,6 +196,13 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
setState("request", undefined)
setState("activity", `Permission response: ${response}`)
},
background: {
blocking: () => [],
tasks: () => props.backgroundTasks ?? [],
move: async () => {
setState("activity", "Requested background execution")
},
},
blocked: () => state.request !== undefined,
},
centered: () => true,
@@ -1,36 +0,0 @@
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
{ id: "task_status", type: "shell" as const, label: "opencode2 service status" },
{ id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" },
{ id: "task_tests", type: "shell" as const, label: "bun test packages/app" },
]
export default {
title: "OpenCode/Session/Background work",
id: "session-background-work",
parameters: {
docs: {
description: {
component: "Production controls for moving blocking work and inspecting active background tasks.",
},
},
},
}
export const InlineMoveHint = {
render: () => (
<div class="flex w-[696px] max-w-full flex-col items-start gap-4">
<BackgroundMoveHint keybind={["Ctrl", "B"]} />
</div>
),
}
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
</div>
),
}
@@ -1,15 +1,11 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js"
import createPresence from "solid-presence"
import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
@@ -19,7 +15,7 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
@@ -28,129 +24,6 @@ import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
tasks: Accessor<BackgroundTask[]>
move: () => Promise<void>
}
export function BackgroundMoveHint(props: { keybind?: string[] }) {
const language = useLanguage()
const command = useCommand()
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
const keys = () => props.keybind ?? command.keybindParts("session.background")
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
return (
<div
data-component="session-background-hint"
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
>
<span data-slot="session-background-hint-prefix" class="shrink-0">
{parts()[0].trim()}
</span>
<Keybind keys={keys()} variant="neutral" />
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
</div>
)
}
function BackgroundMoveHintRow(props: { show: boolean; centered: boolean; padding: string }) {
const [ref, setRef] = createSignal<HTMLDivElement>()
const visibility = createMemo<{ show: boolean; animate: boolean }>(
(previous) => ({ show: props.show, animate: previous.animate || previous.show !== props.show }),
{ show: props.show, animate: false },
)
const presence = createPresence({ show: () => visibility().show, element: () => ref() ?? null })
return (
<Show when={presence.present()}>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-10 items-start pt-4 ${props.padding}`]: true,
"animate-in fade-in": visibility().animate && visibility().show,
"animate-out fade-out fill-mode-forwards": visibility().animate && !visibility().show,
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={setOpen}
>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
>
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
{props.tasks.length}
</Badge>
<TextShimmer
as="span"
text={language.t("session.background.running")}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<div
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
@@ -221,7 +94,6 @@ function SessionSummaryPanel(props: {
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
@@ -296,9 +168,6 @@ function SessionSummaryPanel(props: {
)}
</Show>
</button>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
@@ -317,7 +186,6 @@ function SessionSummaryPanel(props: {
type MessageTimelineProps = {
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
scroll: { overflow: boolean; jump: boolean }
onResumeScroll: () => void
@@ -482,18 +350,6 @@ function MessageTimelineView(
padding: turnPadding,
anchor: props.anchor,
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
})
const backgroundHint = (row: TimelineRow.TimelineRow) =>
row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === backgroundHintPartID()
return (
<VirtualizedTimeline
@@ -501,14 +357,9 @@ function MessageTimelineView(
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
return content?.type === "tool" && ["edit", "write"].includes(content.name)
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
}}
renderRow={(row, onSizeChange) => (
<>
<rowRenderer.Row row={row} onSizeChange={onSizeChange} />
<BackgroundMoveHintRow show={backgroundHint(row())} centered={props.centered} padding={turnPadding()} />
</>
)}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
<div
data-session-title
@@ -634,7 +485,6 @@ function MessageTimelineView(
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
@@ -74,9 +74,7 @@ export function createTimelineVirtualizer(input: Input) {
let prependLoading = false
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let gestureAnchorFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
let scrollTop = 0
const clearPrependAnchor = () => {
prependLoading = false
@@ -180,28 +178,12 @@ export function createTimelineVirtualizer(input: Input) {
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
const anchorAfterGesture = () => {
if (gestureAnchorFrame !== undefined) return
const apply = () => {
gestureAnchorFrame = undefined
if (input.hasScrollGesture()) {
gestureAnchorFrame = requestAnimationFrame(apply)
return
}
if (input.shouldAnchorBottom()) virtualizer.scrollToEnd()
}
gestureAnchorFrame = requestAnimationFrame(apply)
}
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
if (resizeAnchorScheduled || input.hasScrollGesture()) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
virtualizer.scrollToEnd()
})
}
@@ -262,11 +244,7 @@ export function createTimelineVirtualizer(input: Input) {
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
@@ -287,7 +265,6 @@ export function createTimelineVirtualizer(input: Input) {
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
scrollTop = root.scrollTop
input.setScrollRef(root)
}
@@ -347,17 +324,13 @@ export function createTimelineVirtualizer(input: Input) {
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
const root = event.currentTarget
const movedUp = root.scrollTop < scrollTop - 0.5
scrollTop = root.scrollTop
if (prependLoading) updatePrependAnchor()
input.onScheduleScrollState(root)
input.onScheduleScrollState(event.currentTarget)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(root)
input.onMarkScrollGesture(event.currentTarget)
}
function View(props: ViewProps) {
@@ -490,7 +463,6 @@ export function createTimelineVirtualizer(input: Input) {
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (gestureAnchorFrame !== undefined) cancelAnimationFrame(gestureAnchorFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
@@ -93,24 +93,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
activeDirectory: props.activeDirectory,
}
}
// Fetch sessions per workspace directory instead of paging through every session on the server.
const loadSessions = async (directories: readonly string[], context = captureDeleteContext()) => {
const fetched = await Promise.all(
directories.map((directory) => listAllSessions(context.sdk.api.session, { order: "desc", directory })),
)
const sessions = fetched.flat()
return mergeWorkspaceSessionInventory(sessions, context.data.session.list())
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
fetched.forEach(context.data.session.remember)
return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
}
const workspaceDirectories = createMemo(() => workspaces().map((workspace) => workspace.directory))
const sessionQuery = useQuery(() => ({
queryKey: [
serverSDK.scope,
null,
"settings-workspace-sessions",
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: workspaceDirectories().length > 0,
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions().then(() => Date.now()),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
@@ -118,7 +108,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
]),
),
)
@@ -146,7 +136,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions([workspace.directory], context),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
+25 -28
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -24,32 +23,30 @@ export default function Layout(props: ParentProps) {
}
return (
<TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
</TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
)
}
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createTitlebarRightSlot } from "./right-slot"
describe("titlebar right slot", () => {
test("selects the latest owner and restores the previous owner after overlap", () => {
createRoot((dispose) => {
const slot = createTitlebarRightSlot()
const committed = slot.createRegistration()
committed.register()
expect(committed.active()).toBe(true)
const shadow = slot.createRegistration()
shadow.register()
expect(committed.active()).toBe(false)
expect(shadow.active()).toBe(true)
shadow.unregister()
expect(committed.active()).toBe(true)
expect(shadow.active()).toBe(false)
committed.unregister()
expect(committed.active()).toBe(false)
dispose()
})
})
})
@@ -1,65 +0,0 @@
import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
type Registration = {
active: () => boolean
register: () => void
unregister: () => void
}
type TitlebarRightSlot = {
createRegistration: () => Registration
mount: () => HTMLElement | undefined
setMount: (mount: HTMLElement) => void
}
const TitlebarRightContext = createContext<TitlebarRightSlot>()
export function TitlebarRightProvider(props: ParentProps) {
return (
<TitlebarRightContext.Provider value={createTitlebarRightSlot()}>{props.children}</TitlebarRightContext.Provider>
)
}
export function createTitlebarRightSlot(): TitlebarRightSlot {
const [store, setStore] = createStore<{ mount?: HTMLElement; registrations: symbol[] }>({ registrations: [] })
return {
mount: () => store.mount,
setMount: (mount) => setStore("mount", mount),
createRegistration() {
const id = Symbol()
return {
active: () => store.registrations.at(-1) === id,
register: () => setStore("registrations", (items) => [...items, id]),
unregister: () => setStore("registrations", (items) => items.filter((item) => item !== id)),
}
},
}
}
export function TitlebarRightMount() {
const slot = useTitlebarRightSlot()
return <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
}
export function TitlebarRight(props: ParentProps) {
const slot = useTitlebarRightSlot()
const registration = slot.createRegistration()
onMount(() => {
registration.register()
onCleanup(registration.unregister)
})
return (
<Show when={registration.active() && slot.mount()} keyed>
{(mount) => <Portal mount={mount}>{props.children}</Portal>}
</Show>
)
}
function useTitlebarRightSlot() {
const slot = useContext(TitlebarRightContext)
if (!slot) throw new Error("TitlebarRight must be used within TitlebarRightProvider")
return slot
}
+22 -3
View File
@@ -1,4 +1,15 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -23,7 +34,6 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs"
import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
const titlebarHeight = 36
const minTitlebarZoom = 0.25
@@ -36,6 +46,15 @@ export type TitlebarUpdate = {
install: () => void
}
export function useTitlebarRightMount() {
const language = useLanguage()
const [mount, setMount] = createSignal<HTMLElement | null>(null)
const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
onMount(sync)
createEffect(on(language.direction, sync, { defer: true }))
return mount
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const command = useCommand()
@@ -402,7 +421,7 @@ function TitlebarRight(props: { state: TitlebarRightState }) {
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<TitlebarRightMount />
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
-1
View File
@@ -878,7 +878,6 @@ describe("ShellTool", () => {
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
if (!isWindows) {
+1 -2
View File
@@ -147,9 +147,8 @@ const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
yield* configureApplication()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+57 -113
View File
@@ -116,7 +116,7 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
&.clickable:not(.webfetch-link) {
&.clickable {
cursor: pointer;
text-decoration: underline;
transition: color 0.15s ease;
@@ -145,6 +145,62 @@
color: var(--text-interactive-base);
}
}
&.webfetch-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--v2-text-text-accent);
text-decoration: none;
&:visited,
&:active {
color: var(--v2-text-text-accent);
}
[data-slot="webfetch-link-text"] {
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webfetch-link-icon {
display: none;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
}
&:hover {
color: var(--v2-text-text-accent);
text-decoration: none;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
}
}
[data-slot="basic-tool-tool-arg"] {
@@ -170,31 +226,6 @@
}
}
[data-component="collapsible"].tool-collapsible[data-compact="true"] > [data-slot="collapsible-trigger"] {
height: 28px;
[data-component="tool-trigger"],
[data-slot="basic-tool-tool-info-main"] {
gap: 6px;
}
[data-slot="basic-tool-tool-title"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--v2-line-height-compact, 16px);
letter-spacing: -0.04px;
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
@@ -281,24 +312,6 @@
}
}
[data-component="collapsible"].tool-collapsible:not([data-rail="false"]) {
> [data-slot="collapsible-content"] {
position: relative;
margin-inline-start: 12px;
padding-inline-start: 16px;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 12px;
width: 0.5px;
background-color: var(--v2-border-border-muted, rgba(0, 0, 0, 0.08));
}
}
}
:root body {
[data-component="task-tool-card"] {
gap: 8px;
@@ -368,72 +381,3 @@
}
}
}
.webfetch-link,
[data-slot="basic-tool-tool-subtitle"].webfetch-link,
[data-slot="exa-tool-link"].webfetch-link,
[data-component="tool-trigger"] [data-slot="basic-tool-tool-subtitle"].webfetch-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--v2-text-text-accent);
text-decoration: none;
overflow: visible;
max-width: 100%;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: inherit;
font-style: normal;
font-weight: var(--font-weight-regular, 440);
line-height: inherit;
letter-spacing: var(--letter-spacing-normal);
&:visited,
&:active {
color: var(--v2-text-text-accent);
}
[data-slot="webfetch-link-text"] {
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: inherit;
}
.webfetch-link-icon {
display: none;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
}
&:hover {
color: var(--v2-text-text-accent);
text-decoration: none;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
}
@@ -0,0 +1,75 @@
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "./basic-tool"
export default {
title: "OpenCode/Tools/Disclosure",
id: "components-basic-tool",
component: BasicTool,
parameters: {
docs: {
description: {
component:
"The disclosure frame shared by production tool messages. Use these stories to inspect common resting, running, expanded, and summary-only states.",
},
},
},
}
export const Completed = {
render: () => (
<BasicTool
icon="glasses"
defaultOpen
trigger={{ title: "Read", subtitle: "src/session.ts", args: ["offset=1", "limit=80"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Loaded the requested file.</div>
</BasicTool>
),
}
export const Running = {
render: () => (
<BasicTool icon="console" status="running" trigger={{ title: "Running tests", subtitle: "bun test src/timeline" }}>
<div class="px-3 py-2 font-mono text-12-regular text-text-base">Running timeline tests...</div>
</BasicTool>
),
}
export const Collapsed = {
render: () => (
<BasicTool
icon="magnifying-glass-menu"
trigger={{ title: "Searched", subtitle: "packages/session-ui", args: ["pattern=TimelineRow.key"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">2 matching files</div>
</BasicTool>
),
}
export const SummaryOnly = {
render: () => (
<BasicTool icon="post-skill" hideDetails trigger={{ title: "Skill", subtitle: "rtl-aware-development" }} />
),
}
export const Controlled = {
render: () => {
const [state, setState] = createStore({ open: false })
return (
<div class="flex max-w-[620px] flex-col gap-3">
<Button class="w-fit" size="small" variant="neutral" onClick={() => setState("open", (value) => !value)}>
{state.open ? "Close tool details" : "Open tool details"}
</Button>
<BasicTool
icon="code-lines"
open={state.open}
onOpenChange={(open) => setState("open", open)}
trigger={{ title: "Edited", subtitle: "src/session.ts", args: ["+3", "-1"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Changed the active Session label.</div>
</BasicTool>
</div>
)
},
}
@@ -36,14 +36,12 @@ export interface BasicToolProps {
defer?: boolean
locked?: boolean
animated?: boolean
rail?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
triggerHref?: string
triggerAsLink?: boolean
clickable?: boolean
compact?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
@@ -257,35 +255,16 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible
open={open()}
onOpenChange={props.locked ? undefined : handleOpenChange}
class="tool-collapsible"
data-compact={props.compact ? "true" : undefined}
data-rail={props.rail === false ? "false" : undefined}
>
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
when={props.triggerAsLink || props.triggerHref}
fallback={
<Show
when={!props.locked}
fallback={
<div
data-slot="collapsible-trigger"
data-locked
data-hide-details={props.hideDetails ? "true" : undefined}
>
{trigger()}
</div>
}
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
</Show>
{trigger()}
</Collapsible.Trigger>
}
>
<Collapsible.Trigger
+1 -1
View File
@@ -702,7 +702,7 @@ function ViewerShell(props: {
data-mode={props.mode}
dir="ltr"
style={styleVariables}
class="relative select-text outline-none"
class="relative outline-none"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
@@ -326,7 +326,7 @@
[data-component="tool-output"] {
white-space: pre;
padding: 0;
margin-bottom: 0px;
margin-bottom: 24px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -569,13 +569,12 @@
}
[data-component="exa-tool-output"] {
width: 100%;
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
font-family: var(--font-family-sans);
font-size: 13px;
line-height: 16px;
font-size: var(--font-size-base);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
}
@@ -590,39 +589,27 @@
[data-slot="exa-tool-links"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
flex: 1 0 0;
gap: 4px;
}
[data-slot="exa-tool-link"] {
width: fit-content;
display: block;
max-width: 100%;
align-self: flex-start;
}
[data-slot="exa-tool-more"] {
all: unset;
cursor: pointer;
width: fit-content;
font-family: var(--font-family-sans);
font-size: 13px;
font-weight: var(--font-weight-regular, 440);
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-faint, #808080);
user-select: none;
font: inherit;
line-height: inherit;
color: var(--v2-text-text-accent);
text-decoration: underline;
text-underline-offset: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: var(--v2-text-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
color: var(--v2-text-text-accent);
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
&:visited {
color: var(--v2-text-text-accent);
}
}
@@ -668,7 +655,10 @@
}
[data-component="context-tool-group-list"] {
padding: 0;
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 12px;
display: flex;
flex-direction: column;
gap: 4px;
@@ -1226,19 +1216,10 @@
> [data-component="collapsible"] > [data-slot="collapsible-content"] {
border: none;
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
background: transparent;
&::before {
display: none;
}
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"],
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] {
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
@@ -1339,36 +1320,20 @@
}
}
[data-component="tool-loaded-item"] {
[data-component="tool-loaded-file"] {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
gap: 8px;
padding: 4px 0 4px 28px;
font-family: var(--font-family-sans);
font-size: 13px;
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
[data-slot="tool-loaded-label"] {
[data-slot="icon-svg"] {
flex-shrink: 0;
font-weight: 530;
}
[data-slot="tool-loaded-value"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 440;
}
[data-slot="tool-loaded-kind"] {
flex-shrink: 0;
margin-inline-start: -2px;
font-weight: 440;
color: var(--v2-text-text-muted);
color: var(--icon-weak);
}
}
@@ -17,17 +17,6 @@
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
> [data-slot="collapsible-content"] {
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
&::before {
display: none;
}
}
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
@@ -26,26 +26,10 @@ describe("current content default open", () => {
test("uses the file-change disclosure preference", () => {
expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true)
expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
})
test("collapses failed patches", () => {
const patch: SessionMessageAssistantTool = {
type: "tool",
id: "tool_patch",
name: "patch",
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "Verification failed" },
metadata: {},
},
time: { created: 1, completed: 2 },
}
expect(currentContentDefaultOpen(patch, false, false)).toBe(false)
})
test("opens deletion-only patches", () => {
test("keeps deletion-only changes collapsed", () => {
expect(
currentContentDefaultOpen(
tool("patch", [
@@ -55,6 +39,6 @@ describe("current content default open", () => {
false,
true,
),
).toBe(true)
).toBe(false)
})
})
@@ -6,7 +6,7 @@ import type {
import { Match, Switch } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { CurrentContextToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -109,10 +109,3 @@ export function SessionContextToolGroup(props: {
/>
)
}
export function SessionPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
return <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
}
@@ -36,8 +36,7 @@ export function currentContentDefaultOpen(
) {
if (content.type !== "tool") return undefined
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name === "patch") return content.state.status !== "error"
if (content.name !== "edit" && content.name !== "write") return undefined
if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
if (!Array.isArray(files) || files.length === 0) return true
@@ -702,32 +702,15 @@ export const webResearchDocument = document([
id: "tool_web_search",
name: "websearch",
offset: 73_100,
args: { query: "figma mcp setup" },
output: [
"https://www.figma.com/community/file/1606560040358762787/figma-mcp-console-setup-guide",
"https://designagentlab.com",
"https://www.figma.com/community/whiteboarding?resource_type=widgets",
"https://figma-console-mcp.southleft.com/mcp",
"https://designagentlab.com/figma-console-mcp",
"https://designagentlab.com/figma-tutorials",
"https://github.com/southleft/figma-console-mcp/issues",
"https://designagentlab.com/ui-kits",
"https://designagentlab.com/prototyping-tools",
"https://www.inthepocket.design/guidelines/figma-mcp/setup-figma-mcp",
"https://www.figma.com/community/plugins",
"https://figma-console-mcp.southleft.com/docs",
"https://designagentlab.com/resources",
"https://github.com/southleft/figma-console-mcp/releases",
"https://www.inthepocket.design/blog/figma-mcp",
"https://designagentlab.com/community",
].join("\n"),
metadata: { provider: "firecrawl" },
args: { query: "WAI ARIA live region status message guidance" },
output: "WAI-ARIA Authoring Practices and MDN live region guidance",
metadata: { provider: "exa" },
}),
completedTool({
id: "tool_web_fetch",
name: "webfetch",
offset: 74_000,
args: { url: "https://www.figma.com" },
args: { url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" },
output: "Status messages should be programmatically determinable without receiving focus.",
}),
],
@@ -745,25 +728,33 @@ export const webResearchDocument = document([
}),
] satisfies SessionMessageInfo[])
export const loadedResourcesDocument = document([
user("msg_user_skill", "Read the project instructions, load the RTL-aware skill, and review the file row.", 79_000),
export const skillWorkflowDocument = document([
{
id: "msg_agent_switched_review",
type: "agent-switched",
agent: "review",
previous: "build",
time: { created: STORY_TIME + 78_000 },
},
{
id: "msg_skill_loaded_rtl",
type: "skill",
skill: "rtl-aware-development",
name: "RTL-aware development",
text: "Verify direction independently from language.",
time: { created: STORY_TIME + 78_500 },
},
user("msg_user_skill", "Review the mixed-direction file row before I merge it.", 79_000),
assistant({
id: "msg_assistant_skill",
offset: 80_000,
completed: 82_000,
agent: "review",
content: [
completedTool({
id: "tool_loaded_file",
name: "read",
offset: 80_100,
args: { path: "C:/workspaces/opencode/packages/cli/AGENTS.md" },
output: "Project instructions loaded.",
metadata: { loaded: ["C:/workspaces/opencode/packages/cli/AGENTS.md"] },
}),
completedTool({
id: "tool_skill_rtl",
name: "skill",
offset: 80_200,
offset: 80_100,
args: { name: "rtl-aware-development" },
output: "Loaded RTL-aware development guidance",
metadata: { name: "rtl-aware-development" },
@@ -776,50 +767,6 @@ export const loadedResourcesDocument = document([
}),
] satisfies SessionMessageInfo[])
export const instructionsUpdatedSingleDocument = document([
user("msg_user_instructions_single", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_single",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_single",
type: "system",
text: "Updated instructions for api/v2-demo",
description: "Instructions updated: api/v2-demo",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const instructionsUpdatedMultipleDocument = document([
user("msg_user_instructions_multi", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_multi",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_multi",
type: "system",
text: "Updated instructions for api/v2-demo and api/session",
description: "Instructions updated: api/v2-demo, api/session",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const permissionPendingDocument = document(
[
user("msg_user_permission_pending", "Publish the verified preview build to the canary channel.", 83_000),
+1 -1
View File
@@ -23,7 +23,7 @@ export {
retryDocument,
revertDocument,
reviewDiffs,
loadedResourcesDocument,
skillWorkflowDocument,
standaloneShellCompletedDocument,
standaloneShellRunningDocument,
streamingDocument,
@@ -2,27 +2,12 @@ import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
const context = (
key: string,
partIDs: string[],
identity: { userMessageID?: string; messageID?: string } = {},
) =>
new TimelineRow.AssistantPart({
userMessageID: identity.userMessageID ?? "user-1",
group: {
key,
type: "context",
refs: partIDs.map((partID) => ({ messageID: identity.messageID ?? "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
})
const patch = (key: string, partIDs: string[], userMessageID = "user-1") =>
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
userMessageID,
group: {
key,
type: "patch",
type: "context",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
@@ -47,13 +32,6 @@ describe("reuseTimelineRows", () => {
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "preserves a patch group key when a member is appended",
previous: [patch("patch:a", ["a"])],
rows: [patch("patch:a", ["a", "b"])],
expected: ["assistant-part:user-1:patch:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
@@ -84,18 +62,11 @@ describe("reuseTimelineRows", () => {
},
{
name: "does not reuse context identity across user messages",
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
previous: [context("context:a", ["a", "b"], "user-1")],
rows: [context("context:b", ["b"], "user-2")],
expected: ["assistant-part:user-2:context:b"],
reused: [],
},
{
name: "does not reuse context identity across assistant messages",
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
expected: ["assistant-part:user-1:context:assistant-2:a"],
reused: [],
},
{
name: "reuses an unaffected ordinary row",
previous: [user()],
+20 -57
View File
@@ -15,8 +15,8 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorGroup = { index: number; row: GroupRow }
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
const contextTools = new Set(["read", "glob", "grep", "list"])
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
@@ -222,12 +222,6 @@ export namespace Timeline {
.filter((entry) => renderable(entry.content, showReasoning))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
const delegating = assistantPartRefs.some(
(entry) =>
entry.content.type === "tool" &&
entry.content.name === "subagent" &&
(entry.content.state.status === "streaming" || entry.content.state.status === "running"),
)
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
@@ -279,7 +273,6 @@ export namespace Timeline {
status.type === "busy" &&
!error &&
!retry &&
!delegating &&
(showReasoning ? assistantPartRefs.length === 0 : true)
) {
const heading = assistantMessages
@@ -291,7 +284,8 @@ export namespace Timeline {
}
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
else if (error && !interrupted) {
if (error && !interrupted) {
rows.push(new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(error.message) }))
}
@@ -315,20 +309,20 @@ export namespace Timeline {
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const groupByPart = new Map<string, PriorGroup>()
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row }))
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeGroupKey(groupByPart, reserved, input, index, claimed)
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
@@ -404,16 +398,16 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) {
return result
}
function stabilizeGroupKey(
groupByPart: Map<string, PriorGroup>,
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type === "part") return row
const existing = row.group.refs.reduce<PriorGroup | undefined>((result, ref) => {
const candidate = groupByPart.get(groupPartKey(row.userMessageID, ref))
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -432,10 +426,6 @@ function stabilizeGroupKey(
})
}
function groupPartKey(userMessageID: string, ref: PartRef) {
return `${userMessageID}:${ref.messageID}:${ref.partID}`
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
@@ -446,38 +436,17 @@ function renderable(content: Content, showReasoning: boolean) {
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined
let context: PartRef[] = []
const flush = () => {
const current = adjacent
const first = current?.refs[0]
const first = context[0]
if (!first) return
if (current.type === "patch" && current.refs.length === 1) {
groups.push({ type: "part", key: `part:${first.messageID}:${first.partID}`, ref: first })
adjacent = undefined
return
}
groups.push({
type: current.type,
key:
current.type === "patch"
? `part:${first.messageID}:${first.partID}`
: `context:${first.messageID}:${first.partID}`,
refs: current.refs,
})
adjacent = undefined
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
const type =
item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)
? "context"
: item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error"
? "patch"
: undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
adjacent.refs.push({ messageID: item.messageID, partID: item.partID })
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
@@ -491,12 +460,6 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
return groups
}
function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
if (content.name !== "read" || content.state.status !== "completed") return false
const loaded = content.state.metadata?.loaded
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
function reasoningHeading(text: string): string | undefined {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
@@ -1,7 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
inspectAndExplainDocument,
loadedResourcesDocument,
skillWorkflowDocument,
subagentDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
@@ -44,12 +44,12 @@ export const ResearchTheWeb = {
),
}
export const LoadedResources = {
export const UseASpecializedSkill = {
render: () => (
<CurrentSessionTimelineStory
title="Loaded instruction file and skill"
description="The assistant reads project instructions, loads specialized guidance, and applies both to its response."
document={loadedResourcesDocument}
title="Use a specialized skill"
description="The selected review agent loads RTL guidance and applies it to a mixed-direction file row."
document={skillWorkflowDocument}
width="760px"
/>
),
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -166,38 +166,6 @@ describe("current session timeline rows", () => {
])
})
test("suppresses thinking while a subagent is delegating or running", () => {
const statuses = ["streaming", "running"] as const
statuses.forEach((status) => {
const source = [
{ id: "msg_user", type: "user", text: "delegate", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_subagent",
name: "subagent",
state:
status === "streaming"
? { status, input: "" }
: { status, input: { description: "Inspect code" }, metadata: {} },
time: { created: 2 },
},
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
expect(Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.map((row) => row._tag)).toEqual(
["UserMessage", "AssistantPart"],
)
})
})
test("renders retry state from the current assistant message", () => {
const source = [
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
@@ -217,35 +185,6 @@ describe("current session timeline rows", () => {
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
test("does not render the retry error twice", () => {
const source = [
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
error: { type: "ProviderError", message: "The provider response ended unexpectedly." },
retry: {
attempt: 2,
at: 10,
error: { type: "ProviderError", message: "The provider response ended unexpectedly." },
},
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, {
type: "retry",
attempt: 2,
next: 10,
message: "The provider response ended unexpectedly.",
})
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
@@ -323,7 +262,7 @@ describe("current session timeline rows", () => {
expect(groups).toEqual([
{
type: "context",
key: "context:msg_assistant:tool_read",
key: "context:tool_read",
refs: [
{ messageID: "msg_assistant", partID: "tool_read" },
{ messageID: "msg_assistant", partID: "tool_grep" },
@@ -337,161 +276,6 @@ describe("current session timeline rows", () => {
])
})
test("keeps reads that load files outside context groups", () => {
const read = {
type: "tool",
id: "tool_read",
name: "read",
state: {
status: "completed",
input: { path: "packages/cli/AGENTS.md" },
content: [{ type: "text", text: "instructions" }],
metadata: { loaded: ["packages/cli/AGENTS.md"] },
},
time: { created: 2, ran: 3, completed: 4 },
} satisfies SessionMessageAssistantTool
const grep = {
type: "tool",
id: "tool_grep",
name: "grep",
state: { status: "running", input: {}, metadata: {} },
time: { created: 5 },
} satisfies SessionMessageAssistantTool
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [read, grep],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group] : [],
)
expect(groups).toEqual([
{
type: "part",
key: "part:msg_assistant:tool_read",
ref: { messageID: "msg_assistant", partID: "tool_read" },
},
{
type: "context",
key: "context:msg_assistant:tool_grep",
refs: [{ messageID: "msg_assistant", partID: "tool_grep" }],
},
])
})
test("keeps context row keys unique when tool IDs repeat across assistant messages", () => {
const tool = (name: string) => ({
type: "tool" as const,
id: "tool_0",
name,
state: { status: "running" as const, input: {}, metadata: {} },
time: { created: 2 },
})
const assistant = (id: string, name: string) => ({
id,
type: "assistant" as const,
agent: "build",
model: { id: "model", providerID: "provider" },
content: [tool(name)],
time: { created: 2 },
})
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
assistant("msg_assistant_1", "read"),
assistant("msg_assistant_2", "execute"),
assistant("msg_assistant_3", "grep"),
] satisfies SessionMessageInfo[]
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.map(TimelineRow.key)
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:msg_user:context:msg_assistant_1:tool_0",
"assistant-part:msg_user:part:msg_assistant_2:tool_0",
"assistant-part:msg_user:context:msg_assistant_3:tool_0",
])
})
test("groups adjacent successful patches and leaves failed patches separate", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_patch_1",
name: "patch",
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "tool_patch_2",
name: "patch",
state: { status: "running", input: {}, metadata: { files: [] } },
time: { created: 4 },
},
{
type: "tool",
id: "tool_patch_failed",
name: "patch",
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "failed" },
metadata: { files: [] },
},
time: { created: 5, completed: 6 },
},
{
type: "tool",
id: "tool_patch_3",
name: "patch",
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
time: { created: 7, completed: 8 },
},
],
time: { created: 2, completed: 8 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
expect(groups).toEqual([
{
type: "patch",
key: "part:msg_assistant:tool_patch_1",
refs: [
{ messageID: "msg_assistant", partID: "tool_patch_1" },
{ messageID: "msg_assistant", partID: "tool_patch_2" },
],
},
{
type: "part",
key: "part:msg_assistant:tool_patch_failed",
ref: { messageID: "msg_assistant", partID: "tool_patch_failed" },
},
{
type: "part",
key: "part:msg_assistant:tool_patch_3",
ref: { messageID: "msg_assistant", partID: "tool_patch_3" },
},
])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
@@ -8,14 +8,12 @@ import { Card } from "@opencode-ai/ui/card"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import { Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import {
MessageDivider,
SessionAssistantContent,
SessionContextToolGroup,
SessionPatchToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -99,24 +97,6 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (row().group.type === "patch") {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "patch") return []
return group.refs.flatMap((ref) => {
const message = input.projection.messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
})
})
return (
<SessionPatchToolGroup
tools={tools()}
onSizeChange={onSizeChange}
/>
)
}
const ref = createMemo(() => {
const group = row().group
return group.type === "part" ? group.ref : undefined
@@ -169,18 +149,10 @@ export function createSessionTimelineRowRenderer(input: {
label: i18n.t("ui.sessionTimeline.notice.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "location-switched")
return { label: i18n.t("ui.patch.action.moved"), data: message.location.directory }
if (message.type === "skill") return { label: i18n.t("ui.tool.skill"), data: message.name }
if (message.type === "system") {
const prefix = "Instructions updated: "
if (message.description?.startsWith(prefix)) {
const keys = message.description.slice(prefix.length).split(",").map((s) => s.trim()).filter(Boolean)
return {
label: i18n.t("ui.sessionTimeline.notice.instructionsUpdated"),
items: keys,
}
}
return { label: message.description ?? message.text }
}
if (message.type === "system") return { label: message.description ?? message.text }
if (message.type === "compaction") return { label: i18n.t("ui.messagePart.compaction"), data: message.status }
if (message.type !== "synthetic") return undefined
if (message.description === "Continuing after restart") return { label: message.description }
@@ -292,88 +264,29 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "Notice") throw new Error("Expected a notice timeline row")
return value
}
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
const moved = createMemo(() => {
const value = message()
return value?.type === "location-switched" ? value : undefined
})
const content = createMemo(() => {
const value = message()
return value ? notice(value) : undefined
const message = input.projection.messageByID().get(current().messageID)
return message ? notice(message) : undefined
})
return (
<Frame row={current()}>
<Show
when={moved()}
fallback={
<Show when={content()}>
{(content) => (
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
<For each={content().items}>
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
)}
</For>
</div>
</div>
</Show>
)}
</Show>
}
>
{(message) => (
<Show when={content()}>
{(content) => (
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<Tooltip
appearance="compact"
placement="top"
value={i18n.t("ui.sessionTimeline.notice.movedTooltip")}
class="shrink-0"
triggerTabIndex={0}
>
<bdi data-slot="session-timeline-notice-label" dir="auto" class="font-[530]">
{i18n.t("ui.sessionTimeline.notice.movedTo")}
</bdi>
</Tooltip>{" "}
<bdi data-slot="session-timeline-notice-value" dir="ltr" class="min-w-0 truncate font-[440]">
{message().location.directory}
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
)}
</Show>
@@ -4,11 +4,10 @@ import {
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
requestHistoryDocument,
retryDocument,
revertDocument,
skillWorkflowDocument,
streamingDocument,
thinkingDocument,
} from "../storybook/current-session-fixtures"
@@ -72,6 +71,17 @@ export const CompactionAndContinuation = {
),
}
export const AgentAndSkillContext = {
render: () => (
<CurrentSessionTimelineStory
title="Agent and skill context"
description="A review agent and its loaded skill appear chronologically before the response."
document={skillWorkflowDocument}
width="600px"
/>
),
}
export const AnsweredQuestionAndDeclinedCommand = {
render: () => (
<CurrentSessionTimelineStory
@@ -119,25 +129,3 @@ export const MixedDirectionRtl = {
/>
),
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
title="Instructions updated (single)"
description="A system notice in the timeline showing a single updated instruction source."
document={instructionsUpdatedSingleDocument}
width="600px"
/>
),
}
export const InstructionsUpdatedMultiple = {
render: () => (
<CurrentSessionTimelineStory
title="Instructions updated (multiple)"
description="A system notice in the timeline showing multiple updated instruction sources."
document={instructionsUpdatedMultipleDocument}
width="600px"
/>
),
}
@@ -16,11 +16,6 @@ export type PartGroup =
type: "context"
refs: PartRef[]
}
| {
key: string
type: "patch"
refs: PartRef[]
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
+100 -178
View File
@@ -412,52 +412,25 @@ function taskSession(
}
function ExaOutput(props: { output?: string }) {
const i18n = useI18n()
const [showAll, setShowAll] = createSignal(false)
let firstRevealedRef: HTMLAnchorElement | undefined
const links = createMemo(() => urls(props.output))
const visibleLinks = createMemo(() => {
const all = links()
if (showAll() || all.length <= 10) return all
return all.slice(0, 10)
})
const remaining = createMemo(() => Math.max(0, links().length - 10))
const expand = (event: MouseEvent) => {
event.stopPropagation()
setShowAll(true)
requestAnimationFrame(() => {
firstRevealedRef?.focus()
})
}
return (
<Show when={links().length > 0}>
<div data-component="exa-tool-output">
<div data-slot="exa-tool-links">
<For each={visibleLinks()}>
{(url, index) => (
<For each={links()}>
{(url) => (
<a
ref={(el) => {
if (index() === 10) firstRevealedRef = el
}}
data-slot="exa-tool-link"
class="webfetch-link"
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
<span data-slot="webfetch-link-text">{url}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
{url}
</a>
)}
</For>
<Show when={!showAll() && remaining() > 0}>
<button type="button" data-slot="exa-tool-more" onClick={expand}>
{i18n.plural("ui.common.moreCount", remaining())}
</button>
</Show>
</div>
</div>
</Show>
@@ -562,40 +535,6 @@ export function CurrentContextToolGroup(props: {
)
}
export function CurrentPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
const metadata = createMemo(() => ({
files: props.tools.flatMap((tool) => {
const files = currentToolMetadata(tool).files
return Array.isArray(files) ? files : []
}),
}))
const pending = createMemo(() =>
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const render = ToolRegistry.render("patch") ?? GenericTool
return (
<div
data-component="tool-part-wrapper"
data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}
>
<Dynamic
component={render}
tool="patch"
input={{}}
metadata={metadata()}
status={pending() ? "running" : "completed"}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
</div>
)
}
function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: ReturnType<typeof useI18n>) {
const input = currentToolInput(tool)
const metadata = currentToolMetadata(tool)
@@ -674,7 +613,7 @@ export const ToolRegistry = {
render: getTool,
}
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) {
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -682,7 +621,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
defaultValue={props.defaultOpen === false ? [] : [value()]}
defaultValue={[value()]}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@@ -829,29 +768,14 @@ ToolRegistry.register({
}}
/>
<For each={loaded()}>
{(filepath) => {
const relative = relativizeProjectPath(filepath, data.directory)
const path = relative === filepath ? relative : relative.replace(/^[/\\]/, "")
const marker = "__OPENCODE_LOADED_PATH__"
const parts = i18n.t("ui.tool.loadedFile", { path: marker }).split(marker)
return (
<div data-component="tool-loaded-item" aria-label={i18n.t("ui.tool.loadedFile", { path })}>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts[0].trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
{path}
</span>
<Show when={parts[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
)
}}
{(filepath) => (
<div data-component="tool-loaded-file">
<Icon name="enter" size="small" />
<span>
{i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
</span>
</div>
)}
</For>
</>
)
@@ -974,7 +898,7 @@ ToolRegistry.register({
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="webfetch-link"
class="clickable webfetch-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
@@ -1023,7 +947,6 @@ ToolRegistry.register({
render(props) {
const data = useData()
const i18n = useI18n()
const delegating = () => props.status === "streaming"
const childSessionId = createMemo(() => {
const value = props.metadata.sessionID
if (typeof value === "string" && value) return value
@@ -1115,33 +1038,17 @@ ToolRegistry.register({
)
return (
<Show
when={delegating()}
fallback={
<BasicTool
icon="task"
status={props.status}
trigger={trigger()}
hideDetails
triggerAsLink
triggerHref={href()}
clickable={clickable()}
onTriggerClick={navigate}
onTriggerKeyDown={navigateKey}
/>
}
>
<div
data-component="task-tool-delegating"
class="flex h-9 w-fit max-w-full items-center gap-2 rounded-[8px] bg-v2-background-bg-layer-01 p-2.5"
>
<Icon name="subagent" size="small" class="shrink-0 text-v2-icon-icon-faint" />
<TextShimmer
text={i18n.t("ui.tool.agent.delegating")}
class="min-w-0 truncate text-[13px] font-[530] leading-none tracking-[-0.04px]"
/>
</div>
</Show>
<BasicTool
icon="task"
status={props.status}
trigger={trigger()}
hideDetails
triggerAsLink
triggerHref={href()}
clickable={clickable()}
onTriggerClick={navigate}
onTriggerKeyDown={navigateKey}
/>
)
},
})
@@ -1201,7 +1108,6 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
@@ -1229,8 +1135,9 @@ ToolRegistry.register({
name: "shell",
render(props) {
const i18n = useI18n()
const streaming = () => props.status === "streaming"
const sawStreaming = streaming()
const pending = () =>
props.status === "streaming" || props.status === "running" || props.metadata.status === "running"
const sawPending = pending()
const command = () => {
if (typeof props.input.command === "string") return props.input.command
if (typeof props.metadata.command === "string") return props.metadata.command
@@ -1244,29 +1151,15 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t("ui.tool.shell")}
active={streaming() || props.status === "running" || props.metadata.status === "running"}
/>
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={!open()}>
<Show
when={command()}
fallback={
<Show when={streaming()}>
<span data-slot="basic-tool-tool-subtitle">{i18n.t("ui.tool.shell.writingCommand")}</span>
</Show>
}
>
{(command) => <ShellSubmessage text={command()} animate={sawStreaming} />}
</Show>
<Show when={!open() && command()}>
<ShellSubmessage text={command()} animate={sawPending} />
</Show>
</div>
</div>
@@ -1374,7 +1267,6 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
@@ -1443,7 +1335,6 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
@@ -1496,12 +1387,22 @@ ToolRegistry.register({
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => patchFiles(props.metadata.files))
const pending = createMemo(() => props.status === "streaming" || props.status === "running")
const single = createMemo(() => {
const list = files()
if (list.length !== 1) return undefined
return list[0]
})
const [expanded, setExpanded] = createSignal<string[]>([])
let seeded = false
createEffect(() => {
const list = files()
if (list.length === 0) return
if (seeded) return
seeded = true
setExpanded(list.filter((file) => file.type !== "delete").map((file) => file.path))
})
const subtitle = createMemo(() => {
const count = files().length
@@ -1516,12 +1417,8 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
rail={false}
defer={props.deferContent !== false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -1536,9 +1433,8 @@ ToolRegistry.register({
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file, index) => {
const value = () => `${index()}:${file.path}`
const active = createMemo(() => expanded().includes(value()))
{(file) => {
const active = createMemo(() => expanded().includes(file.path))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
@@ -1554,7 +1450,7 @@ ToolRegistry.register({
})
return (
<Accordion.Item value={value()} data-type={file.type}>
<Accordion.Item value={file.path} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
@@ -1618,17 +1514,38 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(single()!.path)}</span>
</Show>
</div>
<Show when={!pending() && single()!.path.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(single()!.path)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending()}>
<DiffChanges
appearance="standard"
changes={{ additions: single()!.additions, deletions: single()!.deletions }}
/>
</Show>
</div>
</div>
}
>
<ToolFileAccordion
path={single()!.path}
defaultOpen={false}
actions={
<Switch>
<Match when={single()!.type === "add"}>
@@ -1722,29 +1639,34 @@ ToolRegistry.register({
const i18n = useI18n()
const name = createMemo(() => skillToolName(props.input, props.metadata))
const running = createMemo(() => props.status === "streaming" || props.status === "running")
const marker = "__OPENCODE_LOADED_SKILL__"
const parts = createMemo(() => i18n.t("ui.tool.loadedSkill", { name: marker }).split(marker))
return (
<Show when={name()} fallback={<TextShimmer text={i18n.t("ui.tool.skill")} active={running()} />}>
{(name) => (
<div data-component="tool-loaded-item" aria-label={i18n.t("ui.tool.loadedSkill", { name: name() })}>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts()[0].trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<TextShimmer as="span" text={name()} active={running()} />
</span>
<Show when={parts()[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
)}
</Show>
const trigger = () => (
<div data-slot="skill-tool-trigger" class="flex min-w-0 items-center gap-1.5">
<Icon name="post-skill" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span
data-slot="skill-tool-label"
class="shrink-0 text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
>
{i18n.t("ui.tool.skill")}
</span>
<Show when={name()}>
{(name) => (
<>
<span data-slot="skill-tool-separator" aria-hidden="true" class="shrink-0 text-v2-text-text-muted">
·
</span>
<TextShimmer
as="bdi"
text={name()}
active={running()}
class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
/>
</>
)}
</Show>
</div>
)
return <BasicTool icon="post-skill" status={props.status} trigger={trigger()} hideDetails />
},
})
-1
View File
@@ -22,7 +22,6 @@ export default defineMain({
"@storybook/addon-a11y",
"@storybook/addon-vitest",
],
staticDirs: [path.resolve(here, "../../app/public")],
stories: [
"../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../../session-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
@@ -6,26 +6,6 @@ const keybinds: Record<string, string> = {
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
"session.background": "ctrl+b",
}
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
export function parseKeybind(config: string) {
if (!config || config === "none") return []
return config.split(",").map((combo) => {
const parts = combo.trim().toLowerCase().split("+")
return {
key:
parts.find(
(part) => !["ctrl", "control", "meta", "cmd", "command", "mod", "alt", "option", "shift"].includes(part),
) ?? "",
ctrl: parts.includes("ctrl") || parts.includes("control") || parts.includes("mod"),
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
shift: parts.includes("shift"),
alt: parts.includes("alt") || parts.includes("option"),
}
})
}
export function formatKeybind(config: string) {
@@ -25,10 +25,6 @@ const [all, setAll] = createSignal<string[]>([])
const [active, setActive] = createSignal<string | undefined>(undefined)
const [reviewOpen, setReviewOpen] = createSignal(false)
export function useCurrentRoute() {
return () => ({ type: "home" as const })
}
const tabs = {
all,
active,
+3 -1
View File
@@ -1,4 +1,6 @@
import "../../app/src/index.css"
import "@opencode-ai/ui/styles/tailwind"
import "@opencode-ai/session-ui/styles"
import "@opencode-ai/ui/styles/tokens"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
+2 -4
View File
@@ -34,10 +34,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const directory = result.directory
if (!directory) throw new Error("No worktree directory returned")
// Seed the location store before optimistic session creation mounts the
// destination. A raw read initializes the server location but leaves the
// optimistic session without its project until the create request echoes.
await data.location.syncInfo({ directory })
// Call a location-based route to initialize it before moving on.
await client.api.location.get({ location: { directory } })
setProgress("Creating session")
return directory
+1 -1
View File
@@ -35,7 +35,7 @@ function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<span data-slot="collapsible-arrow-icon">
<Icon name="fill-triangle-down" size="small" />
<Icon name="chevron-down" size="small" />
</span>
</div>
)
-9
View File
@@ -152,8 +152,6 @@ const source = {
"ui.tool.read": "Read",
"ui.tool.loaded": "Loaded",
"ui.tool.loadedFile": "Loaded {{path}}",
"ui.tool.loadedSkill": "Loaded {{name}} skill",
"ui.tool.list": "List",
"ui.tool.glob": "Glob",
"ui.tool.grep": "Grep",
@@ -162,14 +160,12 @@ const source = {
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.shell": "Shell",
"ui.tool.shell.writingCommand": "Writing command...",
"ui.tool.execute": "Execute",
"ui.tool.patch": "Patch",
"ui.tool.questions": "Questions",
"ui.tool.questions.numbered": "Questions {{number}}",
"ui.tool.agent": "{{type}} Agent",
"ui.tool.agent.default": "Agent",
"ui.tool.agent.delegating": "Delegating agent...",
"ui.tool.skill": "Skill",
"ui.basicTool.called": "Called `{{tool}}`",
@@ -192,8 +188,6 @@ const source = {
"ui.common.next": "Next",
"ui.common.submit": "Submit",
"ui.common.showMore": "Show more",
"ui.common.moreCount.one": "+{{count}} more",
"ui.common.moreCount.other": "+{{count}} more",
"ui.permission.deny": "Deny",
"ui.permission.allowAlways": "Allow always",
@@ -211,12 +205,9 @@ const source = {
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.interrupted": "Interrupted",
"ui.sessionTimeline.notice.model": "Model",
"ui.sessionTimeline.notice.movedTo": "Moved to",
"ui.sessionTimeline.notice.movedTooltip": "Session working directory changed",
"ui.sessionTimeline.notice.failed": "{{actor}} failed",
"ui.sessionTimeline.notice.cancelled": "{{actor}} cancelled",
"ui.sessionTimeline.notice.finished": "{{actor}} finished",
"ui.sessionTimeline.notice.instructionsUpdated": "Instructions updated",
"ui.message.queued": "Queued",
"ui.message.attachment.alt": "attachment",
-4
View File
@@ -160,10 +160,6 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
},
"fill-triangle-down": {
viewBox: "0 0 16 16",
body: `<path d="M5.37624 6.75194C5.1818 6.41861 5.42223 6 5.80813 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43199 10.5096C8.23905 10.8404 7.76115 10.8404 7.56821 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
},
archive: {
viewBox: "0 0 16 16",
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
@@ -12,7 +12,6 @@ export interface TooltipProps extends ComponentProps<typeof Root> {
contentStyle?: JSX.CSSProperties
inactive?: boolean
forceOpen?: boolean
triggerTabIndex?: number
}
export function Tooltip(props: TooltipProps) {
@@ -30,7 +29,6 @@ export function Tooltip(props: TooltipProps) {
"contentStyle",
"inactive",
"forceOpen",
"triggerTabIndex",
"ignoreSafeArea",
"value",
])
@@ -112,7 +110,6 @@ export function Tooltip(props: TooltipProps) {
<Trigger
ref={ref}
as="div"
tabIndex={local.triggerTabIndex}
data-component="tooltip-v2-trigger"
class={local.class}
onPointerDownCapture={arm}
@@ -252,7 +252,6 @@
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-faint: var(--v2-icon-icon-faint);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
--color-v2-icon-icon-contrast: var(--v2-icon-icon-contrast);
--color-v2-icon-icon-accent: var(--v2-icon-icon-accent);
-4
View File
@@ -27,7 +27,6 @@
/* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -159,7 +158,6 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
@@ -273,7 +271,6 @@
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -396,7 +393,6 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-1100);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-400);
-2
View File
@@ -176,7 +176,6 @@
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-500)",
"v2-icon-icon-inverse": "var(--v2-grey-50)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-600)",
@@ -412,7 +411,6 @@
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-500)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-400)",
-1
View File
@@ -52,7 +52,6 @@ export function mapV2Foreground(
"v2-text-text-faint": shift(body, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }),
"v2-icon-icon-base": greyRef(pickGrey(primitives, bgBase, 7, isDark ? 400 : 800)),
"v2-icon-icon-muted": greyRef(pickGrey(primitives, bgBase, 3, 600)),
"v2-icon-icon-faint": greyRef(pickGrey(primitives, bgBase, 1.5, 500)),
"v2-icon-icon-inverse": greyRef(pickGrey(primitives, bgInverse, 7, inverseTarget)),
"v2-icon-icon-contrast": greyRef(pickGrey(primitives, bgContrast, 7, 100)),
"v2-icon-icon-accent": isDark ? "var(--v2-blue-400)" : "var(--v2-blue-600)",