mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 09:36:17 +00:00
Compare commits
13
Commits
context-weight
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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(
|
||||
|
||||
@@ -81,7 +81,25 @@ 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"]')
|
||||
const thinking = page.locator('[data-slot="session-turn-thinking"]')
|
||||
await expect(hint).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox, prefixBox, thinkingBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
hint.boundingBox(),
|
||||
hintPrefix.boundingBox(),
|
||||
thinking.boundingBox(),
|
||||
])
|
||||
if (!cardBox || !hintBox || !prefixBox || !thinkingBox) return undefined
|
||||
return {
|
||||
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
|
||||
ordered: cardBox.y < hintBox.y && hintBox.y < thinkingBox.y,
|
||||
}
|
||||
})
|
||||
.toEqual({ aligned: true, ordered: true })
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
@@ -104,10 +122,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 +136,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 +218,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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
@@ -359,7 +471,23 @@ function MessageTimelineView(
|
||||
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
|
||||
return content?.type === "tool" && ["edit", "write", "patch"].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>
|
||||
|
||||
@@ -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,7 +256,12 @@ export function BasicTool(props: BasicToolProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Collapsible
|
||||
open={open()}
|
||||
onOpenChange={handleOpenChange}
|
||||
class="tool-collapsible"
|
||||
data-rail={props.rail === false ? "false" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={props.triggerAsLink || props.triggerHref}
|
||||
fallback={
|
||||
|
||||
@@ -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,7 +1226,15 @@
|
||||
|
||||
> [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"] {
|
||||
@@ -1320,20 +1338,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"] {
|
||||
|
||||
@@ -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,13 +2,17 @@ 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,
|
||||
userMessageID: identity.userMessageID ?? "user-1",
|
||||
group: {
|
||||
key,
|
||||
type: "context",
|
||||
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
|
||||
refs: partIDs.map((partID) => ({ messageID: identity.messageID ?? "assistant-1", partID })),
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
@@ -62,11 +66,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()],
|
||||
|
||||
@@ -312,7 +312,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
|
||||
const contextByPart = new Map<string, PriorContext>()
|
||||
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 }))
|
||||
row.group.refs.forEach((ref) => contextByPart.set(contextPartKey(row.userMessageID, ref), { index, row }))
|
||||
})
|
||||
const reserved = new Map<string, number>()
|
||||
rows.forEach((row, index) => {
|
||||
@@ -407,7 +407,7 @@ function stabilizeContextKey(
|
||||
) {
|
||||
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}`)
|
||||
const candidate = contextByPart.get(contextPartKey(row.userMessageID, ref))
|
||||
if (!candidate) return result
|
||||
const key = TimelineRow.key(candidate.row)
|
||||
if (claimed.has(key)) return result
|
||||
@@ -426,6 +426,10 @@ function stabilizeContextKey(
|
||||
})
|
||||
}
|
||||
|
||||
function contextPartKey(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()
|
||||
@@ -440,12 +444,12 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
|
||||
const flush = () => {
|
||||
const first = context[0]
|
||||
if (!first) return
|
||||
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
|
||||
groups.push({ type: "context", key: `context:${first.messageID}:${first.partID}`, refs: context })
|
||||
context = []
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
|
||||
if (item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)) {
|
||||
context.push({ messageID: item.messageID, partID: item.partID })
|
||||
return
|
||||
}
|
||||
@@ -460,6 +464,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", () => {
|
||||
@@ -262,7 +262,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 +276,89 @@ 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("places a divider after interrupted output unless the turn compacts", () => {
|
||||
const messages = [
|
||||
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
|
||||
import type { SessionUserActions, SessionUserComment } from "../actions"
|
||||
import {
|
||||
MessageDivider,
|
||||
@@ -152,7 +152,17 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
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 }
|
||||
@@ -272,22 +282,48 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
<Frame row={current()}>
|
||||
<Show when={content()}>
|
||||
{(content) => (
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Frame>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -768,14 +795,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 +940,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>
|
||||
}
|
||||
/>
|
||||
@@ -1112,6 +1150,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
rail={false}
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
@@ -1155,6 +1194,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
rail={false}
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
@@ -1271,6 +1311,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
@@ -1339,6 +1380,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
@@ -1422,6 +1464,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.patch"),
|
||||
@@ -1519,6 +1562,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
rail={false}
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
@@ -1643,34 +1687,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",
|
||||
@@ -188,6 +190,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",
|
||||
@@ -208,6 +212,7 @@ const source = {
|
||||
"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"/>`,
|
||||
|
||||
Reference in New Issue
Block a user