mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 11:36:14 +00:00
Compare commits
16
Commits
mantle-http
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7c2ccfc0 | ||
|
|
24ac05868c | ||
|
|
4eaf533cd0 | ||
|
|
0f7a76eff0 | ||
|
|
16d731bd67 | ||
|
|
ea582fc133 | ||
|
|
7e27e81bc7 | ||
|
|
667722897e | ||
|
|
d12dbd12a9 | ||
|
|
b0bd0bc394 | ||
|
|
437df1164c | ||
|
|
9a91e21a76 | ||
|
|
d01ac2069a | ||
|
|
a78d3c5438 | ||
|
|
474c3588c1 | ||
|
|
9361117504 |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
|
||||
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
|
||||
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
|
||||
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
|
||||
"x86_64-linux": "sha256-t7k0Nk9Z7FMkAKt2UReI1jD1KoPB+LhQQx1x1p5Htlc=",
|
||||
"aarch64-linux": "sha256-tU7C2zYKiX+RpwBBBLjX7vrqQNLPvDc5Uz9EbgGJvSA=",
|
||||
"aarch64-darwin": "sha256-A3o+2zNuFHV5FZx/azdXBjcKdS5o41b2e2Q1Qq8C1m0=",
|
||||
"x86_64-darwin": "sha256-NXgvteaxJQqBBZIHnQbivghHTJnL2IMZiFPNssGp2Os="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +40,16 @@ const patterns = [
|
||||
/model_context_window_exceeded/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
/request_too_large/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
@@ -106,6 +107,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
clientScoped &&
|
||||
(codes.includes("context_length_exceeded") ||
|
||||
codes.includes("model_context_window_exceeded") ||
|
||||
codes.includes("request_too_large") ||
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
|
||||
@@ -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,14 +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,
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "bedrock-mantle-responses",
|
||||
name: "Bedrock Mantle Responses",
|
||||
enabled: () => false,
|
||||
}),
|
||||
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({
|
||||
@@ -43,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({
|
||||
|
||||
@@ -211,6 +211,28 @@ describe("RequestExecutor", () => {
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
it.effect("classifies Anthropic request_too_large as context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "context-overflow",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify ordinary invalid requests as context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -18,13 +18,19 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
test("classifies Anthropic request_too_large as recoverable overflow", () => {
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies generic request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
@@ -33,7 +39,6 @@ describe("provider error classification", () => {
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
|
||||
@@ -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" }),
|
||||
@@ -59,27 +61,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps Responses on HTTP when a WebSocket executor is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
|
||||
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" }), {
|
||||
webSocket: { execute: () => Effect.die("unexpected Mantle WebSocket request") },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(input.request.url).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/responses")
|
||||
return Effect.succeed(
|
||||
input.respond(sseEvents({ type: "response.completed", response: {} }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -201,6 +207,11 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
for (const [id, name] of [
|
||||
[pending, "frontend-design"],
|
||||
[completed, "OpenCode"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -974,7 +974,7 @@ export type WorktreeResolved = {
|
||||
type: "worktree.resolved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
data: { projectID: string; directory: string; previous: string; adopted?: Array<string> }
|
||||
}
|
||||
|
||||
export type CommandUpdated = {
|
||||
|
||||
@@ -617,7 +617,22 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
case "worktree.resolved": {
|
||||
for (const [sessionID, info] of Object.entries(store.session.info)) {
|
||||
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
|
||||
const explicit = event.data.adopted?.includes(info.projectID)
|
||||
const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory
|
||||
if (!directory) {
|
||||
if (info.location.workspaceID) continue
|
||||
result.session.invalidate(sessionID)
|
||||
void result.session.sync(sessionID)
|
||||
continue
|
||||
}
|
||||
const adopted = Worktree.adopt(
|
||||
{
|
||||
projectID: info.projectID,
|
||||
directory,
|
||||
workspaceID: info.location.workspaceID,
|
||||
},
|
||||
event.data,
|
||||
)
|
||||
if (!adopted) continue
|
||||
setStore("session", "info", sessionID, "projectID", adopted.projectID)
|
||||
setStore("session", "info", sessionID, "subpath", adopted.subpath)
|
||||
|
||||
@@ -154,6 +154,106 @@ test("updates authoritative cached project metadata from live events", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("adopts cached directory-project sessions when their repository is resolved", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const refreshed: SessionInfo = {
|
||||
...session(0),
|
||||
id: "ses_uncached",
|
||||
projectID: "repository",
|
||||
location: { directory: "/unknown-alias" },
|
||||
subpath: "app",
|
||||
}
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith("/api/session/ses_uncached")) throw new Error(`Unexpected request: ${request.url}`)
|
||||
return Response.json({ data: refreshed })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/repo",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
const sessions: SessionInfo[] = [
|
||||
{ ...session(0), id: "ses_root", projectID: "directory-root", location: { directory: "/repo" } },
|
||||
{ ...session(0), id: "ses_nested", projectID: "directory-nested", location: { directory: "/repo/app" } },
|
||||
{
|
||||
...session(0),
|
||||
id: "ses_alias",
|
||||
projectID: "directory-nested",
|
||||
location: { directory: "/repo/alias/../app" },
|
||||
},
|
||||
{ ...session(0), id: "ses_symlink", projectID: "directory-nested", location: { directory: "/shortcut" } },
|
||||
{ ...refreshed, projectID: "directory-uncached" },
|
||||
{ ...session(0), id: "ses_global", projectID: "global", location: { directory: "/repo/legacy" } },
|
||||
{ ...session(0), id: "ses_escaped", projectID: "global", location: { directory: "/repo/../other" } },
|
||||
{ ...session(0), id: "ses_other", projectID: "other-repository", location: { directory: "/repo/vendor" } },
|
||||
{ ...session(0), id: "ses_sibling", projectID: "global", location: { directory: "/repo-other" } },
|
||||
{
|
||||
...session(0),
|
||||
id: "ses_remote",
|
||||
projectID: "directory-root",
|
||||
location: { directory: "/repo", workspaceID: "workspace-remote" },
|
||||
},
|
||||
]
|
||||
sessions.forEach((item) => setup.data.session.remember(item))
|
||||
for (const project of [
|
||||
{ id: "directory-root", canonical: "/repo" },
|
||||
{ id: "directory-nested", canonical: "/repo/app" },
|
||||
]) {
|
||||
const updated: OpenCodeEvent = {
|
||||
id: `evt_${project.id}`,
|
||||
created: 0,
|
||||
type: "project.updated",
|
||||
data: { ...project, time: { created: 0, updated: 0 }, sandboxes: [] },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
|
||||
}
|
||||
|
||||
const resolved: OpenCodeEvent = {
|
||||
id: "evt_repository_resolved",
|
||||
created: 1,
|
||||
type: "worktree.resolved",
|
||||
durable: { aggregateID: "repository", seq: 0, version: 1 },
|
||||
data: {
|
||||
projectID: "repository",
|
||||
directory: "/repo",
|
||||
previous: "global",
|
||||
adopted: ["directory-root", "directory-nested", "directory-uncached"],
|
||||
},
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: resolved.type, details: resolved }))
|
||||
|
||||
expect(setup.data.session.get("ses_root")?.projectID).toBe("repository")
|
||||
expect(setup.data.session.get("ses_root")?.subpath).toBeUndefined()
|
||||
expect(setup.data.session.get("ses_nested")).toMatchObject({ projectID: "repository", subpath: "app" })
|
||||
expect(setup.data.session.get("ses_alias")).toMatchObject({ projectID: "repository", subpath: "app" })
|
||||
expect(setup.data.session.get("ses_symlink")).toMatchObject({ projectID: "repository", subpath: "app" })
|
||||
expect(setup.data.session.get("ses_global")).toMatchObject({ projectID: "repository", subpath: "legacy" })
|
||||
expect(setup.data.session.get("ses_escaped")?.projectID).toBe("global")
|
||||
expect(setup.data.session.get("ses_other")?.projectID).toBe("other-repository")
|
||||
expect(setup.data.session.get("ses_sibling")?.projectID).toBe("global")
|
||||
expect(setup.data.session.get("ses_remote")?.projectID).toBe("directory-root")
|
||||
await wait(() => setup.data.session.get("ses_uncached")?.projectID === "repository")
|
||||
expect(setup.data.session.get("ses_uncached")?.subpath).toBe("app")
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes global credential events across every loaded location and workspace", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: URL[] = []
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
|
||||
describe("Worktree.adopt", () => {
|
||||
const event = {
|
||||
projectID: "repository",
|
||||
directory: "/repo",
|
||||
previous: "previous",
|
||||
adopted: ["directory-root", "directory-nested"],
|
||||
}
|
||||
|
||||
test("adopts explicitly superseded directory projects", () => {
|
||||
expect(Worktree.adopt({ projectID: "directory-root", directory: "/repo" }, event)).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: undefined,
|
||||
})
|
||||
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/packages/app" }, event)).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: "packages/app",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves existing previous-project and global adoption", () => {
|
||||
expect(Worktree.adopt({ projectID: "previous", directory: "/repo/packages/app" }, event)).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: "packages/app",
|
||||
})
|
||||
expect(Worktree.adopt({ projectID: "global", directory: "/repo/packages/app" }, event)).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: "packages/app",
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves unrelated projects, sibling directories, and workspaces unchanged", () => {
|
||||
expect(Worktree.adopt({ projectID: "other", directory: "/repo/vendor" }, event)).toBeUndefined()
|
||||
expect(Worktree.adopt({ projectID: "global", directory: "/repo-other" }, event)).toBeUndefined()
|
||||
expect(Worktree.adopt({ projectID: "repository", directory: "/repo" }, event)).toBeUndefined()
|
||||
expect(
|
||||
Worktree.adopt({ projectID: "directory-root", directory: "/repo", workspaceID: "remote" }, event),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes Windows directory separators", () => {
|
||||
expect(
|
||||
Worktree.adopt(
|
||||
{ projectID: "directory-nested", directory: "C:\\repo\\packages\\app" },
|
||||
{ ...event, directory: "C:\\repo" },
|
||||
),
|
||||
).toEqual({ projectID: "repository", subpath: "packages/app" })
|
||||
expect(
|
||||
Worktree.adopt(
|
||||
{ projectID: "directory-nested", directory: "c:/Repo/packages/App" },
|
||||
{ ...event, directory: "C:\\repo" },
|
||||
),
|
||||
).toEqual({ projectID: "repository", subpath: "packages/App" })
|
||||
})
|
||||
|
||||
test("normalizes aliases and rejects paths that leave the repository", () => {
|
||||
expect(Worktree.adopt({ projectID: "directory-nested", directory: "/repo/alias/../packages/app" }, event)).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: "packages/app",
|
||||
})
|
||||
expect(Worktree.adopt({ projectID: "global", directory: "/repo/../other" }, event)).toBeUndefined()
|
||||
expect(Worktree.adopt({ projectID: "global", directory: "/app" }, { ...event, directory: "/" })).toEqual({
|
||||
projectID: "repository",
|
||||
subpath: "app",
|
||||
})
|
||||
})
|
||||
|
||||
test("decodes existing durable events without adopted project IDs", () => {
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Worktree.Event.Resolved.data)({
|
||||
projectID: "repository",
|
||||
directory: "/repo",
|
||||
previous: "global",
|
||||
}),
|
||||
).toEqual({ projectID: "repository", directory: "/repo", previous: "global" })
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -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
|
||||
@@ -104,7 +111,12 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (previous && previous.canonical !== project.canonical) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, project.id)).get().pipe(Effect.orDie)
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, project.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
|
||||
}
|
||||
if (!project.vcs) return project
|
||||
@@ -135,9 +147,38 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored) return
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(item.directory))
|
||||
const markerless = yield* db
|
||||
.select({ id: ProjectTable.id, directory: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(ProjectTable.vcs),
|
||||
gte(ProjectTable.worktree, directory),
|
||||
lte(ProjectTable.worktree, AbsolutePath.make(directory + "\uffff")),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const adopted = yield* Effect.filter(markerless, (candidate) =>
|
||||
Effect.gen(function* () {
|
||||
if (candidate.id === item.projectID) return false
|
||||
if (!FSUtil.contains(directory, candidate.directory)) return false
|
||||
const found = yield* fs
|
||||
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
if (!found[0]) return false
|
||||
return (yield* fs.resolve(path.dirname(found[0]))) === directory
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(
|
||||
Worktree.Event.Resolved,
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{
|
||||
projectID: item.projectID,
|
||||
directory: item.directory,
|
||||
previous: project.previous ?? ID.global,
|
||||
...(adopted.length ? { adopted: adopted.map((candidate) => candidate.id) } : {}),
|
||||
},
|
||||
{
|
||||
commit: () =>
|
||||
db
|
||||
@@ -256,12 +297,7 @@ const layer = Layer.effect(
|
||||
return node ? ID.make(node) : undefined
|
||||
})
|
||||
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!dotHg) return undefined
|
||||
const hgDiscover = Effect.fnUntraced(function* (dotHg: AbsolutePath) {
|
||||
const worktree = AbsolutePath.make(path.dirname(dotHg))
|
||||
const store = AbsolutePath.make(dotHg)
|
||||
const previous = yield* cached(store)
|
||||
@@ -275,8 +311,17 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.repo.discover(input)
|
||||
if (repo) {
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(input))
|
||||
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 =
|
||||
native && path.basename(native) === ".git"
|
||||
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
|
||||
: undefined
|
||||
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 =
|
||||
@@ -292,13 +337,36 @@ 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 = yield* hgDiscover(input)
|
||||
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
|
||||
const directory = AbsolutePath.make(path.parse(input).root)
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
return yield* persist({
|
||||
id: ID.make(Hash.fast(`directory:${directory}`)),
|
||||
directory,
|
||||
canonical: directory,
|
||||
vcs: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ list, update, resolve })
|
||||
@@ -308,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],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector.js"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
@@ -23,6 +23,7 @@ import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
@@ -478,18 +479,34 @@ const layer = Layer.effectDiscard(
|
||||
// are untouched: the session did not move, its directory got identified.
|
||||
yield* bus.project(Worktree.Event.Resolved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
const candidates = [
|
||||
...new Set(
|
||||
[event.data.previous, Project.ID.global, ...(event.data.adopted ?? [])].filter(
|
||||
(id) => id !== event.data.projectID,
|
||||
),
|
||||
),
|
||||
]
|
||||
if (candidates.length === 0) return
|
||||
const rows = yield* db
|
||||
.select({ id: SessionTable.id, directory: SessionTable.directory })
|
||||
.select({
|
||||
id: SessionTable.id,
|
||||
directory: SessionTable.directory,
|
||||
projectID: SessionTable.project_id,
|
||||
canonical: ProjectTable.worktree,
|
||||
})
|
||||
.from(SessionTable)
|
||||
.innerJoin(ProjectTable, eq(SessionTable.project_id, ProjectTable.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(SessionTable.project_id, stale),
|
||||
// Lexicographic range narrows the scan to prefix neighbors without
|
||||
// LIKE escaping; FSUtil.contains below decides containment exactly.
|
||||
gte(SessionTable.directory, event.data.directory),
|
||||
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
|
||||
inArray(SessionTable.project_id, candidates),
|
||||
isNull(SessionTable.workspace_id),
|
||||
or(
|
||||
event.data.adopted?.length ? inArray(SessionTable.project_id, event.data.adopted) : undefined,
|
||||
and(
|
||||
gte(SessionTable.directory, event.data.directory),
|
||||
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
@@ -497,12 +514,15 @@ const layer = Layer.effectDiscard(
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
|
||||
const directory = event.data.adopted?.includes(row.projectID)
|
||||
? row.canonical
|
||||
: AbsolutePath.make(path.resolve(row.directory))
|
||||
if (!FSUtil.contains(event.data.directory, directory)) return Effect.void
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
project_id: event.data.projectID,
|
||||
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
|
||||
path: RelativePath.make(path.relative(event.data.directory, directory).replaceAll("\\", "/")),
|
||||
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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") },
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -145,24 +145,154 @@ async function rootCommit(dir: string) {
|
||||
}
|
||||
|
||||
describe("Project.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
it.live("creates distinct deterministic projects for exact markerless directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = yield* Project.Service
|
||||
const nested = path.join(tmp.path, "notes", "drafts")
|
||||
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
const repeated = yield* project.resolve(abs(`${tmp.path}${path.sep}.`))
|
||||
const child = yield* project.resolve(abs(nested))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
|
||||
expect(result.id).not.toBe(Project.ID.global)
|
||||
expect(repeated.id).toBe(result.id)
|
||||
expect(child.id).not.toBe(result.id)
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(child.directory).toBe(yield* real(nested))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
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(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const nested = path.join(tmp.path, "packages", "app")
|
||||
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
|
||||
const project = yield* Project.Service
|
||||
const root = yield* project.resolve(abs(tmp.path))
|
||||
const child = yield* project.resolve(abs(nested))
|
||||
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
const repository = yield* project.resolve(abs(nested))
|
||||
|
||||
expect(root.id).not.toBe(child.id)
|
||||
expect(repository.id).not.toBe(root.id)
|
||||
expect(repository.id).not.toBe(child.id)
|
||||
expect(repository.directory).toBe(yield* real(tmp.path))
|
||||
expect(repository.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish project updates for first or repeated resolutions", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
@@ -411,6 +541,48 @@ describe("Project.resolve", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers the nearest mercurial marker over an outer 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(nested, ".hg"), { recursive: true })
|
||||
await fs.mkdir(path.join(nested, "app"))
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(path.join(nested, "app")))
|
||||
|
||||
expect(result.vcs?.type).toBe("hg")
|
||||
expect(result.directory).toBe(yield* real(nested))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers the nearest git marker over an outer mercurial 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 fs.mkdir(path.join(tmp.path, ".hg"))
|
||||
await fs.mkdir(path.join(nested, "app"), { recursive: true })
|
||||
await initRepo(nested, { commit: true })
|
||||
})
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(path.join(nested, "app")))
|
||||
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
expect(result.directory).toBe(yield* real(nested))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns global id for unreadable mercurial metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -96,14 +96,37 @@ describe("Session.create", () => {
|
||||
const { db } = yield* Database.Service
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const nested = Location.Ref.make({ directory: AbsolutePath.make(path.join(directory, "packages", "app")) })
|
||||
const aliased = Location.Ref.make({
|
||||
directory: AbsolutePath.make([path.join(directory, "alias"), "..", "packages", "app"].join(path.sep)),
|
||||
})
|
||||
const created = yield* session.create({ location: ref, title: "Before git" })
|
||||
const child = yield* session.create({ location: nested, title: "Nested before git" })
|
||||
const originalUpdated = created.time.updated
|
||||
const alias = yield* session.create({ location: aliased, title: "Aliased before git" })
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* session.prompt({ sessionID: created.id, text: "Preserved history", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, created.id, "steer")
|
||||
const pending = yield* session.prompt({ sessionID: created.id, text: "Preserved inbox", resume: false })
|
||||
yield* store.claim(created.id)
|
||||
const before = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, created.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!before) return yield* Effect.die(new Error("created session not found"))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git init -q`.cwd(directory)
|
||||
await $`git config user.email test@example.com`.cwd(directory)
|
||||
await $`git config user.name Test`.cwd(directory)
|
||||
})
|
||||
const unborn = yield* projects.resolve(ref.directory)
|
||||
const unbornRoot = yield* session.get(created.id)
|
||||
const unbornNested = yield* session.get(child.id)
|
||||
const unbornAlias = yield* session.get(alias.id)
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, "README.md"), "test\n")
|
||||
await $`git add README.md`.cwd(directory)
|
||||
await $`git commit -qm initial`.cwd(directory)
|
||||
@@ -114,10 +137,25 @@ describe("Session.create", () => {
|
||||
const repeat = yield* projects.resolve(ref.directory)
|
||||
const adopted = yield* session.get(created.id)
|
||||
const nestedAdopted = yield* session.get(child.id)
|
||||
const aliasAdopted = yield* session.get(alias.id)
|
||||
const page = yield* session.list({ project: project.id })
|
||||
const log = Array.from(yield* Stream.runCollect(logEvents(session, created.id)))
|
||||
|
||||
expect(created.projectID).toBe(Project.ID.global)
|
||||
expect(created.projectID).not.toBe(Project.ID.global)
|
||||
expect(child.projectID).not.toBe(created.projectID)
|
||||
expect(alias.projectID).toBe(child.projectID)
|
||||
expect(unborn.id).toBe(Project.ID.global)
|
||||
expect(unbornRoot).toMatchObject({ projectID: Project.ID.global, subpath: undefined })
|
||||
expect(unbornNested).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
|
||||
expect(unbornAlias).toMatchObject({ projectID: Project.ID.global, subpath: "packages/app" })
|
||||
expect(
|
||||
yield* db
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, Project.ID.global))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toMatchObject({ data: { adopted: expect.arrayContaining([created.projectID, child.projectID]) } })
|
||||
expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted")))
|
||||
expect(repeat.id).toBe(project.id)
|
||||
expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id]))
|
||||
@@ -125,16 +163,37 @@ describe("Session.create", () => {
|
||||
projectID: project.id,
|
||||
location: ref,
|
||||
subpath: undefined,
|
||||
time: { updated: originalUpdated },
|
||||
time: { updated: DateTime.makeUnsafe(before.time_updated) },
|
||||
})
|
||||
expect(nestedAdopted).toMatchObject({
|
||||
projectID: project.id,
|
||||
location: nested,
|
||||
subpath: RelativePath.make("packages/app"),
|
||||
})
|
||||
expect(aliasAdopted).toMatchObject({
|
||||
projectID: project.id,
|
||||
location: aliased,
|
||||
subpath: RelativePath.make("packages/app"),
|
||||
})
|
||||
// Adoption is a project-domain fact; the session log records nothing new.
|
||||
expect(log.map((event) => event.type)).toEqual(["session.created"])
|
||||
expect(yield* session.messages({ sessionID: created.id })).toEqual([])
|
||||
expect(log.map((event) => event.type)).toEqual([
|
||||
"session.created",
|
||||
"session.inbox.enqueued",
|
||||
"session.inbox.delivered",
|
||||
"session.inbox.enqueued",
|
||||
])
|
||||
expect(yield* session.messages({ sessionID: created.id })).toMatchObject([
|
||||
{ id: expect.any(String), type: "user", text: "Preserved history" },
|
||||
])
|
||||
expect(yield* SessionInbox.find(db, pending.id)).toMatchObject({ payload: { text: "Preserved inbox" } })
|
||||
expect(
|
||||
yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({
|
||||
time_created: before.time_created,
|
||||
time_updated: before.time_updated,
|
||||
time_suspended: before.time_suspended,
|
||||
resume_attempts: before.resume_attempts,
|
||||
})
|
||||
// Repeated resolution announces the directory's identity exactly once.
|
||||
const announced = yield* db
|
||||
.select({ type: EventTable.type })
|
||||
@@ -147,6 +206,46 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
liveIt.live("does not adopt nested repositories or sessions in another workspace", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const projects = yield* Project.Service
|
||||
const outer = path.join(directory, "outer")
|
||||
const nested = path.join(outer, "nested")
|
||||
const child = AbsolutePath.make(path.join(nested, "app"))
|
||||
yield* Effect.promise(() => fs.mkdir(child, { recursive: true }))
|
||||
|
||||
const local = yield* session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(outer) }) })
|
||||
const inside = yield* session.create({ location: Location.Ref.make({ directory: child }) })
|
||||
const remote = yield* session.create({
|
||||
location: Location.Ref.make({
|
||||
directory: AbsolutePath.make(outer),
|
||||
workspaceID: Workspace.ID.make("wrk_remote"),
|
||||
}),
|
||||
})
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
for (const root of [nested, outer]) {
|
||||
await $`git init -q`.cwd(root)
|
||||
await $`git config user.email test@example.com`.cwd(root)
|
||||
await $`git config user.name Test`.cwd(root)
|
||||
await $`git commit --allow-empty -qm initial`.cwd(root)
|
||||
}
|
||||
})
|
||||
|
||||
const parent = yield* projects.resolve(AbsolutePath.make(outer))
|
||||
expect((yield* session.get(local.id)).projectID).toBe(parent.id)
|
||||
expect((yield* session.get(inside.id)).projectID).toBe(inside.projectID)
|
||||
expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID)
|
||||
|
||||
const repository = yield* projects.resolve(AbsolutePath.make(nested))
|
||||
expect((yield* session.get(inside.id)).projectID).toBe(repository.id)
|
||||
expect((yield* session.get(remote.id)).projectID).toBe(remote.projectID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("persists a missing title until one is generated or supplied", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -132,6 +132,7 @@ const it = testEffect(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Project.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Agent.node,
|
||||
@@ -200,6 +201,7 @@ const setup = Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const agents = yield* Agent.Service
|
||||
const projects = yield* Project.Service
|
||||
const instructionBuiltIns = yield* InstructionBuiltIns.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("build"), (agent) => {
|
||||
@@ -210,7 +212,7 @@ const setup = Effect.gen(function* () {
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
project_id: (yield* projects.resolve(AbsolutePath.make("/project"))).id,
|
||||
slug: "generate-test",
|
||||
directory: "/project",
|
||||
title: "Generate test",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export type SessionDomain = Pick<
|
||||
| "synthetic"
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export type SessionDomain = Pick<
|
||||
| "synthetic"
|
||||
| "interrupt"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,27 +57,46 @@ const Resolved = durable({
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
previous: Project.ID,
|
||||
adopted: optional(Schema.Array(Project.ID)),
|
||||
},
|
||||
})
|
||||
|
||||
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
|
||||
|
||||
export function adopt(
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
session: { readonly projectID: string; readonly directory: string; readonly workspaceID?: string },
|
||||
event: {
|
||||
readonly projectID: string
|
||||
readonly directory: string
|
||||
readonly previous: string
|
||||
readonly adopted?: ReadonlyArray<string>
|
||||
},
|
||||
) {
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.workspaceID) return
|
||||
if (
|
||||
session.projectID !== event.previous &&
|
||||
session.projectID !== Project.ID.global &&
|
||||
!event.adopted?.includes(session.projectID)
|
||||
)
|
||||
return
|
||||
if (session.projectID === event.projectID) return
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
const normalize = (value: string) =>
|
||||
value
|
||||
.replaceAll("\\", "/")
|
||||
.split("/")
|
||||
.reduce((result, segment) => {
|
||||
if (!segment || segment === ".") return result
|
||||
if (segment === "..") return result.slice(0, result.lastIndexOf("/"))
|
||||
return `${result}/${segment}`
|
||||
}, "")
|
||||
const directory = normalize(session.directory)
|
||||
const root = normalize(event.directory)
|
||||
const windows = /^\/[a-z]:/i.test(root)
|
||||
const key = windows ? directory.toLowerCase() : directory
|
||||
const parent = windows ? root.toLowerCase() : root
|
||||
if (key !== parent && !key.startsWith(parent + "/")) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
subpath: key === parent ? undefined : directory.slice(root.length + 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,50 +481,50 @@ 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 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>
|
||||
}
|
||||
@@ -529,32 +533,57 @@ export function CurrentContextToolGroup(props: {
|
||||
<Index each={props.tools}>
|
||||
{(tool) => {
|
||||
const trigger = createMemo(() => currentContextToolTrigger(tool(), i18n))
|
||||
const running = () => tool().state.status === "streaming" || tool().state.status === "running"
|
||||
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
|
||||
when={tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)}
|
||||
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-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>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={trigger().matches}>
|
||||
<span data-slot="context-tool-group-dot" />
|
||||
<span data-slot="context-tool-group-matches">{trigger().matches}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -614,7 +614,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "…" : title}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -971,7 +971,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const restart = client.restart
|
||||
if (!restart) return
|
||||
dialog.clear()
|
||||
toast.show({ variant: "info", message: "Restarting service...", duration: 30000 })
|
||||
toast.show({ variant: "info", message: "Restarting service…", duration: 30000 })
|
||||
// restart resolves once the replacement service is healthy; the
|
||||
// event stream reattaches through the reconnect loop.
|
||||
await restart()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user