Compare commits

...
13 Commits
Author SHA1 Message Date
opencode-agent[bot]andBrendonovich ab95695b24 fix(app): combine loaded skill and instruction file entries (#45217)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 10:08:33 +00:00
Luke Parker 5d7c2ccfc0 feat(app): add experimental vertical session tabs (#45210) 2026-08-26 19:40:09 +10:00
Luke Parker 24ac05868c fix(desktop): align and theme Windows caption controls (#45208) 2026-08-26 09:02:10 +00:00
Shoubhit Dash 4eaf533cd0 feat(plugin): add declarative vcs repository markers (#45192) 2026-08-26 13:49:36 +05:30
Shoubhit Dash 0f7a76eff0 fix(ai): keep bedrock mantle responses on http (#45197) 2026-08-26 13:38:27 +05:30
Luke Parker 16d731bd67 feat(app): render settings as a fullscreen surface (#45190) 2026-08-26 07:57:36 +00:00
Luke Parker ea582fc133 feat(app): stack collapsed tool calls (#45176) 2026-08-26 07:56:53 +00:00
opencode-agent[bot]andBrendonovich 7e27e81bc7 fix(app): preserve timeline measurements when moving sessions (#45145)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 14:39:01 +08:00
opencode-agent[bot]andBrendonovich 667722897e fix(app): identify session worktrees from project inventory (#45153)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 14:38:08 +08:00
opencode-agent[bot]andBrendonovich d12dbd12a9 fix(app): prevent duplicate rows when editing queued prompts (#45169)l
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 06:21:44 +00:00
opencode-agent[bot]andBrendonovich b0bd0bc394 fix(app): align project settings server sections (#45163)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 04:42:26 +00:00
Dax 437df1164c feat(plugin): expose session move 2026-08-26 00:14:12 -04:00
opencode-agent[bot]andBrendonovich 9a91e21a76 fix(app): preserve workspace choices across draft tabs (#45149)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 03:51:14 +00:00
90 changed files with 2238 additions and 513 deletions
@@ -1,5 +1,5 @@
import { Auth } from "../route/auth.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
@@ -26,9 +26,15 @@ export interface Settings extends ProviderPackage.Settings {
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
})
const chatRoute = OpenAIChat.route.with({
@@ -38,7 +44,7 @@ const chatRoute = OpenAIChat.route.with({
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -19,6 +20,7 @@ describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -24,11 +24,17 @@ test("session settings use the remote server context", async ({ page }) => {
await configureServers(page)
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
const sessionHeading = page.getByRole("heading", { name: sessionB.title, exact: true, includeHidden: true })
await expect(sessionHeading).toBeVisible()
await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
@@ -55,9 +61,12 @@ test("session settings use the remote server context", async ({ page }) => {
},
])
await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("tab", { name: "Models" }).click()
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(sessionHeading).toBeVisible()
})
test("auto-accept responds for an unfocused server session", async ({ page }) => {
@@ -78,7 +87,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -178,7 +187,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
const first = await transport.waitForConnection()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
await expect
@@ -234,7 +243,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]')
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -2,7 +2,7 @@ import { expect, test, type Route } from "@playwright/test"
const server = "http://127.0.0.1:4097"
test("nested server dialog keeps focus inside the top layer", async ({ page }) => {
test("server dialog keeps focus above fullscreen settings", async ({ page }) => {
await page.addInitScript((server) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] }))
}, server)
@@ -24,8 +24,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) =
await page.goto("/")
await page.keyboard.press("Control+,")
const settings = page.locator(".settings-dialog")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await settings.getByRole("tab", { name: "Servers" }).click()
await settings.getByRole("button", { name: "Add server" }).click()
@@ -41,6 +42,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) =
await expect(password).toBeFocused()
await password.fill("secret")
await expect(password).toHaveValue("secret")
await page.keyboard.press("Escape")
await expect(editor).toBeHidden()
await expect(settings).toBeVisible()
})
function json(route: Route, body: unknown, status = 200) {
@@ -9,7 +9,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
const trigger = page.getByRole("button", { name: "Used Shell" })
await trigger.focus()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
@@ -40,7 +40,7 @@ test.describe("regression: session timeline context group resize", () => {
expect(samples.at(-1)?.expanded).toBe("true")
})
test("paints a stable exploring to explored transition", async ({ page }) => {
test("keeps a grouped tool summary stable as its calls complete", async ({ page }) => {
const events: OpenCodeEvent[] = []
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page, events, [
@@ -55,13 +55,12 @@ test.describe("regression: session timeline context group resize", () => {
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
await expectAppVisible(context)
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
status: {
selector: `${contextSelector} [data-component="tool-status-title"]`,
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
selector: `${contextSelector} [data-component="context-tool-group-trigger"]`,
},
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
following: {
@@ -89,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
await page.waitForTimeout(delay)
}
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
await expect(context.getByRole("button")).toHaveAccessibleName("Used Read, Glob, Grep, List")
await page.waitForTimeout(700)
const trace = await stopVisualProbe<keyof typeof regions>(page)
const labels = trace.samples
@@ -108,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
]),
)
expect(labels).toEqual(["Exploring", "Explored"])
expect(labels).toEqual(["Used Read, Glob, Grep, List"])
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
})
})
@@ -209,13 +208,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
const content: SessionMessageAssistant["content"] = target
? [
toolContent(
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
),
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
toolContent(
@@ -83,6 +83,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -26,7 +26,9 @@ for (const expanded of [false, true]) {
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used Shell" })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
@@ -1,12 +1,9 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
for (const profile of [
{ locale: "de", label: "Erkundung abgeschlossen" },
{ locale: "ar", label: "تم الاستكشاف" },
] as const) {
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
for (const locale of ["de", "ar"] as const) {
test(`projects localized tool names with an English fallback in ${locale}`, async ({ page }) => {
const ids = [`prt_locale_${locale}_01_read`, `prt_locale_${locale}_02_glob`]
await setupTimeline(page, {
messages: [
userMessage(),
@@ -15,11 +12,12 @@ for (const profile of [
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
]),
],
locale: profile.locale,
locale,
})
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -53,9 +53,14 @@ test.describe("session timeline projection", () => {
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
).toBeVisible()
const first = page.locator(
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
)
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"prt_webfetch",
"prt_websearch",
@@ -78,8 +83,7 @@ test.describe("session timeline projection", () => {
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit.locator('[data-component="apply-patch-tool"]')).toBeVisible()
await expect(edit.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(edit).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
@@ -87,6 +91,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -13,7 +13,7 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
@@ -25,9 +25,112 @@ test("groups singleton and separated context operations at correct boundaries",
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
const group = page.locator(
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
const parts = [
shell("prt_stack_shell_1", "completed", "first"),
toolPart("prt_stack_explore", "subagent", "completed", {
agent: "explore",
description: "Inspect the project",
prompt: "Explore the project",
}),
toolPart("prt_stack_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
shell("prt_stack_shell_2", "completed", "second"),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await summary.click()
await expect(summary).toHaveAttribute("aria-expanded", "true")
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveCount(4)
await expect(group.locator('[data-timeline-part-id="prt_stack_shell_1"]')).toBeVisible()
await expect(group.locator('[data-timeline-part-id="prt_stack_patch"]')).toBeVisible()
await expect(group.locator('[data-component="context-tool-group-list"]')).toHaveCSS("row-gap", "8px")
const content = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-content"]')
await expect(content).toHaveCSS("margin-left", "0px")
await expect(content).toHaveCSS("padding-left", "12px")
await expect.poll(() => content.evaluate((element) => getComputedStyle(element, "::before").content)).toBe("none")
})
test("leaves tools expanded by settings outside the collapsed stack", async ({ page }) => {
const parts = [
shell("prt_expanded_shell", "completed", "expanded"),
toolPart("prt_collapsed_patch", "patch", "completed", { patchText: "Update src/value.ts" }),
toolPart("prt_collapsed_read", "read", "completed", { path: "src/value.ts" }),
]
await setupTimeline(page, {
messages: [userMessage(), assistantMessage(parts)],
settings: { shellToolPartsExpanded: true },
})
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
await expect(group.getByRole("button", { name: "Used Patch, Read" })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
})
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
"glob",
"error",
{ path: "C:/Users", pattern: "*.ts" },
{
error: "Invalid tool input",
},
),
toolPart(
"prt_error_grep",
"grep",
"error",
{ path: "C:/Users", pattern: "value" },
{
error: "Search timed out after 30 seconds",
},
),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used Glob, Grep" })
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-circle-exclamation",
)
await expect
.poll(() =>
glob
.locator('[data-kind="tool-error-card"]')
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -21,6 +21,9 @@ test("renders every tool error outcome without leaking hidden tools", async ({ p
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
await group.getByRole("button").click()
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
@@ -33,6 +36,7 @@ test("transitions shell and question through running error outcomes", async ({ p
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -44,7 +48,6 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
await timeline.waitForPart(shellID)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
@@ -68,6 +71,7 @@ test("preserves surviving grouped patch state when its first patch fails", async
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -147,10 +151,12 @@ test("labels all web search provider variants", async ({ page }) => {
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
const tools = page.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels completed searches with result counts", async ({ page }) => {
@@ -188,6 +194,33 @@ test("labels read tools from their path input", async ({ page }) => {
).toContainText("a.ts")
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
id,
"read",
"completed",
{ path: "src/a.ts" },
{ metadata: { loaded: ["AGENTS.md", "packages/app/AGENTS.md", "packages/ui/AGENTS.md"] } },
),
]),
],
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = tool.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded AGENTS.md, packages/app/AGENTS.md, packages/ui/AGENTS.md")
await expect(loaded.locator('[data-slot="tool-loaded-value"]')).toHaveText(
"AGENTS.md, packages/app/AGENTS.md, packages/ui/AGENTS.md",
)
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
@@ -201,18 +234,40 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
for (const [id, name] of [
[pending, "frontend-design"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
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)
}
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
test("groups only consecutive successful skill tools", async ({ page }) => {
const parts = [
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
toolPart("prt_skill_second", "skill", "completed", { id: "effect" }),
toolPart("prt_skill_third", "skill", "completed", { id: "ui-pr-screenshots" }),
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(2)
await expect(loaded.nth(0)).toHaveAttribute("aria-label", "Loaded ocpr, effect, ui-pr-screenshots skills")
await expect(loaded.nth(1)).toHaveAttribute("aria-label", "Loaded opencode skill")
})
function questionInput() {
@@ -42,8 +42,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
url.pathname === `/api/session/${childID}/message` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
@@ -53,6 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,6 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -194,6 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -103,6 +103,127 @@ test("cramped tabs only show the close button for the active tab", async ({ page
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
test("vertical tabs show project details, resize, and navigate", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
const tabA = sidebar.locator(`[data-titlebar-tab-link][href="${hrefA}"]`)
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(sidebar).toHaveCSS("width", "260px")
await expect(tabA).toContainText(sessionA.title)
await expect(tabB).toContainText(sessionB.title)
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const handle = sidebar.locator('[data-component="resize-handle"]')
await expect(handle).toHaveCSS("cursor", "col-resize")
const box = await handle.boundingBox()
if (!box) throw new Error("vertical tab resize handle has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 - 80, box.y + box.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "180px")
await expect(tabB.locator('[data-slot="tab-project"]')).toHaveText("tab-project")
const resized = await handle.boundingBox()
if (!resized) throw new Error("resized vertical tab handle has no bounding box")
await page.mouse.move(resized.x + resized.width / 2, resized.y + resized.height / 2)
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
test("appearance experimental setting switches tab orientation", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionA }]),
)
},
{ server, sessionA: sessionA.id },
)
await page.goto("/")
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await settings.getByRole("tab", { name: "Appearance" }).click()
await expect(settings.getByRole("heading", { name: "Experimental" })).toBeVisible()
const layout = settings.locator('[data-action="settings-tab-layout"]')
await expect(layout).toContainText("Horizontal")
await layout.click()
await page.getByRole("option", { name: "Vertical" }).click()
await expect(layout).toContainText("Vertical")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
await page.setViewportSize({ width: 800, height: 720 })
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
})
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionA }]),
)
},
{ server, sessionA: sessionA.id },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator('[data-slot="titlebar-tabs"]')
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
await page.setViewportSize({ width: 1280, height: 720 })
await expect(
page.locator('[data-slot="vertical-tabs-sidebar"]').locator(`[data-titlebar-tab-link][href="${href}"]`),
).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
})
function session(id: string, title: string) {
return {
id,
+2 -2
View File
@@ -58,7 +58,7 @@ export function AppBaseProviders(
props: ParentProps<{
locale?: Locale
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
onThemeApplied?: () => void
onThemeApplied?: (mode: "light" | "dark", scheme: "system" | "light" | "dark") => void
}>,
) {
return (
@@ -67,7 +67,7 @@ export function AppBaseProviders(
<ThemeProvider
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
props.onThemeApplied?.()
props.onThemeApplied?.(mode, scheme)
}}
>
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
@@ -133,7 +133,7 @@ export function createNewSessionComposerAdapter(props: {
return {
adapter,
project: createComposerProjectControls({ draftId: props.draftID }),
project: createComposerProjectControls({ draftId: props.draftID, worktree: props.worktree }),
model,
ready: prompt.ready,
}
@@ -4,10 +4,11 @@ import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { useServerSDK } from "@/runtime/server/client"
import { serverName, ServerConnection, useServers } from "@/runtime/server/registry"
import { useWorkspaceLocation } from "@/workspaces/location"
import { workspaceSelectionDestination } from "@/workspaces/paths"
import { useTabs } from "@/shell/tabs/tabs"
import type { PromptProjectControls } from "./selector"
export function createComposerProjectControls(props: { draftId: string }) {
export function createComposerProjectControls(props: { draftId: string; worktree: () => string }) {
const servers = useServers()
const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
@@ -38,7 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
tabs.updateDraft(props.draftId, {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
worktree: workspaceSelectionDestination(props.worktree(), location().directory),
branch: undefined,
})
}
@@ -5,6 +5,8 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import { useTabs } from "@/shell/tabs/tabs"
import { ServerConnection } from "@/runtime/server/registry"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
@@ -12,6 +14,7 @@ import {
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
export function resolveNewSessionWorktree(input: {
@@ -57,6 +60,7 @@ export function createNewSessionWorkspaceController(input: {
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
@@ -121,7 +125,8 @@ export function createNewSessionWorkspaceController(input: {
const remember = (worktree = value()) => {
const project = currentProject()
if (!project) return
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
tabs.initializeDraftWorktrees(ServerConnection.key(serverSDK.server), sdk().directory, fallback())
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
@@ -141,6 +146,7 @@ export function createNewSessionWorkspaceController(input: {
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
remember(worktree)
},
create: (branch: string) => {
input.setSelectedBranch(branch)
@@ -160,7 +166,10 @@ export function createNewSessionWorkspaceController(input: {
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
...new Set([
...list,
...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : []),
]),
].slice(0, 50)
},
searchBranches,
+6
View File
@@ -884,6 +884,7 @@ export const dict = {
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
"settings.backToApp": "Back to app",
"settings.tab.general": "General",
"settings.tab.preferences": "Preferences",
"settings.tab.shortcuts": "Shortcuts",
@@ -892,6 +893,11 @@ export const dict = {
"settings.tab.extensions": "Extensions",
"settings.preferences.description": "Customize preferences and theme and default behavior",
"settings.appearance.description": "Customize theme and fonts",
"settings.appearance.section.experimental": "Experimental",
"settings.appearance.row.tabs.title": "Tabs",
"settings.appearance.row.tabs.description": "Choose how session tabs are arranged",
"settings.appearance.row.tabs.horizontal": "Horizontal",
"settings.appearance.row.tabs.vertical": "Vertical",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { queuedPromptRows } from "./queue"
const queued = [
{
id: "msg_original",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
delivery: "queue",
payload: { text: "original" },
},
{
id: "msg_replacement",
sessionID: "ses_1",
timeCreated: 2,
type: "user",
delivery: "queue",
payload: { text: "edited" },
},
] satisfies SessionInboxInfo[]
describe("queuedPromptRows", () => {
test("keeps the edited prompt to one row while its replacement is admitted", () => {
expect(queuedPromptRows(queued, { original: "msg_original", replacement: "msg_replacement" })).toEqual([
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
test("keeps the original visible until its replacement appears", () => {
expect(queuedPromptRows([queued[0]], { original: "msg_original", replacement: "msg_replacement" })).toEqual([
{ id: "msg_original", text: "original", attachments: false },
])
})
test("retains unrelated queue entries", () => {
expect(queuedPromptRows(queued)).toEqual([
{ id: "msg_original", text: "original", attachments: false },
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
test("keeps other prompts visible while a mutation replaces the edited prompt", () => {
const other = { ...queued[0], id: "msg_other", payload: { text: "other" } }
expect(
queuedPromptRows([queued[0], other, queued[1]], { original: "msg_original", replacement: "msg_replacement" }),
).toEqual([
{ id: "msg_other", text: "other", attachments: false },
{ id: "msg_replacement", text: "edited", attachments: false },
])
})
})
+75 -37
View File
@@ -1,6 +1,8 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
@@ -34,39 +36,67 @@ export function createSessionQueue(input: {
const server = useServerSDK()
const location = useWorkspaceLocation()
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash } }>({})
const notify = () => showToast({ title: language.t("common.requestFailed") })
const mutation = useMutation(() => ({
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| {
type: "edit"
inboxIDs: string[]
original: string
replacement: string
item: QueuedPrompt | undefined
prompt: Prompt
text: string
delivery: ComposerDelivery
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
change.item,
change.prompt,
change.text,
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
id: change.replacement,
delivery: change.delivery,
...(change.delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
cancelEdit()
if (change.delivery === "queue")
await rewrite(change.inboxIDs.map((id) => (id === change.original ? admitted.id : id)))
},
onError: notify,
onSettled: () => data.session.pending.sync(input.sessionID).catch(() => undefined),
}))
const queued = createMemo(() =>
data.session.pending
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() =>
queued().map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
})),
)
const rows = createMemo(() => {
const replacement = mutation.isPending ? mutation.variables : undefined
return queuedPromptRows(
queued(),
replacement?.type === "edit" && replacement.delivery === "queue" ? replacement : undefined,
)
})
createEffect(() => {
const editing = state.editing
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
if (!editing || mutation.isPending || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
const notify = () => showToast({ title: language.t("common.requestFailed") })
const run = (work: () => Promise<unknown>) => {
setState("busy", true)
return work()
.catch(() => notify())
.finally(async () => {
await data.session.pending.sync(input.sessionID).catch(() => undefined)
setState("busy", false)
})
}
const rewrite = async (inboxIDs: string[]) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
@@ -108,12 +138,12 @@ export function createSessionQueue(input: {
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const reorder = (inboxIDs: string[]) => {
if (state.busy) return Promise.resolve()
return run(() => rewrite(inboxIDs))
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
}
const edit = (id: string) => {
if (state.busy) return false
if (mutation.isPending) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
@@ -147,25 +177,22 @@ export function createSessionQueue(input: {
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || state.busy) return
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
if (pristine && delivery === "queue") return cancelEdit()
const inboxIDs = queued().map((entry) => entry.id)
void run(async () => {
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
delivery,
...(delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
cancelEdit()
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
mutation.mutate({
type: "edit",
inboxIDs: queued().map((entry) => entry.id),
original: editing.id,
replacement: SessionMessage.ID.create(),
item,
prompt,
text,
delivery,
})
}
const editFirst = () => {
@@ -187,7 +214,7 @@ export function createSessionQueue(input: {
cancelEdit,
editFirst,
rows,
busy: () => state.busy,
busy: () => mutation.isPending,
working: input.working,
steer,
remove,
@@ -204,6 +231,17 @@ export type SessionQueueView = Pick<
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
const replaced = replacement && items.some((item) => item.id === replacement.replacement)
return items
.filter((item) => !replaced || item.id !== replacement.original)
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
}))
}
export function queuedPromptText(item: QueuedPrompt) {
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
@@ -105,6 +105,8 @@ export function createTimelineController(input: { session: TimelineSessionSource
sessionMessages: projectedMessages,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolDefaultOpen: settings.general.shellToolPartsExpanded,
editToolDefaultOpen: settings.general.editToolPartsExpanded,
pendingUserMessageIDs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -342,12 +342,11 @@ function MessageTimelineView(
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const project = createMemo(() => {
const projectID = props.session.data.info()?.projectID
const value = projectID
? data.project.get(projectID)
: data.project.list().find((item) => containsDirectory(item.canonical, sessionDirectory()))
if (!value) return undefined
return { ...value, worktree: value.canonical, worktrees: [] }
const session = props.session.data.info()
const projects = server.ctx.sync.data.project
return session
? projectForSession(session, projects)
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
@@ -388,7 +387,7 @@ function MessageTimelineView(
const pinned = createMemo(() => props.pinned)
const messageByID = projection.messageByID
const virtualized = createTimelineVirtualizer({
sessionKey: props.data.sessionKey,
sessionKey: () => `${server.key}/${props.data.sessionID()}`,
projection,
showHeader,
pinned,
@@ -8,6 +8,8 @@ export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
@@ -81,6 +83,8 @@ export function createTimelineProjection(input: {
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs(),
input.shellToolDefaultOpen(),
input.editToolDefaultOpen(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
@@ -9,6 +9,7 @@ import { createAppearanceSettingsController, type AppearanceSettingsController }
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const tabLayoutOptions: ("horizontal" | "vertical")[] = ["horizontal", "vertical"]
const fontSettings = {
ui: {
action: "settings-ui-font",
@@ -126,6 +127,30 @@ export const SettingsAppearance: Component = () => {
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsList>
</div>
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.appearance.section.experimental")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.appearance.row.tabs.title")}
description={language.t("settings.appearance.row.tabs.description")}
>
<Select
data-action="settings-tab-layout"
options={tabLayoutOptions}
current={tabLayoutOptions.find((option) => option === appearance.tabs.current())}
placement="bottom-end"
gutter={6}
label={(option) =>
option === "horizontal"
? language.t("settings.appearance.row.tabs.horizontal")
: language.t("settings.appearance.row.tabs.vertical")
}
onSelect={(option) => option && appearance.tabs.select(option)}
/>
</SettingsRow>
</SettingsList>
</div>
</div>
</>
)
+3 -17
View File
@@ -1,24 +1,10 @@
import { onCleanup } from "solid-js"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSettingsSurface } from "./surface"
export function useSettingsDialog(defaultValue?: string) {
const dialog = useDialog()
let run = 0
let dead = false
onCleanup(() => {
dead = true
})
return () => {
const current = ++run
void import("@/settings/shell").then((module) => {
if (dead || run !== current) return
void dialog.show(() => <module.DialogSettings defaultValue={defaultValue} />)
})
}
const settings = useSettingsSurface()
return () => settings.open(defaultValue)
}
export function useSettingsCommand() {
@@ -81,6 +81,10 @@ export function createAppearanceSettingsController() {
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
tabs: {
current: settings.appearance.tabLayout,
select: settings.appearance.setTabLayout,
},
}
}
+7
View File
@@ -8,6 +8,7 @@ export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export type TabLayout = "horizontal" | "vertical"
export interface NotificationSettings {
agent: boolean
@@ -47,6 +48,7 @@ export interface Settings {
mono: string
sans: string
terminal: string
tabLayout: TabLayout
}
keybinds: Record<string, string>
permissions: {
@@ -135,6 +137,7 @@ const defaultSettings: Settings = {
mono: "",
sans: "",
terminal: "",
tabLayout: "horizontal",
},
keybinds: {},
permissions: {
@@ -287,6 +290,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTerminalFont(value: string) {
setStore("appearance", "terminal", value.trim() ? value : "")
},
tabLayout: withFallback(() => store.appearance?.tabLayout, defaultSettings.appearance.tabLayout),
setTabLayout(value: TabLayout) {
setStore("appearance", "tabLayout", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
+209 -14
View File
@@ -5,6 +5,90 @@
height: 100%;
}
.settings-screen {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
justify-content: center;
overflow: hidden;
background: var(--v2-background-bg-deep);
outline: none;
container: settings-screen / inline-size;
}
.settings-screen > .settings {
display: flex;
width: min(100%, 1048px);
min-width: 0;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 328px;
min-width: 328px;
padding-block: 48px;
padding-inline-start: 24px;
padding-inline-end: 104px;
border-inline-end: none;
background: transparent;
}
.settings-screen > .settings > .settings-panel {
flex: 1;
min-width: 0;
max-width: 720px;
}
.settings-screen .settings-tab-header {
padding: 48px 0 32px;
background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent);
}
.settings-screen .settings-tab-body {
padding-inline: 0;
}
.settings-nav {
display: flex;
width: 200px;
flex-direction: column;
gap: 16px;
}
.settings-back {
display: flex;
height: 28px;
align-items: center;
gap: 6px;
padding-inline: 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-muted);
font: inherit;
line-height: var(--line-height-compact);
text-align: start;
cursor: pointer;
}
.settings-back:hover,
.settings-back:focus-visible {
background: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
}
.settings-back:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
[dir="rtl"] .settings-back-icon {
transform: scaleX(-1);
}
[data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] {
background: var(--v2-background-bg-base);
}
@@ -21,6 +105,7 @@
overflow-y: auto;
scrollbar-width: none;
user-select: none;
container: settings-panel / inline-size;
}
.settings-panel :is(input, textarea, [contenteditable="true"]) {
@@ -172,12 +257,16 @@
width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] {
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2-root"] {
width: fit-content;
max-width: 100%;
}
[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] {
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2"] {
max-width: 100%;
}
:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="button-v2"] {
width: fit-content;
max-width: 100%;
}
@@ -187,27 +276,93 @@
}
@media (max-width: 639px) {
.settings-screen .settings-nav {
width: 100%;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
}
.settings-screen .settings-tab-header {
padding-top: 24px;
}
.settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 144px;
min-width: 144px;
padding-inline: 8px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
}
}
.settings-nav-footer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
@container settings-screen (min-width: 800px) and (max-width: 1047px) {
.settings-screen > .settings {
padding-inline: 24px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 240px;
min-width: 240px;
padding-inline-start: 16px;
padding-inline-end: 24px;
}
}
.settings-nav-footer > span {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
@container settings-screen (max-width: 799px) {
.settings-screen .settings-nav {
width: 100%;
}
.settings-screen > .settings > .settings-panel {
padding-inline-end: 16px;
}
.settings-screen .settings-tab-header {
padding-top: 24px;
}
.settings-screen
> .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
> [data-slot="tabs-v2-list"] {
width: 160px;
min-width: 160px;
padding-block: 24px;
padding-inline: 12px;
}
}
@container settings-panel (max-width: 520px) {
.settings-tab-header-row {
flex-wrap: wrap;
}
.settings-tab-header-row [data-component="select-v2-root"] {
width: 100%;
}
.settings-tab-header-row [data-component="select-v2"] {
width: 100%;
}
[data-component="settings-row"] {
flex-wrap: wrap;
}
[data-slot="settings-row-control"] {
width: 100%;
}
}
.settings-provider-row {
@@ -230,6 +385,12 @@
}
}
@container settings-panel (max-width: 520px) {
.settings-provider-row {
flex-wrap: wrap;
}
}
.settings-providers [data-component="provider-icon"] {
color: var(--v2-icon-icon-base);
}
@@ -904,6 +1065,40 @@
}
}
@container settings-panel (max-width: 520px) {
.settings-workspaces-toolbar,
.settings-workspaces-main {
align-items: flex-start;
}
.settings-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
.settings-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -949,7 +1144,7 @@
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: 280px;
width: min(280px, 100%);
padding-inline: 0 !important;
}
+35 -14
View File
@@ -1,9 +1,7 @@
import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsGeneral } from "./general/general"
import { SettingsAppearance } from "./appearance/appearance"
import { SettingsKeybinds } from "./keybinds/keybinds"
@@ -20,19 +18,32 @@ import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useCommand } from "@/shell/commands/command"
import { useSettingsSurface } from "./surface"
import "@/settings/settings.css"
export const DialogSettings: Component<{
export const SettingsScreen: Component<{
defaultValue?: string
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const command = useCommand()
const surface = useSettingsSurface()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
let root: HTMLDivElement | undefined
onMount(() => {
command.keybinds(false)
root?.focus({ preventScroll: true })
})
onCleanup(() => command.keybinds(true))
createEffect(() => setTab(props.defaultValue ?? "general"))
const server = createMemo(() => {
const route = layout.route()
switch (route.type) {
@@ -67,11 +78,22 @@ export const DialogSettings: Component<{
})
const showProviders = () => {
void dialog.show(() => <DialogSettings defaultValue="providers" />)
dialog.close()
setTab("providers")
}
return (
<Dialog size="x-large" variant="settings" class="settings-dialog">
<div
ref={root}
data-testid="settings-screen"
class="settings-screen"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
event.preventDefault()
surface.close()
}}
>
<Tabs
orientation="vertical"
variant="settings"
@@ -80,7 +102,11 @@ export const DialogSettings: Component<{
class="settings"
>
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full">
<div class="settings-nav">
<button type="button" class="settings-back" onClick={surface.close}>
<Icon name="arrow-left" size="small" class="settings-back-icon" />
<span>{language.t("settings.backToApp")}</span>
</button>
<div class="flex flex-col gap-4 w-full">
{/* Group 1: Preferences */}
<div class="flex flex-col gap-1 w-full">
@@ -134,11 +160,6 @@ export const DialogSettings: Component<{
</Tabs.Trigger>
</div>
</div>
<div class="settings-nav-footer">
<span>{language.t("app.name.desktop")}</span>
<span>v{platform.version}</span>
</div>
</div>
</Tabs.List>
@@ -175,6 +196,6 @@ export const DialogSettings: Component<{
</Tabs.Content>
</SettingsServerScope>
</Tabs>
</Dialog>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useLocation } from "@solidjs/router"
import { createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
name: "SettingsSurface",
gate: false,
init: () => {
const location = useLocation()
const [store, setStore] = createStore({ open: false, tab: "general" })
let focus: HTMLElement | undefined
const close = () => {
if (!store.open) return
setStore("open", false)
if (focus?.isConnected) focus.focus({ preventScroll: true })
focus = undefined
}
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
return {
store,
open(tab = "general") {
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
setStore({ open: true, tab })
},
close,
}
},
})
@@ -17,6 +17,7 @@ export const SettingsProjects: Component = () => {
const global = useGlobal()
const [allServers, setAllServers] = createSignal(true)
const selected = global.settings.server.selected
const multiple = createMemo(() => global.servers.list().length > 1)
const projects = createMemo(() => {
const server = selected()
if (!server) return []
@@ -41,7 +42,7 @@ export const SettingsProjects: Component = () => {
const name = () => displayName(props.project)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
@@ -72,14 +73,16 @@ export const SettingsProjects: Component = () => {
<h2 class="settings-tab-title">{language.t("settings.projects.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.projects.description")}</span>
</div>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
<Show when={multiple()}>
<InlineServerSelect
all={{
label: language.t("settings.projects.server.all"),
selected: allServers,
onSelect: () => setAllServers(true),
}}
onServerSelect={() => setAllServers(false)}
/>
</Show>
</div>
</div>
@@ -98,7 +101,14 @@ export const SettingsProjects: Component = () => {
>
<Show when={selected()} keyed>
{(server) => (
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
<div class="settings-section">
<Show when={multiple()}>
<h3 class="settings-section-title">{serverName(server) || ServerConnection.key(server)}</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
</div>
</div>
)}
</Show>
</Show>
@@ -117,9 +127,11 @@ export const SettingsProjects: Component = () => {
<For each={groups()}>
{(group) => (
<div class="settings-section">
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
<Show when={multiple()}>
<h3 class="settings-section-title">
{serverName(group.server) || ServerConnection.key(group.server)}
</h3>
</Show>
<div class="flex flex-col gap-2 w-full">
<For each={group.projects}>
{(project) => <ProjectRow project={project} server={group.server} />}
+1 -1
View File
@@ -278,7 +278,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
return (
<div
class="relative flex-1 h-screen w-screen min-h-0 overflow-y-auto flex flex-col items-center justify-start sm:justify-center p-4 sm:p-8 font-sans"
class="relative flex-1 h-full w-full min-h-0 min-w-0 overflow-y-auto flex flex-col items-center justify-start sm:justify-center p-4 sm:p-8 font-sans"
data-tauri-drag-region
>
<div class="w-full max-w-3xl flex flex-col items-center justify-center gap-6 sm:gap-8 my-auto">
@@ -12,6 +12,7 @@ import {
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "./helpers"
@@ -167,6 +168,27 @@ describe("layout workspace helpers", () => {
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
test("keeps the enriched workspace inventory when matching a session by project id", () => {
const project = {
id: "project",
worktree: "/repo",
sandboxes: ["/workspaces/feature"],
worktrees: [{ directory: "/repo" }, { directory: "/workspaces/feature", strategy: "git" }],
}
expect(projectForSession(session({ id: "feature", directory: "/workspaces/feature/packages/app" }), [project])).toBe(
project,
)
})
test("finds the enriched project for a nested workspace when its session project id is stale", () => {
const project = { id: "updated", worktree: "/repo", sandboxes: ["/workspaces/feature"] }
expect(projectForSession(session({ id: "feature", directory: "/workspaces/feature/packages/app" }), [project])).toBe(
project,
)
})
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+2 -5
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory } from "@/workspaces/paths"
import type { ServerConnection } from "@/runtime/server/registry"
import type { HomeProjectSelection } from "@/shell/state/layout"
@@ -100,11 +101,7 @@ export function projectForSession<T extends { id?: string; worktree: string; san
) {
const direct = byID.get(session.projectID)
if (direct) return direct
const directory = pathKey(session.location.directory)
return projects.find(
(project) =>
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
return projects.find((project) => isProjectDirectory(project, session.location.directory))
}
export const errorMessage = (err: unknown, fallback: string) => {
+4 -1
View File
@@ -6,6 +6,7 @@ import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
@@ -69,7 +70,9 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
<Shell>{props.children}</Shell>
<SettingsSurfaceProvider>
<Shell>{props.children}</Shell>
</SettingsSurfaceProvider>
</LayoutProvider>
)
}
+49 -4
View File
@@ -1,15 +1,28 @@
import { lazy, Show, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
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"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
export default function Layout(props: ParentProps) {
const platform = usePlatform()
const [state, setState] = createStore({ debugTools: false })
const settings = useSettingsSurface()
const preferences = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
tabsWidth: 260,
tabsMount: undefined as HTMLElement | undefined,
})
const verticalTabs = () => preferences.appearance.tabLayout() === "vertical" && !mobile()
const update: TitlebarUpdate = {
get version() {
@@ -34,15 +47,47 @@ export default function Layout(props: ParentProps) {
>
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
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>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
<aside
ref={(element) => setState("tabsMount", element)}
data-slot="vertical-tabs-sidebar"
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
style={{ width: `${state.tabsWidth}px` }}
>
<ResizeHandle
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
</aside>
</Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<div
class="flex size-full min-h-0 min-w-0 flex-col"
hidden={settings.store.open}
inert={settings.store.open}
aria-hidden={settings.store.open}
>
<Suspense>{props.children}</Suspense>
</div>
<Show when={settings.store.open}>
<Suspense>
<SettingsScreen defaultValue={settings.store.tab} />
</Suspense>
</Show>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
+8
View File
@@ -245,6 +245,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
})
},
initializeDraftWorktrees(server: ServerConnection.Key, directory: string, worktree: string) {
setStore(
(tab) => tab.type === "draft" && tab.server === server && tab.directory === directory && !tab.worktree,
produce((tab) => {
if (tab.type === "draft") tab.worktree = worktree
}),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// Keep the replacement and navigation atomic so /new-session never renders
// after its backing draft tab has been removed from the store.
+55 -4
View File
@@ -15,6 +15,48 @@
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) {
height: 45px;
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) [data-slot="tab-link"] {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
grid-template-rows: repeat(2, var(--line-height-compact));
align-content: center;
column-gap: 6px;
row-gap: 0;
font-size: 13px;
line-height: var(--line-height-compact);
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="project-avatar-slot"] {
grid-column: 1;
grid-row: 1;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-title"] {
grid-column: 2;
grid-row: 1;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-project"] {
grid-column: 2;
grid-row: 2;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: var(--line-height-compact);
white-space: nowrap;
}
[data-titlebar-tab][data-orientation="vertical"] [data-slot="tab-close"] {
top: 0;
bottom: 0;
margin-block: auto;
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
@@ -39,6 +81,10 @@
gap: 6px;
}
[data-titlebar-tab-list][data-orientation="vertical"] {
gap: 2px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
@@ -68,6 +114,11 @@
display: none;
}
[data-titlebar-tab-list][data-orientation="vertical"] [data-titlebar-tab-slot]::before,
[data-titlebar-tab-slot]:has([data-titlebar-tab][data-orientation="vertical"])::before {
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
@@ -133,21 +184,21 @@
}
@container (max-width: 64px) {
[data-titlebar-tab-link] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}
[data-titlebar-tab-title] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-titlebar-tab-title] {
display: none;
}
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
[data-titlebar-tab]:not([data-orientation="vertical"]):not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
[data-slot="tab-close"] {
[data-titlebar-tab]:not([data-orientation="vertical"]) [data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
@@ -33,6 +33,7 @@ export function TabNavItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
orientation?: "horizontal" | "vertical"
}) {
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
@@ -180,6 +181,7 @@ export function TabNavItem(props: {
}}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-orientation={props.orientation ?? "horizontal"}
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
@@ -278,6 +280,13 @@ export function TabNavItem(props: {
event.preventDefault()
}}
/>
<Show when={props.orientation === "vertical" && projectName()}>
{(name) => (
<span data-slot="tab-project" dir="auto">
{name()}
</span>
)}
</Show>
</a>
<div data-slot="tab-close">
@@ -299,6 +308,7 @@ export function TabNavItem(props: {
return (
<TabPreviewPopover
trigger={tab}
orientation={props.orientation}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
@@ -325,6 +335,7 @@ export function DraftTabItem(props: {
dragging?: boolean
pressed?: boolean
hidden?: boolean
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const closeTab = (event: MouseEvent) => {
@@ -337,6 +348,7 @@ export function DraftTabItem(props: {
ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab
data-slot="titlebar-tab-item"
data-orientation={props.orientation ?? "horizontal"}
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
@@ -8,6 +8,7 @@
z-index: 50;
box-sizing: border-box;
width: 256px;
max-width: calc(100dvw - 24px);
display: flex;
flex-direction: column;
gap: 6px;
@@ -1,9 +1,10 @@
import { HoverCard } from "@kobalte/core/hover-card"
import { createSignal, Show, type JSXElement } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 2_000
const OPEN_DELAY = 750
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -24,7 +25,9 @@ export function TabPreviewPopover(props: {
open: boolean
onOpenChange: (open: boolean) => void
data: TabPreviewData
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
let triggerEl: HTMLDivElement | undefined
// When opened during a rapid tab-hopping streak, this preview appears and
// disappears instantly (no repeated enter/exit animation) — only the first,
@@ -50,7 +53,13 @@ export function TabPreviewPopover(props: {
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
ignoreSafeArea
placement="bottom-start"
placement={
props.orientation === "vertical"
? language.direction() === "rtl"
? "left-start"
: "right-start"
: "bottom-start"
}
gutter={6}
>
<HoverCard.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
+62 -20
View File
@@ -4,7 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToHorizontalAxis, RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/shell/tabs/tabs"
@@ -27,6 +27,7 @@ function SessionTabSlot(props: {
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
fallbackTitle?: string
onRename: (title: string) => Promise<void>
@@ -49,7 +50,12 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
<TabNavItem
ref={(el) => {
@@ -65,6 +71,7 @@ function SessionTabSlot(props: {
active={props.active}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
</div>
)
@@ -76,6 +83,7 @@ function SessionTabEntry(props: {
index: number
active: boolean
forceTruncate: boolean
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onNavigate: (element: HTMLDivElement) => void
@@ -160,6 +168,7 @@ function SessionTabEntry(props: {
index={props.index}
active={props.active}
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onRename={rename}
@@ -175,6 +184,7 @@ function DraftTabSlot(props: {
id: string
index: number
active: boolean
orientation: "horizontal" | "vertical"
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
@@ -195,7 +205,12 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
<DraftTabItem
ref={(el) => {
@@ -207,12 +222,14 @@ function DraftTabSlot(props: {
onClose={props.onClose}
active={props.active}
dragging={sortable.isDragSource()}
orientation={props.orientation}
/>
</div>
)
}
export function TitlebarTabStrip(props: {
orientation?: "horizontal" | "vertical"
tabs: Tab[]
currentTab: Tab | undefined
forceTruncate: boolean
@@ -224,6 +241,7 @@ export function TitlebarTabStrip(props: {
const global = useGlobal()
const language = useLanguage()
const command = useCommand()
const vertical = () => props.orientation === "vertical"
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
@@ -259,7 +277,9 @@ export function TitlebarTabStrip(props: {
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
props.onOverflowChange(
vertical() ? scrollRef.scrollHeight > scrollRef.clientHeight : scrollRef.scrollWidth > scrollRef.clientWidth,
)
}
createResizeObserver(
@@ -288,10 +308,19 @@ export function TitlebarTabStrip(props: {
})
return (
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot={vertical() ? "vertical-tabs" : "titlebar-tabs"}
data-orientation={vertical() ? "vertical" : "horizontal"}
class="relative min-w-0"
classList={{ "min-h-0 overflow-hidden": vertical() }}
>
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
data-slot={vertical() ? "vertical-tabs-scroll" : "titlebar-tabs-scroll"}
class="flex min-w-0 no-scrollbar [app-region:no-drag]"
classList={{
"flex-row items-center gap-1.5 overflow-x-auto": !vertical(),
"max-h-full flex-col overflow-y-auto overflow-x-hidden": vertical(),
}}
ref={scrollRef}
>
<DragDropProvider
@@ -304,10 +333,13 @@ export function TitlebarTabStrip(props: {
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
modifiers={[
vertical() ? RestrictToVerticalAxis : RestrictToHorizontalAxis,
RestrictToElement.configure({ element: () => listRef }),
]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
AutoScroller.configure({ acceleration: 8, threshold: vertical() ? { x: 0, y: 0.05 } : { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
@@ -335,7 +367,13 @@ export function TitlebarTabStrip(props: {
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<div
data-titlebar-tab-list
data-orientation={vertical() ? "vertical" : "horizontal"}
class="flex w-full min-w-0"
classList={{ "flex-row items-center": !vertical(), "flex-col items-stretch": vertical() }}
ref={listRef}
>
<For each={props.tabs}>
{(tab) => {
const id = tabKey(tab)
@@ -355,6 +393,7 @@ export function TitlebarTabStrip(props: {
index={visibleIndex()}
active={props.currentTab === tab}
forceTruncate={props.forceTruncate}
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
onNavigate={(element) => {
@@ -372,6 +411,7 @@ export function TitlebarTabStrip(props: {
id={id}
index={visibleIndex()}
active={props.currentTab === tab}
orientation={vertical() ? "vertical" : "horizontal"}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
@@ -385,16 +425,18 @@ export function TitlebarTabStrip(props: {
</div>
</DragDropProvider>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
<Show when={!vertical()}>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</Show>
</div>
)
}
+80 -33
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useLocation, useNavigate } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -36,7 +37,11 @@ export type TitlebarUpdate = {
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
export function Titlebar(props: {
update?: TitlebarUpdate
debugTools?: { visible: boolean; toggle: () => void }
verticalTabs?: { mount?: HTMLElement }
}) {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
@@ -54,7 +59,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -324,7 +329,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
classList={{
"pt-2": !bottom(),
"pt-2": !bottom() && !windows(),
"pb-2": bottom(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
@@ -357,40 +362,82 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
/>
</Tooltip>
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<Show
when={props.verticalTabs}
fallback={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
<TitlebarTabStrip
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={tabsAreOverflowing()}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
{(vertical) => (
<Show when={vertical().mount} keyed>
{(mount) => (
<Portal mount={mount}>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
forceTruncate={false}
onOverflowChange={setTabsAreOverflowing}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<button
type="button"
data-action="vertical-tabs-new-session"
class="mt-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="plus" />
{language.t("command.session.new")}
</button>
</Portal>
)}
</Show>
)}
</Show>
<div class="flex-1" />
<TitlebarRight state={rightState()} />
</div>
+14
View File
@@ -8,6 +8,7 @@ import {
mergeWorkspaceSessionInventory,
sessionsForWorkspace,
workspaceInventory,
workspaceSelectionDestination,
} from "./paths"
describe("isWorkspaceDirectory", () => {
@@ -41,6 +42,19 @@ describe("isWorkspaceSelection", () => {
})
})
describe("workspaceSelectionDestination", () => {
test("preserves local intent for the main selection and project root", () => {
expect(workspaceSelectionDestination("main", "/repo")).toBe("main")
expect(workspaceSelectionDestination("/repo/", "/repo")).toBe("main")
expect(workspaceSelectionDestination("c:\\repo\\", "C:\\repo")).toBe("main")
})
test("preserves workspace intent without carrying project-specific paths", () => {
expect(workspaceSelectionDestination("create", "/repo")).toBe("create")
expect(workspaceSelectionDestination("/workspaces/feature", "/repo")).toBe("create")
})
})
test("groups and filters workspace inventory by project", () => {
const inventory = workspaceInventory([
{
+4
View File
@@ -106,6 +106,10 @@ export function isWorkspaceSelection(project: WorkspaceProject | undefined, sele
return isWorkspaceDirectory(project, selection)
}
export function workspaceSelectionDestination(selection: string, projectWorktree: string) {
return selection === "main" || sameDirectory(selection, projectWorktree) ? "main" : "create"
}
export function workspaceDefaultSelection(
setting: WorkspaceDefaultDestination,
lastUsed: WorkspaceLastUsed | undefined,
@@ -293,7 +293,7 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type ProjectVcs = "git" | "hg"
export type ProjectVcs = string
export type ProjectIcon = { url?: string; override?: string; color?: string }
+7 -68
View File
@@ -4,12 +4,13 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { PluginSourceDirectory } from "../../plugin/source-directory.js"
export type Operation =
| {
@@ -124,7 +125,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
(entry) =>
PluginSourceDirectory.discover(fs, entry.path).pipe(
Effect.map((targets) => targets.map((target): Operation => ({ type: "add", target, options: {} }))),
),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
@@ -153,75 +157,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
return Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
return Option.none<string>()
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
PluginSourceDirectory.names.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+7 -2
View File
@@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -64,6 +65,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
@@ -90,7 +92,10 @@ const layer = Layer.effect(
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
@@ -103,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+1
View File
@@ -442,6 +442,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
rename: runtime.session.rename,
move: runtime.session.move,
synthetic: runtime.session.synthetic,
interrupt: (input) =>
runtime.session
+1
View File
@@ -287,6 +287,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+62
View File
@@ -0,0 +1,62 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
})
+2
View File
@@ -20,6 +20,7 @@ export interface Interface {
| "generate"
| "command"
| "rename"
| "move"
| "resume"
| "switchAgent"
| "switchModel"
@@ -76,6 +77,7 @@ export const layerWithCell = (cell: Cell) =>
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
move: (input) => require(cell, (runtime) => runtime.session.move(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
@@ -0,0 +1,68 @@
export * as PluginSourceDirectory from "./source-directory.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Option, Predicate, Schema } from "effect"
import path from "path"
export const names = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
fs: FSUtil.Interface,
directory: string,
) {
const children = (yield* Effect.forEach(names, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) => entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) }))),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) =>
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* packageEntry(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
function packageEntry(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
+3 -53
View File
@@ -1,41 +1,19 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "../plugin.js"
import { PluginPromise } from "../plugin/promise.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
@@ -69,7 +47,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* load(operation).pipe(
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
@@ -102,34 +80,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
? entrypoint
: typeof Bun !== "undefined"
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+8 -1
View File
@@ -8,11 +8,18 @@ import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "../../vcs/patch.js"
import {
chunksByFile,
emptyPatch,
MAX_PATCH_BYTES,
MAX_TOTAL_PATCH_BYTES,
PATCH_CONTEXT_LINES,
} from "../../vcs/patch.js"
import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
+1
View File
@@ -23,6 +23,7 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+40 -13
View File
@@ -13,6 +13,7 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -42,11 +43,16 @@ export interface Resolved {
readonly directory: AbsolutePath
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
markers: readonly string[] = [".git", ".hg"],
) {
return yield* fs.up({ targets: [...markers], start: input, mode: "first" }).pipe(
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.orElseSucceed(() => undefined),
)
@@ -90,6 +96,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -157,11 +164,11 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const markers = yield* fs
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
const found = yield* fs
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!markers[0]) return false
return (yield* fs.resolve(path.dirname(markers[0]))) === directory
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
}),
)
yield* bus.publish(
@@ -305,15 +312,16 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
const marker = yield* markers.discover(directory)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
)
const repo =
marker && path.basename(marker) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(marker)))
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo) {
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -329,11 +337,30 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
@@ -349,5 +376,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
})
+176
View File
@@ -0,0 +1,176 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (directory: AbsolutePath) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
const operations = yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
+4 -10
View File
@@ -18,14 +18,8 @@ export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
Schema.Struct({
type: Schema.Literal("hg"),
store: AbsolutePath,
}),
])
export const Vcs = Schema.Struct({
type: Project.Vcs,
store: AbsolutePath,
})
export type Vcs = typeof Vcs.Type
+1 -1
View File
@@ -12,7 +12,7 @@ type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(),
vcs: text().$type<"git" | "hg">(),
vcs: text().$type<ProjectSchema.Vcs["type"]>(),
name: text(),
icon_url: text(),
icon_url_override: text(),
+1 -1
View File
@@ -73,7 +73,7 @@ const layer = Layer.effect(
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
const id = value.selection ?? location.vcsBackend ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+1
View File
@@ -140,6 +140,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
@@ -137,6 +137,7 @@ describe("fromPromise", () => {
switchAgent: (input) => Effect.sync(() => seen.push(input)),
switchModel: (input) => Effect.sync(() => seen.push(input)),
rename: (input) => Effect.sync(() => seen.push(input)),
move: (input) => Effect.sync(() => seen.push(input)),
wait: (input) => Effect.sync(() => seen.push(input)),
},
})
@@ -157,6 +158,9 @@ describe("fromPromise", () => {
}),
).toBeUndefined()
expect(await ctx.session.rename({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined()
expect(
await ctx.session.move({ sessionID: "ses_success", directory: "/destination", delivery: "queue" }),
).toBeUndefined()
expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
},
}),
@@ -169,6 +173,11 @@ describe("fromPromise", () => {
model: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") },
},
{ sessionID: Session.ID.make("ses_success"), title: "Renamed" },
{
sessionID: Session.ID.make("ses_success"),
directory: AbsolutePath.make("/destination"),
delivery: "queue",
},
{ sessionID: Session.ID.make("ses_success") },
])
}),
+100
View File
@@ -170,6 +170,106 @@ describe("Project.resolve", () => {
}),
)
it.live("discovers repository markers from automatically loaded plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await fs.mkdir(path.join(tmp.path, "nested", "directory"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "nested", "directory")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.canonical).toBe(abs(tmp.path))
expect(result.vcs).toEqual({ type: "svn", store: abs(path.join(tmp.path, ".svn")) })
expect(result.id).not.toBe(Project.ID.global)
expect((yield* project.list()).find((item) => item.id === result.id)?.vcs).toBe("svn")
}),
)
it.live("discovers repository markers from configured plugin files", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".pijul"))
await Bun.write(path.join(tmp.path, "opencode.jsonc"), '{ "plugins": ["./pijul.ts"] }')
await Bun.write(
path.join(tmp.path, "pijul.ts"),
'export default { id: "custom.pijul", vcs: { id: "pijul", markers: [".pijul"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.directory).toBe(abs(tmp.path))
expect(result.vcs?.type).toBe("pijul")
}),
)
it.live("prefers a nested plugin repository over its parent git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(nested, ".svn"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(nested))
expect(result.directory).toBe(abs(nested))
expect(result.vcs?.type).toBe("svn")
}),
)
it.live("preserves git identity when a plugin marker shares its repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "jj.ts"),
'export default { id: "jj", vcs: { markers: [".jj"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend).toBe("jj")
}),
)
it.live("repository markers override markerless directory projects", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
@@ -72,8 +72,10 @@ export function getBackgroundColor() {
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
// The macOS frame follows nativeTheme, not the renderer theme.
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
// Native window controls follow nativeTheme, not the renderer theme.
if (process.platform === "darwin" || process.platform === "win32") {
nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
}
updateTitlebar(win)
}
@@ -141,7 +141,10 @@ function DesktopWindow(props: {
<AppBaseProviders
locale={locale.latest}
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
onThemeApplied={() => void props.api.themeReady()}
onThemeApplied={(mode, scheme) => {
void props.api.setTitlebar({ mode, scheme })
void props.api.themeReady()
}}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
</AppBaseProviders>
+2
View File
@@ -1,6 +1,7 @@
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -45,6 +46,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+1
View File
@@ -57,6 +57,7 @@ export type SessionDomain = Pick<
| "synthetic"
| "interrupt"
| "rename"
| "move"
| "wait"
| "context"
> & {
+2
View File
@@ -69,6 +69,7 @@ export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
@@ -366,6 +367,7 @@ export function fromPromise(plugin: Plugin) {
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
},
+2
View File
@@ -1,5 +1,6 @@
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -46,6 +47,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
+1
View File
@@ -57,6 +57,7 @@ export type SessionDomain = Pick<
| "synthetic"
| "interrupt"
| "rename"
| "move"
| "wait"
| "context"
> & {
+4
View File
@@ -0,0 +1,4 @@
export interface VcsDiscovery {
readonly id?: string
readonly markers: readonly string[]
}
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
@@ -49,6 +50,13 @@ test.each([
])
})
test.each([
["effect", Plugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, effect: () => Effect.void })],
["promise", PromisePlugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, setup() {} })],
])("%s plugin definitions retain repository markers", (_name, plugin) => {
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+3 -1
View File
@@ -8,7 +8,9 @@ import { ProjectID } from "./project-id.js"
export const ID = ProjectID
export type ID = typeof ID.Type
export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Project.Vcs" })
export const Vcs = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9._-]*$/)).annotate({
identifier: "Project.Vcs",
})
export const Current = Schema.Struct({
id: ID,
directory: AbsolutePath,
@@ -284,19 +284,7 @@
[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));
}
padding-inline-start: 12px;
}
}
@@ -263,6 +263,10 @@
}
}
[data-timeline-row="AssistantPart"][data-timeline-spacing="content"] [data-component="text-part"] {
margin-top: 0;
}
[data-component="reasoning-part"] {
width: 100%;
color: var(--v2-text-text-muted);
@@ -663,28 +667,66 @@
cursor: default;
[data-slot="context-tool-group-title"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 1;
min-width: 0;
}
[data-slot="context-tool-group-prefix"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-title"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-component="tag"] {
flex-shrink: 0;
border: 0;
background: var(--v2-background-bg-layer-03);
}
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
cursor: pointer;
}
}
[data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 8px;
}
[data-component="context-tool-group-list"] {
/* The 28px compact trigger centers a 16px line box, already leaving 6px above this list. */
padding: 4px 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
/* 16px line boxes with 13px gaps reproduce the design's 29px row pitch for 13px solid rows. */
gap: 13px;
gap: 8px;
[data-slot="context-tool-group-item"] {
min-width: 0;
min-height: 28px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0;
opacity: 0.8;
> [data-component="tool-part-wrapper"] {
width: 100%;
}
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
height: 28px;
}
[data-slot="basic-tool-tool-info-structured"] {
gap: 6px;
@@ -4,7 +4,7 @@
--card-pad-r: 0px;
&::before {
border-radius: 0;
display: none;
}
/* Figma's leading-none would clip Inter descenders at 13px; keep the compact metric. */
@@ -97,8 +97,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
{/* 20px-viewBox path at 16px: 1.25 renders the 1px stroke Figma specifies. */}
<Icon name="circle-ban-sign" style={{ "stroke-width": 1.25 }} />
<Icon name="circle-exclamation" />
</span>
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
@@ -26,7 +26,8 @@ describe("current content default open", () => {
test("uses the file-change disclosure preference", () => {
expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true)
expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(false)
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
})
test("collapses errored tools regardless of disclosure preferences", () => {
@@ -38,7 +38,7 @@ export function currentContentDefaultOpen(
// Errored tools render the error card, which starts collapsed.
if (content.state.status === "error") return false
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name === "patch") return true
if (content.name === "patch") return editExpanded
if (content.name !== "edit" && content.name !== "write") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
+51 -11
View File
@@ -8,6 +8,7 @@ import type {
} from "@opencode-ai/client/promise"
import { Option, Schema } from "effect"
import { createMemo, type Accessor } from "solid-js"
import { currentContentDefaultOpen } from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
@@ -18,13 +19,14 @@ type Content = SessionMessageAssistant["content"][number]
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))
export type TimelineProjectionInput = {
sessionMessages: SessionMessageInfo[]
status: SessionStatus
showReasoningSummaries: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
pendingUserMessageIDs?: ReadonlySet<string>
previousRows?: TimelineRow.TimelineRow[]
}
@@ -36,6 +38,8 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
input.showReasoningSummaries,
input.status,
input.pendingUserMessageIDs,
input.shellToolDefaultOpen ?? false,
input.editToolDefaultOpen ?? false,
)
const rows = reuseTimelineRows(input.previousRows, projection.rows)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
@@ -67,6 +71,8 @@ export function createReactiveTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
shellToolDefaultOpen?: Accessor<boolean>
editToolDefaultOpen?: Accessor<boolean>
pendingUserMessageIDs?: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
@@ -80,6 +86,8 @@ export function createReactiveTimelineProjection(input: {
input.showReasoningSummaries(),
input.status(),
input.pendingUserMessageIDs?.(),
input.shellToolDefaultOpen?.() ?? false,
input.editToolDefaultOpen?.() ?? false,
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
@@ -128,6 +136,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus,
pendingUserMessageIDs?: ReadonlySet<string>,
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
) {
type Turn = {
id: string
@@ -204,6 +214,8 @@ export namespace Timeline {
showReasoning,
status,
turn.id === activeMessageID,
shellToolDefaultOpen,
editToolDefaultOpen,
)
}),
],
@@ -218,6 +230,8 @@ export namespace Timeline {
showReasoning: boolean,
status: SessionStatus,
isActive: boolean,
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
@@ -237,6 +251,7 @@ export namespace Timeline {
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
let assistantGroupIndex = 0
let previousAssistantTool = false
// An assistant message can produce several rows because its content parts are
// rendered separately. Notices end a segment so none of those rows cross it.
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
@@ -248,17 +263,23 @@ export namespace Timeline {
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
const before = interruptedAt < 0 ? refs : refs.filter((ref) => ref.messageIndex <= interruptedAt)
const after = interruptedAt < 0 ? [] : refs.filter((ref) => ref.messageIndex > interruptedAt)
const appendGroups = (items: typeof refs) =>
groupContent(items).forEach((group) => {
const appendGroups = (items: typeof refs) => {
let offset = 0
groupContent(items, shellToolDefaultOpen, editToolDefaultOpen).forEach((group) => {
const tool = group.type !== "part" || items[offset]?.content.type === "tool"
offset += group.type === "part" ? 1 : group.refs.length
rows.push(
new TimelineRow.AssistantPart({
userMessageID: turnID,
group,
previousAssistantPart: assistantGroupIndex > 0,
spacing: assistantGroupIndex > 0 ? (previousAssistantTool && tool ? "tool" : "content") : undefined,
}),
)
assistantGroupIndex += 1
previousAssistantTool = tool
})
}
appendGroups(before)
if (interruptedAt >= 0) {
@@ -444,6 +465,7 @@ function stabilizeGroupKey(
return new TimelineRow.AssistantPart({
userMessageID: row.userMessageID,
previousAssistantPart: row.previousAssistantPart,
spacing: row.spacing,
group: { ...row.group, key: existing.row.group.key },
})
}
@@ -462,7 +484,11 @@ function renderable(content: Content, showReasoning: boolean) {
return true
}
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
function groupContent(
items: { messageID: string; partID: string; content: Content }[],
shellToolDefaultOpen: boolean,
editToolDefaultOpen: boolean,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[] } | undefined
const flush = () => {
@@ -482,13 +508,7 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
items.forEach((item) => {
const type =
item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)
? "context"
: item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error"
? "patch"
: item.content.type === "tool" && item.content.name === "edit" && item.content.state.status !== "error"
? "edit"
: undefined
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
@@ -506,6 +526,26 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
return groups
}
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
if ((content.name === "edit" || content.name === "write" || content.name === "patch") && editExpanded)
return undefined
return "context"
}
if (
(content.state.status !== "completed" ||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
)
return undefined
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
return undefined
}
function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
if (content.name !== "read" || content.state.status !== "completed") return false
const loaded = content.state.metadata?.loaded
@@ -514,7 +514,9 @@ describe("current session timeline rows", () => {
assistant("msg_assistant_3", "grep"),
] satisfies SessionMessageInfo[]
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.map(TimelineRow.key)
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.map(
TimelineRow.key,
)
expect(keys).toEqual([
"user-message:msg_user",
@@ -595,7 +597,7 @@ describe("current session timeline rows", () => {
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, false, true)
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
expect(groups).toEqual([
@@ -628,6 +630,224 @@ describe("current session timeline rows", () => {
])
})
test("groups every consecutive collapsed tool in chronological order", () => {
const source = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
...["shell", "subagent", "patch", "shell", "edit", "write", "grep"].map(
(name, index): SessionMessageAssistantTool => ({
type: "tool" as const,
id: `tool_${index}`,
name,
state: {
status: "completed" as const,
input: {},
content: [{ type: "text" as const, text: "done" }],
metadata: {},
},
time: { created: index + 2, completed: index + 3 },
}),
),
{ type: "text" as const, text: "finished" },
{
type: "tool" as const,
id: "tool_after_text",
name: "shell",
state: {
status: "completed" as const,
input: {},
content: [{ type: "text" as const, text: "done" }],
metadata: {},
},
time: { created: 10, completed: 11 },
},
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group] : [],
)
expect(groups.map((group) => group.type)).toEqual(["context", "part", "context"])
expect(groups[0]?.type === "context" ? groups[0].refs.map((ref) => ref.partID) : []).toEqual([
"tool_0",
"tool_1",
"tool_2",
"tool_3",
"tool_4",
"tool_5",
"tool_6",
])
expect(groups[2]?.type === "context" ? groups[2].refs.map((ref) => ref.partID) : []).toEqual(["tool_after_text"])
})
test.each([
{ shell: false, edit: false, types: ["context"] },
{ shell: true, edit: false, types: ["part", "context"] },
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
const source = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: ["shell", "edit", "write", "patch", "read"].map(
(name, index): SessionMessageAssistantTool => ({
type: "tool" as const,
id: `tool_${name}`,
name,
state: {
status: "completed" as const,
input: {},
content: [{ type: "text" as const, text: "done" }],
metadata: {},
},
time: { created: index + 2, completed: index + 3 },
}),
),
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const rows = Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, shell, edit).rows
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("keeps active and background work visible outside collapsed stacks", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_running_shell",
name: "shell",
state: { status: "running", input: {}, metadata: {} },
time: { created: 2 },
},
{
type: "tool",
id: "tool_background_agent",
name: "subagent",
state: {
status: "completed",
input: {},
content: [{ type: "text", text: "running" }],
metadata: { status: "running" },
},
time: { created: 3, completed: 4 },
},
{
type: "tool",
id: "tool_completed_shell",
name: "shell",
state: {
status: "completed",
input: {},
content: [{ type: "text", text: "done" }],
metadata: {},
},
time: { created: 5, completed: 6 },
},
],
time: { created: 2 },
},
]
expect(
Timeline.constructSessionMessageRows(source, false, { type: "busy" }).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group.type] : [],
),
).toEqual(["part", "part", "context"])
})
test("keeps failed calls inside a collapsed mixed-tool stack", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "search", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_glob_failed",
name: "glob",
state: {
status: "error",
input: { pattern: "*.ts" },
error: { type: "ToolError", message: "Invalid tool input" },
metadata: {},
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "tool_grep_failed",
name: "grep",
state: {
status: "error",
input: { pattern: "value" },
error: { type: "ToolError", message: "Search timed out" },
metadata: {},
},
time: { created: 4, completed: 5 },
},
{
type: "tool",
id: "tool_shell_failed",
name: "shell",
state: {
status: "error",
input: { command: "exit 1" },
error: { type: "ToolError", message: "Command failed" },
metadata: {},
},
time: { created: 6, completed: 7 },
},
],
time: { created: 2, completed: 8 },
},
]
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group] : [],
)
expect(groups).toEqual([
{
type: "context",
key: "context:msg_assistant:tool_glob_failed",
refs: [
{ messageID: "msg_assistant", partID: "tool_glob_failed" },
{ messageID: "msg_assistant", partID: "tool_grep_failed" },
{ messageID: "msg_assistant", partID: "tool_shell_failed" },
],
},
])
expect(
Timeline.constructSessionMessageRows(source, false, { type: "idle" }, undefined, true).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group.type] : [],
),
).toEqual(["context", "part"])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
@@ -229,10 +229,12 @@ export function createSessionTimelineRowRenderer(input: {
id={props.row._tag === "UserMessage" ? input.anchor?.(props.row.userMessageID) : undefined}
data-message-id={props.row.userMessageID}
data-timeline-row={props.row._tag}
data-timeline-spacing={props.row._tag === "AssistantPart" ? props.row.spacing : undefined}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": input.centered?.(),
"pt-3": props.row._tag === "AssistantPart" && props.row.previousAssistantPart,
"pt-2": props.row._tag === "AssistantPart" && props.row.spacing === "tool",
"pt-4": props.row._tag === "AssistantPart" && props.row.spacing === "content",
}}
>
<div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
@@ -22,6 +22,8 @@ export function SessionTimeline(props: SessionTimelineProps) {
sessionMessages: () => props.document.messages,
status: () => props.document.status,
showReasoningSummaries: () => props.showReasoningSummaries ?? true,
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
const renderer = createSessionTimelineRowRenderer({
@@ -49,6 +49,7 @@ export namespace TimelineRow {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
spacing?: "tool" | "content"
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
@@ -118,6 +119,7 @@ export type TimelineRowMap = {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
spacing?: "tool" | "content"
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
+177 -78
View File
@@ -20,6 +20,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
import { BasicTool, GenericTool } from "../components/basic-tool"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Badge } from "@opencode-ai/ui/badge"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -32,13 +33,16 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { AnimatedCountList } from "../components/tool-count-summary"
import { ToolStatusTitle } from "../components/tool-status-title"
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
import { animate } from "motion"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
import { currentToolInput, currentToolMetadata } from "../message/current-tool-state"
import {
currentToolError,
currentToolInput,
currentToolMetadata,
currentToolOutput,
} from "../message/current-tool-state"
import { writeClipboard } from "../message/message-content"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
@@ -477,84 +481,176 @@ export function CurrentContextToolGroup(props: {
() =>
props.busy || props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const summary = createMemo(() => ({
read: props.tools.filter((tool) => tool.name === "read").length,
search: props.tools.filter((tool) => tool.name === "glob" || tool.name === "grep").length,
list: props.tools.filter((tool) => tool.name === "list").length,
}))
const names = createMemo(() =>
[
...new Set(
props.tools.map((tool) => {
const input = currentToolInput(tool)
if (tool.name === "skill") return i18n.t("ui.tool.skill")
if (tool.name === "subagent" && typeof input.agent === "string" && input.agent)
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
}),
),
].join(", "),
)
const label = createMemo(() => {
const tools = names()
const text = i18n.t("ui.messagePart.tools.used", { tools })
const index = text.indexOf(tools)
return { text, before: text.slice(0, index).trim(), after: text.slice(index + tools.length).trim() }
})
const items = createMemo(() =>
props.tools.reduce<SessionMessageAssistantTool[][]>((groups, tool) => {
const previous = groups.at(-1)
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
skillToolName(currentToolInput(tool), currentToolMetadata(tool)) &&
previous?.[0]?.name === "skill" &&
previous[0].state.status !== "error" &&
skillToolName(currentToolInput(previous[0]), currentToolMetadata(previous[0]))
) {
previous.push(tool)
return groups
}
groups.push([tool])
return groups
}, []),
)
const change = (open: boolean) => {
props.onOpenChange(open)
props.onSizeChange?.()
}
return (
<div data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
<div data-component="collapsed-tool-group" data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
<BasicTool
icon="glasses"
status={pending() ? "running" : "completed"}
compact
rail={false}
allowOpenWhilePending
open={props.open}
onOpenChange={change}
trigger={
<div data-component="context-tool-group-trigger">
<span data-slot="context-tool-group-title" class="min-w-0 flex items-center gap-2">
<span data-slot="basic-tool-tool-title" class="shrink-0">
<ToolStatusTitle
active={pending()}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="basic-tool-tool-subtitle"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
>
<AnimatedCountList
items={[
{ key: "ui.messagePart.context.read", count: summary().read },
{ key: "ui.messagePart.context.search", count: summary().search },
{ key: "ui.messagePart.context.list", count: summary().list },
]}
fallback=""
/>
</span>
<div data-component="context-tool-group-trigger" aria-label={label().text}>
<span data-slot="context-tool-group-title">
<Show when={label().before}>
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
</Show>
<span data-slot="basic-tool-tool-title">{names()}</span>
<Show when={label().after}>
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
</Show>
<Badge>{props.tools.length}</Badge>
</span>
</div>
}
>
<div data-component="context-tool-group-list">
<Index each={props.tools}>
{(tool) => {
<Index each={items()}>
{(group) => {
const tool = createMemo(() => group()[0]!)
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
const running = () => tool().state.status === "streaming" || tool().state.status === "running"
const skills = createMemo(() =>
group().flatMap((item) => {
const name = skillToolName(currentToolInput(item), currentToolMetadata(item))
return name ? [name] : []
}),
)
const marker = "__OPENCODE_LOADED_SKILL__"
const loaded = createMemo(() => i18n.plural("ui.tool.loadedSkills", skills().length, { name: marker }))
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={trigger().title} active={running()} />
</span>
<Show when={trigger().subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
<Show
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
fallback={
<Show
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<ToolDisplay
id={tool().id}
tool={tool().name}
input={currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={false}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
}
>
<div
data-component="tool-loaded-item"
data-timeline-part-ids={group()
.map((item) => item.id)
.join(",")}
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
name: skills().join(", "),
})}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{loaded().split(marker)[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<For each={skills()}>
{(name, index) => (
<>
<Show when={index() > 0}>, </Show>
<TextShimmer
as="span"
text={name}
active={["streaming", "running"].includes(group()[index()]!.state.status)}
/>
</>
)}
</For>
</div>
<Show when={trigger().matches}>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{trigger().matches}</span>
</span>
<Show when={loaded().split(marker)[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
</Show>
}
>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={trigger().title}
active={tool().state.status === "streaming" || tool().state.status === "running"}
/>
</span>
<Show when={trigger().subtitle}>
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</div>
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
)}
</Show>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
)
}}
@@ -888,6 +984,14 @@ ToolRegistry.register({
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
const paths = createMemo(() =>
loaded().map((filepath) => {
const relative = relativizeProjectPath(filepath, data.directory)
return relative === filepath ? relative : relative.replace(/^[/\\]/, "")
}),
)
const marker = "__OPENCODE_LOADED_PATH__"
const parts = createMemo(() => i18n.t("ui.tool.loadedFile", { path: marker }).split(marker))
return (
<>
<BasicTool
@@ -899,31 +1003,26 @@ ToolRegistry.register({
args,
}}
/>
<For each={loaded()}>
{(filepath) => {
const relative = relativizeProjectPath(filepath, data.directory)
const path = relative === filepath ? relative : relative.replace(/^[/\\]/, "")
const marker = "__OPENCODE_LOADED_PATH__"
const parts = i18n.t("ui.tool.loadedFile", { path: marker }).split(marker)
return (
<div data-component="tool-loaded-item" aria-label={i18n.t("ui.tool.loadedFile", { path })}>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts[0].trim()}
<Show when={paths().length > 0}>
<div
data-component="tool-loaded-item"
aria-label={i18n.t("ui.tool.loadedFile", { path: paths().join(", ") })}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts()[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
{paths().join(", ")}
</span>
<Show when={parts()[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</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>
)}
</Show>
</div>
</Show>
</>
)
},
+3
View File
@@ -110,6 +110,7 @@ const source = {
"ui.messagePart.context.list.other": "{{count}} lists",
"ui.messagePart.context.match.one": "({{count}} match)",
"ui.messagePart.context.match.other": "({{count}} matches)",
"ui.messagePart.tools.used": "Used {{tools}}",
"ui.list.loading": "Loading",
"ui.list.empty": "No results",
@@ -158,6 +159,8 @@ const source = {
"ui.tool.loaded": "Loaded",
"ui.tool.loadedFile": "Loaded {{path}}",
"ui.tool.loadedSkill": "Loaded {{name}} skill",
"ui.tool.loadedSkills.one": "Loaded {{name}} skill",
"ui.tool.loadedSkills.other": "Loaded {{name}} skills",
"ui.tool.list": "List",
"ui.tool.glob": "Glob",
"ui.tool.grep": "Grep",
+4
View File
@@ -32,6 +32,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M6.33345 6.33349V5.00015H9.66679V7.00015L8.00015 8.00015V9.66679M8.27485 11.6819H7.71897M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"circle-exclamation": {
viewBox: "0 0 16 16",
body: `<path d="M7.9987 5.50016V8.50016M7.9987 10.5002V10.5068M14.1654 8.00016C14.1654 11.4059 11.4045 14.1668 7.9987 14.1668C6.29582 14.1668 4.75415 13.4766 3.63821 12.3607C2.52226 11.2447 1.83203 9.70304 1.83203 8.00016C1.83203 4.59441 4.59294 1.8335 7.9987 1.8335C9.70158 1.8335 11.2432 2.52372 12.3592 3.63967C13.4751 4.75562 14.1654 6.29728 14.1654 8.00016Z" stroke="currentColor" stroke-linecap="square"/>`,
},
"sidebar-right": {
viewBox: "0 0 20 20",
body: `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,