Compare commits

..
19 Commits
Author SHA1 Message Date
OpeOginniandBrendan Allan 85f32fa0da fix(app): load workspace sessions by directory (#44027)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-22 07:50:08 +00:00
Luke Parker 8062b5455a feat(app): align moved location notice (#44085) 2026-08-22 17:30:42 +10:00
Luke Parker 4566395d42 feat(app): show subagent delegation state (#44078) 2026-08-22 17:03:32 +10:00
opencode-agent[bot]andHona 3c70f6df28 fix(app): enforce single titlebar action owner (#44075)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 17:03:24 +10:00
Brendan Allan 97536add75 fix(app): preserve bottom scroll anchoring (#44077) 2026-08-22 14:53:01 +08:00
Brendan Allan 6258c1943a feat(app): group adjacent patch calls (#43901) 2026-08-22 06:51:53 +00:00
Luke Parker 0d2c865c93 feat(app): move background work to session summary (#44068) 2026-08-22 16:31:53 +10:00
Luke Parker 7032a096bf fix(desktop): configure app before instance lock (#44067) 2026-08-22 16:09:13 +10:00
Luke Parker 80ef4f454f feat(desktop): align loaded resource calls with figma (#44060) 2026-08-22 15:17:11 +10:00
Luke Parker aa2dd5040f fix(desktop): omit shared rail from framed disclosures (#44061) 2026-08-22 15:17:09 +10:00
Luke Parker 9b17449e88 fix(desktop): match instructions notice typography to figma (#44059) 2026-08-22 15:06:13 +10:00
Luke Parker 346689fb43 fix(storybook): use production styles and remove synthetic tool stories (#44058) 2026-08-22 15:05:51 +10:00
Luke Parker 87d2488c78 feat(desktop): apply left border rail styling to all tool disclosures (#44052) 2026-08-22 14:23:18 +10:00
Aiden ClineandMajor Hayden a1d0f43531 fix(ai): preserve unique Gemini function call IDs (#44051)
Signed-off-by: Major Hayden <major@mhtx.net>
Co-authored-by: Major Hayden <major@mhtx.net>
2026-08-21 23:12:04 -05:00
Luke Parker 030b2d9543 feat(desktop): align instructions updated notice presentation with figma (#44050) 2026-08-22 13:49:24 +10:00
Luke Parker e7177a8764 fix(desktop): align web search results presentation with figma (#44039) 2026-08-22 13:22:22 +10:00
opencode-agent[bot]andHona a84b1c15ce fix(app): wait for session route id (#44048)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 13:22:10 +10:00
opencode-agent[bot]andHona d3eee25ee2 fix(session-ui): scope context row keys (#44043)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 02:57:53 +00:00
Luke Parker b4fabf5984 fix(desktop): align webfetch link styling with figma (#44037) 2026-08-22 02:29:03 +00:00
60 changed files with 1594 additions and 699 deletions
+4 -5
View File
@@ -212,7 +212,6 @@ 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
@@ -580,7 +579,6 @@ 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
@@ -620,7 +618,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}`
// 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 metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
@@ -649,7 +649,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
@@ -673,7 +672,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
+52 -26
View File
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "tool_0",
id: "provider_call",
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: "tool_0",
id: "provider_call",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
id: "provider_call",
name: "lookup",
result: "done",
resultType: "text",
@@ -1101,21 +1101,17 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
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.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: "tool_0",
id: response.toolCalls[0].id,
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1158,7 +1154,8 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
}),
)
@@ -1198,7 +1195,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1212,16 +1209,20 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
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.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.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1229,6 +1230,31 @@ 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(
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"
import { createTwoFilesPatch } from "diff"
import {
defineVisualRegions,
reportVisualStability,
@@ -13,10 +14,63 @@ 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,5 +47,10 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
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()
})
@@ -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("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
test("keeps patch file disclosures independent", 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,15 +21,17 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
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 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")
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")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,6 +73,81 @@ 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"]')
@@ -81,7 +156,24 @@ 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)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
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 })
const request = page.waitForRequest(
(request) =>
@@ -104,10 +196,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
@@ -118,7 +210,14 @@ test("shows a badge for active background work", async ({ page }) => {
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
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()
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
@@ -193,10 +292,15 @@ 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(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(page.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(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -69,9 +69,43 @@ 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(
[
@@ -196,11 +230,7 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
files: [patchFile("src/a.ts", "modified")],
},
},
)
@@ -127,19 +127,16 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
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]) {
for (const [id, name] of [
[pending, "sample-skill"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
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()
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)
}
})
+1 -3
View File
@@ -1,6 +1,5 @@
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"
@@ -15,7 +14,6 @@ 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")
@@ -69,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) {
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<NewSessionStatus 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>
+9 -14
View File
@@ -4,7 +4,6 @@ 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"
@@ -15,6 +14,7 @@ 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,21 +87,16 @@ export function NewSessionView(props: {
)
}
export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) {
export function NewSessionStatus(props: { visible: boolean }) {
const language = useLanguage()
return (
<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>
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}
+4 -1
View File
@@ -544,7 +544,6 @@ 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",
@@ -657,6 +656,10 @@ 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",
@@ -2,15 +2,12 @@ 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,
@@ -32,9 +29,6 @@ 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}
@@ -81,22 +75,10 @@ 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,13 +1,12 @@
import { createMemo, Show } from "solid-js"
import { createMemo } 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 { useTitlebarRightMount } from "@/shell/titlebar/titlebar"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions"
export function SessionHeader() {
@@ -28,15 +27,9 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const rightMount = useTitlebarRightMount()
return (
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderActions state={actions()} />
</Portal>
)}
</Show>
<TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
)
}
@@ -63,6 +63,7 @@ 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,
},
@@ -93,11 +94,13 @@ 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,
},
]
})
@@ -1,83 +0,0 @@
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>
)
}
@@ -1,114 +0,0 @@
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,6 +72,7 @@ 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}
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
let syncs = 0
const sessions = {
get: () => undefined,
sync: () => {
syncs++
return Promise.resolve()
},
}
const session = createSessionResolution(() => undefined, () => sessions)
expect(session()).toBeUndefined()
expect(syncs).toBe(0)
dispose()
})
})
})
@@ -29,15 +29,20 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
// session that simply has not resolved yet. Resolve failures rethrow on read so
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
export function createSessionResolution<T>(
sessionID: () => string,
sessionID: () => string | undefined,
sessions: () => SessionStore<T>,
options?: { children?: boolean },
) {
const cached = createMemo(() => sessions().get(sessionID()))
const cached = createMemo(() => {
const id = sessionID()
if (!id) return
return sessions().get(id)
})
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
let stale = false
onCleanup(() => {
stale = true
@@ -60,10 +65,11 @@ export function createSessionResolution<T>(
return createMemo(() => {
const id = sessionID()
if (!id) return
const value = cached()
if (value) return value
const state = status()
if (state?.id !== id || state.store !== sessions()) return undefined
if (!state || state.id !== id || state.store !== sessions()) return undefined
if (state.state === "failed") throw state.failure
// A session missing after settlement was deleted, possibly by another client.
// Match the resolve error so the boundary shows the
-8
View File
@@ -84,7 +84,6 @@ 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[] }
}
@@ -196,13 +195,6 @@ 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,
@@ -0,0 +1,36 @@
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,11 +1,14 @@
import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, For, 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"
@@ -15,7 +18,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 } from "@opencode-ai/session-ui/timeline/projection"
import { Timeline, TimelineRow } 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"
@@ -24,6 +27,98 @@ 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>
)
}
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"
@@ -94,6 +189,7 @@ function SessionSummaryPanel(props: {
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
@@ -168,6 +264,9 @@ 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
@@ -186,6 +285,7 @@ function SessionSummaryPanel(props: {
type MessageTimelineProps = {
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
scroll: { overflow: boolean; jump: boolean }
onResumeScroll: () => void
@@ -350,6 +450,18 @@ 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
@@ -357,9 +469,25 @@ 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", "patch"].includes(content.name)
return content?.type === "tool" && ["edit", "write"].includes(content.name)
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
renderRow={(row, onSizeChange) => (
<>
<rowRenderer.Row row={row} onSizeChange={onSizeChange} />
<Show when={backgroundHint(row())}>
<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 class={`flex h-10 items-start pt-4 ${turnPadding()}`}>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
)}
header={
<div
data-session-title
@@ -485,6 +613,7 @@ function MessageTimelineView(
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
@@ -74,7 +74,9 @@ 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
@@ -178,12 +180,28 @@ 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 || input.hasScrollGesture()) return
if (resizeAnchorScheduled) return
resizeAnchorScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
virtualizer.scrollToEnd()
})
}
@@ -244,7 +262,11 @@ export function createTimelineVirtualizer(input: Input) {
const maybeAnchorBottom = () => {
if (rows().length === 0) return
if (!input.shouldAnchorBottom() || input.hasScrollGesture()) return
if (input.hasScrollGesture()) {
anchorAfterGesture()
return
}
if (!input.shouldAnchorBottom()) return
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
@@ -265,6 +287,7 @@ export function createTimelineVirtualizer(input: Input) {
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
setListRoot(root)
scrollTop = root.scrollTop
input.setScrollRef(root)
}
@@ -324,13 +347,17 @@ 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(event.currentTarget)
input.onScheduleScrollState(root)
input.onHistoryScroll()
if (!input.hasScrollGesture()) return
if (!movedUp && root.scrollHeight - root.clientHeight - root.scrollTop >= 10) return
input.onUserScroll()
input.onAutoScrollHandleScroll()
input.onMarkScrollGesture(event.currentTarget)
input.onMarkScrollGesture(root)
}
function View(props: ViewProps) {
@@ -463,6 +490,7 @@ 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,14 +93,24 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
activeDirectory: props.activeDirectory,
}
}
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())
// 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 workspaceDirectories = createMemo(() => workspaces().map((workspace) => workspace.directory))
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions().then(() => Date.now()),
queryKey: [
serverSDK.scope,
null,
"settings-workspace-sessions",
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: workspaceDirectories().length > 0,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
@@ -108,7 +118,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
]),
),
)
@@ -136,7 +146,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(context),
loadSessions([workspace.directory], context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
+28 -25
View File
@@ -3,6 +3,7 @@ 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 })))
@@ -23,30 +24,32 @@ export default function Layout(props: ParentProps) {
}
return (
<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>
</TitlebarRightProvider>
)
}
@@ -0,0 +1,27 @@
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()
})
})
})
@@ -0,0 +1,65 @@
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
}
+3 -22
View File
@@ -1,15 +1,4 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
Match,
on,
onMount,
Show,
Switch,
untrack,
} from "solid-js"
import { createEffect, createMemo, createResource, Match, createSignal, 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"
@@ -34,6 +23,7 @@ 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
@@ -46,15 +36,6 @@ 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()
@@ -421,7 +402,7 @@ function TitlebarRight(props: { state: TitlebarRightState }) {
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
<TitlebarRightMount />
</div>
)
}
+2 -1
View File
@@ -147,8 +147,9 @@ const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
if (!acquireApplicationLock()) return yield* Effect.interrupt
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
return runtime.pipe(Layer.provideMerge(platform))
}),
)
@@ -116,7 +116,7 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
&.clickable {
&.clickable:not(.webfetch-link) {
cursor: pointer;
text-decoration: underline;
transition: color 0.15s ease;
@@ -256,6 +256,24 @@
}
}
[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;
@@ -325,3 +343,72 @@
}
}
}
.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;
}
}
}
@@ -1,75 +0,0 @@
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,6 +36,7 @@ export interface BasicToolProps {
defer?: boolean
locked?: boolean
animated?: boolean
rail?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
@@ -255,16 +256,34 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible
open={open()}
onOpenChange={props.locked ? undefined : handleOpenChange}
class="tool-collapsible"
data-rail={props.rail === false ? "false" : undefined}
>
<Show
when={props.triggerAsLink || props.triggerHref}
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
fallback={
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
<Show
when={!props.locked}
fallback={
<div
data-slot="collapsible-trigger"
data-locked
data-hide-details={props.hideDetails ? "true" : undefined}
>
{trigger()}
</div>
}
>
{trigger()}
</Collapsible.Trigger>
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
</Show>
}
>
<Collapsible.Trigger
@@ -326,7 +326,7 @@
[data-component="tool-output"] {
white-space: pre;
padding: 0;
margin-bottom: 24px;
margin-bottom: 0px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -569,12 +569,13 @@
}
[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: var(--font-size-base);
line-height: var(--line-height-large);
font-size: 13px;
line-height: 16px;
color: var(--v2-text-text-muted);
}
@@ -589,27 +590,39 @@
[data-slot="exa-tool-links"] {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
gap: 8px;
min-width: 0;
flex: 1 0 0;
}
[data-slot="exa-tool-link"] {
display: block;
width: fit-content;
max-width: 100%;
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;
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;
&:hover {
color: var(--v2-text-text-accent);
color: var(--v2-text-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
}
&:visited {
color: var(--v2-text-text-accent);
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
}
}
@@ -655,10 +668,7 @@
}
[data-component="context-tool-group-list"] {
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
@@ -1216,10 +1226,19 @@
> [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"][aria-expanded="true"],
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
@@ -1320,20 +1339,36 @@
}
}
[data-component="tool-loaded-file"] {
[data-component="tool-loaded-item"] {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 4px 28px;
gap: 6px;
min-width: 0;
max-width: 100%;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
[data-slot="icon-svg"] {
[data-slot="tool-loaded-label"] {
flex-shrink: 0;
color: var(--icon-weak);
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);
}
}
@@ -17,6 +17,17 @@
> [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,10 +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, true)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
})
test("keeps deletion-only changes collapsed", () => {
test("opens deletion-only patches", () => {
expect(
currentContentDefaultOpen(
tool("patch", [
@@ -39,6 +39,6 @@ describe("current content default open", () => {
false,
true,
),
).toBe(false)
).toBe(true)
})
})
@@ -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, ToolDisplay } from "../tools/tool-renderer"
import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -109,3 +109,10 @@ export function SessionContextToolGroup(props: {
/>
)
}
export function SessionPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
return <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
}
@@ -36,7 +36,8 @@ export function currentContentDefaultOpen(
) {
if (content.type !== "tool") return undefined
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined
if (content.name === "patch") return true
if (content.name !== "edit" && content.name !== "write") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
if (!Array.isArray(files) || files.length === 0) return true
@@ -702,15 +702,32 @@ export const webResearchDocument = document([
id: "tool_web_search",
name: "websearch",
offset: 73_100,
args: { query: "WAI ARIA live region status message guidance" },
output: "WAI-ARIA Authoring Practices and MDN live region guidance",
metadata: { provider: "exa" },
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" },
}),
completedTool({
id: "tool_web_fetch",
name: "webfetch",
offset: 74_000,
args: { url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" },
args: { url: "https://www.figma.com" },
output: "Status messages should be programmatically determinable without receiving focus.",
}),
],
@@ -728,33 +745,25 @@ export const webResearchDocument = document([
}),
] satisfies SessionMessageInfo[])
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),
export const loadedResourcesDocument = document([
user("msg_user_skill", "Read the project instructions, load the RTL-aware skill, and review the file row.", 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_100,
offset: 80_200,
args: { name: "rtl-aware-development" },
output: "Loaded RTL-aware development guidance",
metadata: { name: "rtl-aware-development" },
@@ -767,6 +776,50 @@ export const skillWorkflowDocument = 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,
skillWorkflowDocument,
loadedResourcesDocument,
standaloneShellCompletedDocument,
standaloneShellRunningDocument,
streamingDocument,
@@ -2,12 +2,27 @@ 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[], userMessageID = "user-1") =>
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") =>
new TimelineRow.AssistantPart({
userMessageID,
group: {
key,
type: "context",
type: "patch",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
@@ -32,6 +47,13 @@ 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"])],
@@ -62,11 +84,18 @@ describe("reuseTimelineRows", () => {
},
{
name: "does not reuse context identity across user messages",
previous: [context("context:a", ["a", "b"], "user-1")],
rows: [context("context:b", ["b"], "user-2")],
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "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()],
+56 -18
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 ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorGroup = { index: number; row: GroupRow }
const contextTools = new Set(["read", "glob", "grep", "list"])
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
@@ -222,6 +222,12 @@ 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 }))
@@ -273,6 +279,7 @@ export namespace Timeline {
status.type === "busy" &&
!error &&
!retry &&
!delegating &&
(showReasoning ? assistantPartRefs.length === 0 : true)
) {
const heading = assistantMessages
@@ -309,20 +316,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 contextByPart = new Map<string, PriorContext>()
const groupByPart = new Map<string, PriorGroup>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
if (row._tag !== "AssistantPart" || row.group.type === "part") 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 = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const row = stabilizeGroupKey(groupByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
@@ -398,16 +405,16 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) {
return result
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
function stabilizeGroupKey(
groupByPart: Map<string, PriorGroup>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
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 (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 (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -426,6 +433,10 @@ function stabilizeContextKey(
})
}
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()
@@ -436,17 +447,38 @@ function renderable(content: Content, showReasoning: boolean) {
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: PartRef[] = []
let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined
const flush = () => {
const first = context[0]
const current = adjacent
const first = current?.refs[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
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
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
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 })
return
}
flush()
@@ -460,6 +492,12 @@ 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,
skillWorkflowDocument,
loadedResourcesDocument,
subagentDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
@@ -44,12 +44,12 @@ export const ResearchTheWeb = {
),
}
export const UseASpecializedSkill = {
export const LoadedResources = {
render: () => (
<CurrentSessionTimelineStory
title="Use a specialized skill"
description="The selected review agent loads RTL guidance and applies it to a mixed-direction file row."
document={skillWorkflowDocument}
title="Loaded instruction file and skill"
description="The assistant reads project instructions, loads specialized guidance, and applies both to its response."
document={loadedResourcesDocument}
width="760px"
/>
),
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -166,6 +166,38 @@ 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 } },
@@ -262,7 +294,7 @@ describe("current session timeline rows", () => {
expect(groups).toEqual([
{
type: "context",
key: "context:tool_read",
key: "context:msg_assistant:tool_read",
refs: [
{ messageID: "msg_assistant", partID: "tool_read" },
{ messageID: "msg_assistant", partID: "tool_grep" },
@@ -276,6 +308,161 @@ 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,12 +8,14 @@ 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 { Show, createMemo, type Accessor, type JSX } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import {
MessageDivider,
SessionAssistantContent,
SessionContextToolGroup,
SessionPatchToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -97,6 +99,24 @@ 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
@@ -149,10 +169,18 @@ 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") return { label: message.description ?? message.text }
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 === "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 }
@@ -264,29 +292,88 @@ 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 message = input.projection.messageByID().get(current().messageID)
return message ? notice(message) : undefined
const value = message()
return value ? notice(value) : undefined
})
return (
<Frame row={current()}>
<Show when={content()}>
{(content) => (
<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) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
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()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
<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>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
)}
</Show>
@@ -4,10 +4,11 @@ import {
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
requestHistoryDocument,
retryDocument,
revertDocument,
skillWorkflowDocument,
streamingDocument,
thinkingDocument,
} from "../storybook/current-session-fixtures"
@@ -71,17 +72,6 @@ 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
@@ -129,3 +119,25 @@ 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,6 +16,11 @@ export type PartGroup =
type: "context"
refs: PartRef[]
}
| {
key: string
type: "patch"
refs: PartRef[]
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
+162 -100
View File
@@ -412,25 +412,52 @@ 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={links()}>
{(url) => (
<For each={visibleLinks()}>
{(url, index) => (
<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()}
>
{url}
<span data-slot="webfetch-link-text">{url}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</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>
@@ -535,6 +562,40 @@ 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)
@@ -613,7 +674,7 @@ export const ToolRegistry = {
render: getTool,
}
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -621,7 +682,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={[value()]}
defaultValue={props.defaultOpen === false ? [] : [value()]}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@@ -768,14 +829,29 @@ ToolRegistry.register({
}}
/>
<For each={loaded()}>
{(filepath) => (
<div data-component="tool-loaded-file">
<Icon name="enter" size="small" />
<span>
{i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
</span>
</div>
)}
{(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>
)
}}
</For>
</>
)
@@ -898,21 +974,17 @@ ToolRegistry.register({
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
class="webfetch-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url()}
<span data-slot="webfetch-link-text">{url()}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</a>
</Show>
</div>
<Show when={!pending() && url()}>
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
}
/>
@@ -951,6 +1023,7 @@ 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
@@ -1042,17 +1115,33 @@ ToolRegistry.register({
)
return (
<BasicTool
icon="task"
status={props.status}
trigger={trigger()}
hideDetails
triggerAsLink
triggerHref={href()}
clickable={clickable()}
onTriggerClick={navigate}
onTriggerKeyDown={navigateKey}
/>
<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>
)
},
})
@@ -1112,6 +1201,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
@@ -1155,6 +1245,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
@@ -1271,6 +1362,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
@@ -1339,6 +1431,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
@@ -1391,22 +1484,12 @@ 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
@@ -1421,8 +1504,12 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={props.deferContent !== false}
defer={false}
rail={false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -1437,8 +1524,9 @@ ToolRegistry.register({
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.path))
{(file, index) => {
const value = () => `${index()}:${file.path}`
const active = createMemo(() => expanded().includes(value()))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
@@ -1454,7 +1542,7 @@ ToolRegistry.register({
})
return (
<Accordion.Item value={file.path} data-type={file.type}>
<Accordion.Item value={value()} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
@@ -1518,38 +1606,17 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
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>
}
defer={false}
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
rail={false}
>
<ToolFileAccordion
path={single()!.path}
defaultOpen={false}
actions={
<Switch>
<Match when={single()!.type === "add"}>
@@ -1643,34 +1710,29 @@ 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))
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 (
<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>
)
return <BasicTool icon="post-skill" status={props.status} trigger={trigger()} hideDetails />
},
})
+1
View File
@@ -22,6 +22,7 @@ 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,6 +6,26 @@ 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,6 +25,10 @@ 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,
+1 -3
View File
@@ -1,6 +1,4 @@
import "@opencode-ai/ui/styles/tailwind"
import "@opencode-ai/session-ui/styles"
import "@opencode-ai/ui/styles/tokens"
import "../../app/src/index.css"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
+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="chevron-down" size="small" />
<Icon name="fill-triangle-down" size="small" />
</span>
</div>
)
+8
View File
@@ -152,6 +152,8 @@ 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",
@@ -166,6 +168,7 @@ const source = {
"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}}`",
@@ -188,6 +191,8 @@ 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",
@@ -205,9 +210,12 @@ 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,6 +160,10 @@ 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,6 +12,7 @@ export interface TooltipProps extends ComponentProps<typeof Root> {
contentStyle?: JSX.CSSProperties
inactive?: boolean
forceOpen?: boolean
triggerTabIndex?: number
}
export function Tooltip(props: TooltipProps) {
@@ -29,6 +30,7 @@ export function Tooltip(props: TooltipProps) {
"contentStyle",
"inactive",
"forceOpen",
"triggerTabIndex",
"ignoreSafeArea",
"value",
])
@@ -110,6 +112,7 @@ export function Tooltip(props: TooltipProps) {
<Trigger
ref={ref}
as="div"
tabIndex={local.triggerTabIndex}
data-component="tooltip-v2-trigger"
class={local.class}
onPointerDownCapture={arm}
@@ -252,6 +252,7 @@
--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,6 +27,7 @@
/* ── 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);
@@ -158,6 +159,7 @@
--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);
@@ -271,6 +273,7 @@
--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);
@@ -393,6 +396,7 @@
--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,6 +176,7 @@
"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)",
@@ -411,6 +412,7 @@
"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,6 +52,7 @@ 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)",
@@ -36,16 +36,12 @@
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
grid-area: 1 / 1;
white-space: pre;
transition: opacity var(--text-shimmer-swap) ease-out;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"]:where([data-active="true"]) [data-slot="text-shimmer-char-shimmer"] {
transition: opacity var(--text-shimmer-swap) ease-out;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
color: inherit;
opacity: 1;
@@ -116,7 +112,6 @@
color: inherit;
-webkit-text-fill-color: currentColor;
background-image: none;
opacity: 0 !important;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {