mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 09:36:17 +00:00
Compare commits
19
Commits
context-weight
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f32fa0da | ||
|
|
8062b5455a | ||
|
|
4566395d42 | ||
|
|
3c70f6df28 | ||
|
|
97536add75 | ||
|
|
6258c1943a | ||
|
|
0d2c865c93 | ||
|
|
7032a096bf | ||
|
|
80ef4f454f | ||
|
|
aa2dd5040f | ||
|
|
9b17449e88 | ||
|
|
346689fb43 | ||
|
|
87d2488c78 | ||
|
|
a1d0f43531 | ||
|
|
030b2d9543 | ||
|
|
e7177a8764 | ||
|
|
a84b1c15ce | ||
|
|
d3eee25ee2 | ||
|
|
b4fabf5984 |
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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")<{
|
||||
|
||||
@@ -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 />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user