mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5ed2866a | |||
| 4de91c9755 | |||
| 89e3329bd5 | |||
| 17a5b32270 | |||
| fca453a2a5 | |||
| 2a51cf5f88 | |||
| b7bd2b0ae0 | |||
| 45b2759a1e | |||
| 65b6abc4bb | |||
| 8c8492ef97 | |||
| 115e689723 | |||
| 7c30a4230e | |||
| bd4557291b | |||
| 9ecf46bfea | |||
| 9ded81b6c2 | |||
| 87142c1b4c | |||
| d8320209c9 | |||
| 31c72b9a17 |
@@ -297,7 +297,7 @@ export const classifyHttpFailure = (input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapHttpError = (error: unknown, redactedNames: ReadonlyArray<string | RegExp>) => {
|
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||||
const transportError = (input: {
|
const transportError = (input: {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly kind?: string | undefined
|
readonly kind?: string | undefined
|
||||||
@@ -314,35 +314,23 @@ export const mapHttpError = (error: unknown, redactedNames: ReadonlyArray<string
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const cause =
|
if (Cause.isTimeoutError(error)) {
|
||||||
HttpClientError.isHttpClientError(error) && "cause" in error.reason
|
return transportError({ message: error.message, kind: "Timeout" })
|
||||||
? error.reason.cause
|
|
||||||
: error instanceof Error
|
|
||||||
? error.cause
|
|
||||||
: undefined
|
|
||||||
const code = [cause, error]
|
|
||||||
.map((value) => (typeof value === "object" && value !== null ? Reflect.get(value, "code") : undefined))
|
|
||||||
.find((value): value is string => typeof value === "string")
|
|
||||||
const request = HttpClientError.isHttpClientError(error) && "request" in error ? error.request : undefined
|
|
||||||
const raw = cause instanceof Error ? cause.message : error instanceof Error ? error.message : undefined
|
|
||||||
const detail = raw && request ? redactBody(raw, secretValues(request)) : raw
|
|
||||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
|
||||||
|
|
||||||
if (Cause.isTimeoutError(error) || Cause.isTimeoutError(cause))
|
|
||||||
return transportError({ message: message ?? "HTTP transport timed out", kind: code ?? "Timeout", request })
|
|
||||||
if (!HttpClientError.isHttpClientError(error)) {
|
|
||||||
return transportError({ message: message ?? "HTTP transport failed", kind: code, request })
|
|
||||||
}
|
}
|
||||||
|
if (!HttpClientError.isHttpClientError(error)) {
|
||||||
|
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||||
|
}
|
||||||
|
const request = "request" in error ? error.request : undefined
|
||||||
if (error.reason._tag === "TransportError") {
|
if (error.reason._tag === "TransportError") {
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? error.reason.description ?? "HTTP transport failed",
|
message: error.reason.description ?? "HTTP transport failed",
|
||||||
kind: code ?? error.reason._tag,
|
kind: error.reason._tag,
|
||||||
request,
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? `HTTP transport failed: ${error.reason._tag}`,
|
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||||
kind: code ?? error.reason._tag,
|
kind: error.reason._tag,
|
||||||
request,
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -355,16 +343,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
if (!middleware)
|
if (!middleware)
|
||||||
return yield* http.execute(request).pipe(
|
return yield* http
|
||||||
Effect.mapError((error) => mapHttpError(error, redactedNames)),
|
.execute(request)
|
||||||
Effect.flatMap(statusError(request, redactedNames)),
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
)
|
|
||||||
|
|
||||||
const response = yield* middleware(request, (input) =>
|
const response = yield* middleware(request, (input) =>
|
||||||
http
|
http
|
||||||
.execute(input)
|
.execute(input)
|
||||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
).pipe(Effect.mapError((error) => mapHttpError(error, redactedNames)))
|
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||||
return yield* statusError(response.request, redactedNames)(response)
|
return yield* statusError(response.request, redactedNames)(response)
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Effect, Stream } from "effect"
|
|||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { render as renderEndpoint } from "../endpoint"
|
import { render as renderEndpoint } from "../endpoint"
|
||||||
import { mapHttpError } from "../executor"
|
|
||||||
import { Framing } from "../framing"
|
import { Framing } from "../framing"
|
||||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
@@ -87,16 +86,20 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
runtime.http
|
runtime.http
|
||||||
.execute(prepared.request, prepared.middleware)
|
.execute(prepared.request, prepared.middleware)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((response) =>
|
Effect.map((response) =>
|
||||||
Stream.unwrap(
|
prepared.framing.frame(
|
||||||
Effect.map(Headers.CurrentRedactedNames, (redactedNames) =>
|
response.stream.pipe(
|
||||||
prepared.framing.frame(
|
Stream.mapError((error) =>
|
||||||
response.stream.pipe(Stream.mapError((error) => mapHttpError(error, redactedNames))),
|
ProviderShared.eventError(
|
||||||
|
`${request.model.provider}/${request.model.route.id}`,
|
||||||
|
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||||
|
ProviderShared.errorText(error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
|||||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||||
* exercise transport errors that surface during parsing.
|
* exercise transport errors that surface during parsing.
|
||||||
*/
|
*/
|
||||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
|
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||||
dynamicResponse((input) =>
|
dynamicResponse((input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||||
controller.error(error)
|
controller.error(new Error("connection reset"))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return input.respond(stream, { headers: SSE_HEADERS })
|
return input.respond(stream, { headers: SSE_HEADERS })
|
||||||
|
|||||||
@@ -1221,18 +1221,12 @@ describe("OpenAI Chat route", () => {
|
|||||||
|
|
||||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layer = truncatedStream(
|
const layer = truncatedStream([
|
||||||
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||||
Object.assign(new Error("socket closed unexpectedly"), { code: "ECONNRESET" }),
|
])
|
||||||
)
|
|
||||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: socket closed unexpectedly",
|
|
||||||
kind: "ECONNRESET",
|
|
||||||
url: "https://api.openai.test/v1/chat/completions",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||||
|
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||||
|
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
})
|
})
|
||||||
await mockServer(page, { questions: [] })
|
await mockServer(page, { questions: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection({ path: "/api/event" })
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
@@ -132,32 +132,40 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toBe(cursor)
|
.toBe(cursor)
|
||||||
await transport.send({
|
await transport.send(
|
||||||
directory,
|
{
|
||||||
payload: {
|
directory,
|
||||||
type: "question.asked",
|
payload: {
|
||||||
properties: {
|
type: "question.asked",
|
||||||
id: "question-caret",
|
properties: {
|
||||||
sessionID,
|
id: "question-caret",
|
||||||
questions: [
|
sessionID,
|
||||||
{
|
questions: [
|
||||||
header: "Continue",
|
{
|
||||||
question: "Continue?",
|
header: "Continue",
|
||||||
options: [{ label: "Yes", description: "Continue the session" }],
|
question: "Continue?",
|
||||||
},
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
],
|
},
|
||||||
tool: { messageID: "message-caret", callID: "call-caret" },
|
],
|
||||||
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
await expect(question).toBeVisible()
|
await expect(question).toBeVisible()
|
||||||
await expect(editor).toHaveCount(0)
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
await transport.send({
|
await transport.send(
|
||||||
directory,
|
{
|
||||||
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
directory,
|
||||||
})
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
await page.keyboard.press("x")
|
await page.keyboard.press("x")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
|||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const initialPageSize = 20
|
const initialPageSize = 20
|
||||||
const historyPageSize = 200
|
const historyPageSize = 50
|
||||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(
|
||||||
id: "timeline-event-7",
|
partUpdated(textPart("prt_transport_id", "event with id")),
|
||||||
})
|
{ id: "timeline-event-7" },
|
||||||
|
"/api/event",
|
||||||
|
)
|
||||||
await timeline.waitForPart("prt_transport_id")
|
await timeline.waitForPart("prt_transport_id")
|
||||||
|
|
||||||
await timeline.transport.error("retry with event id")
|
await timeline.transport.error("retry with event id", "/api/event")
|
||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const root = "C:/OpenCode/WorkspaceProject"
|
||||||
|
const workspace = "C:/OpenCode/worktree/project/feature"
|
||||||
|
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
|
||||||
|
const project = {
|
||||||
|
id: "proj_workspaces",
|
||||||
|
canonical: root,
|
||||||
|
vcs: "git" as const,
|
||||||
|
name: "workspace-project",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [workspace],
|
||||||
|
}
|
||||||
|
const provider = {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
}
|
||||||
|
const diff = {
|
||||||
|
file: "src/workspace.ts",
|
||||||
|
additions: 3,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified" as const,
|
||||||
|
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
|
||||||
|
}
|
||||||
|
const cors = {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
||||||
|
"access-control-allow-headers": "content-type",
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, directory: string, title?: string): SessionInfo {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectID: project.id,
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "opencode", id: "test" },
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
title,
|
||||||
|
location: { directory },
|
||||||
|
subpath: "",
|
||||||
|
time: { created: 1, updated: 2 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(id: string, text: string) {
|
||||||
|
return { id, type: "user" as const, time: { created: 1 }, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json(route: Route, body: unknown) {
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", headers: cors, body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(page: Page, tab: Record<string, unknown>) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ root, server, tab }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
|
||||||
|
},
|
||||||
|
{ root, server, tab },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("selects an existing workspace from the start menu", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspaces"
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
const directories = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "GET" &&
|
||||||
|
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await directories
|
||||||
|
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||||
|
await page.getByRole("menuitem", { name: "feature", exact: true }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "feature", exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists and manually deletes workspaces from settings", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_settings"
|
||||||
|
const cleanWorkspace = `${workspace}-clean`
|
||||||
|
const inventory = { ...project, sandboxes: [cleanWorkspace] }
|
||||||
|
let releaseSessions = () => {}
|
||||||
|
const sessionsReady = new Promise<void>((resolve) => {
|
||||||
|
releaseSessions = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
|
directory: root,
|
||||||
|
project: inventory,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/api/session**", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (
|
||||||
|
route.request().method() !== "GET" ||
|
||||||
|
url.pathname !== "/api/session" ||
|
||||||
|
url.searchParams.get("limit") !== "100" ||
|
||||||
|
url.searchParams.get("order") !== "desc"
|
||||||
|
)
|
||||||
|
return route.fallback()
|
||||||
|
await sessionsReady
|
||||||
|
await json(route, { data: [], cursor: {} })
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
const directories = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "GET" &&
|
||||||
|
new URL(request.url()).pathname === `/api/project/${project.id}/directories`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await directories
|
||||||
|
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "Workspace" }).hover()
|
||||||
|
const sessions = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "GET" &&
|
||||||
|
new URL(request.url()).pathname === "/api/session" &&
|
||||||
|
new URL(request.url()).searchParams.get("limit") === "100",
|
||||||
|
)
|
||||||
|
await page.getByRole("menuitem", { name: "View all", exact: true }).click()
|
||||||
|
await sessions
|
||||||
|
|
||||||
|
const settings = page.getByRole("dialog")
|
||||||
|
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
|
||||||
|
await expect(page.locator('[data-component="session-new-design"]')).toBeAttached()
|
||||||
|
releaseSessions()
|
||||||
|
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
|
||||||
|
const confirmation = page.getByRole("dialog").filter({ hasText: 'Delete workspace "feature-clean"?' })
|
||||||
|
const removed = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "DELETE" &&
|
||||||
|
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||||
|
)
|
||||||
|
await confirmation.getByRole("button", { name: "Delete workspace", exact: true }).click()
|
||||||
|
const request = await removed
|
||||||
|
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(root)
|
||||||
|
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace, force: true })
|
||||||
|
await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits the owning prompt after a new workspace is created", async ({ page }) => {
|
||||||
|
const draftID = "draft_workspace_submit"
|
||||||
|
const sessionID = "ses_workspace_submit"
|
||||||
|
const createdSession = session(sessionID, createdWorkspace)
|
||||||
|
let releaseCopy = () => {}
|
||||||
|
const copyReady = new Promise<void>((resolve) => {
|
||||||
|
releaseCopy = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||||
|
if (request.method() !== "POST") return route.fallback()
|
||||||
|
await copyReady
|
||||||
|
await json(route, { directory: createdWorkspace })
|
||||||
|
})
|
||||||
|
await page.route("**/api/session**", async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const promptPath = `/api/session/${sessionID}/prompt`
|
||||||
|
if (request.method() === "OPTIONS" && (url.pathname === "/api/session" || url.pathname === promptPath))
|
||||||
|
return route.fulfill({ status: 204, headers: cors })
|
||||||
|
if (request.method() === "POST" && url.pathname === "/api/session")
|
||||||
|
return json(route, { data: createdSession })
|
||||||
|
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||||
|
return json(route, { data: createdSession })
|
||||||
|
if (request.method() !== "POST" || url.pathname !== promptPath) return route.fallback()
|
||||||
|
const input = request.postDataJSON() as { id: string; text: string }
|
||||||
|
await json(route, {
|
||||||
|
data: {
|
||||||
|
id: input.id,
|
||||||
|
sessionID,
|
||||||
|
timeCreated: 3,
|
||||||
|
type: "user",
|
||||||
|
data: { text: input.text },
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await init(page, { type: "draft", draftID, directory: root })
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
const editor = page.getByRole("textbox", { name: "Prompt" })
|
||||||
|
await expectAppVisible(editor)
|
||||||
|
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||||
|
await editor.fill("Build workspace support")
|
||||||
|
|
||||||
|
const copied = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||||
|
)
|
||||||
|
const created = page.waitForRequest(
|
||||||
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/api/session",
|
||||||
|
)
|
||||||
|
const sent = page.waitForRequest(
|
||||||
|
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||||
|
)
|
||||||
|
await page.getByRole("button", { name: "Send", exact: true }).click()
|
||||||
|
|
||||||
|
const copyRequest = await copied
|
||||||
|
expect(new URL(copyRequest.url()).searchParams.get("location[directory]")).toBe(root)
|
||||||
|
expect(copyRequest.postDataJSON()).toEqual({ strategy: "git_worktree", directory: "C:/OpenCode" })
|
||||||
|
releaseCopy()
|
||||||
|
|
||||||
|
expect((await created).postDataJSON()).toEqual({
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "test", providerID: "opencode" },
|
||||||
|
location: { directory: createdWorkspace },
|
||||||
|
})
|
||||||
|
const promptRequest = await sent
|
||||||
|
expect(promptRequest.postDataJSON()).toEqual({
|
||||||
|
id: expect.stringMatching(/^msg_/),
|
||||||
|
text: "Build workspace support",
|
||||||
|
files: [],
|
||||||
|
agents: [],
|
||||||
|
})
|
||||||
|
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const sessionID = "ses_workspace_move_new"
|
||||||
|
const messageID = "msg_workspace_move_new"
|
||||||
|
const currentSession = session(sessionID, root, "Create a workspace")
|
||||||
|
const transport = await installSseTransport<OpenCodeEvent>(page, { server })
|
||||||
|
let releaseCopy = () => {}
|
||||||
|
const copyReady = new Promise<void>((resolve) => {
|
||||||
|
releaseCopy = resolve
|
||||||
|
})
|
||||||
|
let releaseMove = () => {}
|
||||||
|
const moveReady = new Promise<void>((resolve) => {
|
||||||
|
releaseMove = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
|
directory: root,
|
||||||
|
project,
|
||||||
|
provider,
|
||||||
|
sessions: [currentSession],
|
||||||
|
pageMessages: () => ({ items: [userMessage(messageID, "Create isolated workspace")] }),
|
||||||
|
vcsDiff: [diff],
|
||||||
|
})
|
||||||
|
await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors })
|
||||||
|
if (request.method() !== "POST") return route.fallback()
|
||||||
|
await copyReady
|
||||||
|
await json(route, { directory: createdWorkspace })
|
||||||
|
})
|
||||||
|
await page.route(`**/api/session/${sessionID}**`, async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
if (request.method() === "OPTIONS" && url.pathname === `/api/session/${sessionID}/move`)
|
||||||
|
return route.fulfill({ status: 204, headers: cors })
|
||||||
|
if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`)
|
||||||
|
return json(route, { data: currentSession })
|
||||||
|
if (request.method() !== "POST" || url.pathname !== `/api/session/${sessionID}/move`)
|
||||||
|
return route.fallback()
|
||||||
|
await moveReady
|
||||||
|
currentSession.location.directory = createdWorkspace
|
||||||
|
await route.fulfill({ status: 204, headers: cors })
|
||||||
|
})
|
||||||
|
await init(page, { type: "session", sessionId: sessionID })
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||||
|
await page.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||||
|
|
||||||
|
const copied = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`,
|
||||||
|
)
|
||||||
|
const moved = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/move`,
|
||||||
|
)
|
||||||
|
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||||
|
|
||||||
|
await copied
|
||||||
|
await expect(page.getByText("Creating workspace", { exact: true })).toBeVisible()
|
||||||
|
releaseCopy()
|
||||||
|
|
||||||
|
const moveRequest = await moved
|
||||||
|
expect(moveRequest.postDataJSON()).toEqual({ directory: createdWorkspace })
|
||||||
|
await transport.send({
|
||||||
|
id: "evt_workspace_created",
|
||||||
|
created: 3,
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||||
|
location: { directory: root },
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
location: { directory: createdWorkspace },
|
||||||
|
subpath: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
releaseMove()
|
||||||
|
await expect(page.getByText("Workspace created", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||||
"./regression/new-session-panel-corner.spec.ts",
|
"./regression/new-session-panel-corner.spec.ts",
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
"./regression/session-timeline-context-resize.spec.ts",
|
||||||
|
"./regression/workspaces.spec.ts",
|
||||||
"./utils/**/*.ts"
|
"./utils/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (path === "/api/project") return json(route, [config.project])
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
|
if (/^\/api\/project\/[^/]+\/directories$/.test(path))
|
||||||
|
return json(route, [
|
||||||
|
{ directory: config.directory },
|
||||||
|
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
|
||||||
|
directory,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
})),
|
||||||
|
])
|
||||||
if (path === "/api/location") return json(route, location(config))
|
if (path === "/api/location") return json(route, location(config))
|
||||||
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
|
||||||
if (projectCopy && route.request().method() === "POST") {
|
if (projectCopy && route.request().method() === "POST") {
|
||||||
@@ -243,7 +251,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
const sessions = config.sessions
|
const sessions = config.sessions
|
||||||
.filter((session) => !directory || session.directory === directory)
|
.filter((session) => {
|
||||||
|
const location = session.location as { directory?: string } | undefined
|
||||||
|
return !directory || location?.directory === directory || session.directory === directory
|
||||||
|
})
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
@@ -466,6 +477,7 @@ function currentPermission(value: unknown) {
|
|||||||
|
|
||||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
|
const location = session.location && typeof session.location === "object" ? session.location : {}
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
parentID: session.parentID,
|
parentID: session.parentID,
|
||||||
@@ -483,10 +495,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
|||||||
},
|
},
|
||||||
title: session.title ?? session.id,
|
title: session.title ?? session.id,
|
||||||
location: {
|
location: {
|
||||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
directory:
|
||||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
"directory" in location && typeof location.directory === "string"
|
||||||
|
? location.directory
|
||||||
|
: typeof session.directory === "string"
|
||||||
|
? session.directory
|
||||||
|
: fallbackDirectory,
|
||||||
|
...(typeof session.workspaceID === "string"
|
||||||
|
? { workspaceID: session.workspaceID }
|
||||||
|
: "workspaceID" in location && typeof location.workspaceID === "string"
|
||||||
|
? { workspaceID: location.workspaceID }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
subpath: session.path,
|
subpath: session.subpath ?? session.path,
|
||||||
revert: session.revert,
|
revert: session.revert,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,23 +29,38 @@ export type SseEventOptions = {
|
|||||||
|
|
||||||
export type SseTransport<T> = {
|
export type SseTransport<T> = {
|
||||||
server: string
|
server: string
|
||||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
waitForConnection(options?: {
|
||||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
after?: number
|
||||||
|
timeout?: number
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}): Promise<SseConnectionRecord>
|
||||||
|
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
|
||||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||||
close(): Promise<void>
|
close(): Promise<void>
|
||||||
disconnect(message?: string): Promise<void>
|
disconnect(message?: string): Promise<void>
|
||||||
error(message?: string): Promise<void>
|
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
|
||||||
connections(): Promise<SseConnectionRecord[]>
|
connections(): Promise<SseConnectionRecord[]>
|
||||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrowserCommand<T> =
|
type BrowserCommand<T> =
|
||||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
| {
|
||||||
|
type: "send"
|
||||||
|
deliveries: { payload: T; options?: SseEventOptions }[]
|
||||||
|
burst: boolean
|
||||||
|
cuts?: number[]
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
| {
|
||||||
|
type: "end"
|
||||||
|
mode: "close" | "disconnect" | "error"
|
||||||
|
message?: string
|
||||||
|
path?: SseConnectionRecord["path"]
|
||||||
|
}
|
||||||
| { type: "connections" }
|
| { type: "connections" }
|
||||||
| { type: "acknowledgements" }
|
| { type: "acknowledgements" }
|
||||||
|
|
||||||
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
|
|||||||
let nextConnectionID = 0
|
let nextConnectionID = 0
|
||||||
let nextDeliveryID = 0
|
let nextDeliveryID = 0
|
||||||
|
|
||||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
const current = (path?: SseConnectionRecord["path"]) =>
|
||||||
|
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
|
||||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||||
const boundaries = [...new Set(cuts ?? [])]
|
const boundaries = [...new Set(cuts ?? [])]
|
||||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||||
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
|
|||||||
acknowledgements.push(acknowledgement)
|
acknowledgements.push(acknowledgement)
|
||||||
return acknowledgement
|
return acknowledgement
|
||||||
}
|
}
|
||||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
|
||||||
const connection = current()
|
const connection = current(path)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
connection.endedAt = performance.now()
|
connection.endedAt = performance.now()
|
||||||
connection.endedBy = mode
|
connection.endedBy = mode
|
||||||
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
|
|||||||
if (input.type === "connections")
|
if (input.type === "connections")
|
||||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||||
if (input.type === "acknowledgements") return acknowledgements
|
if (input.type === "acknowledgements") return acknowledgements
|
||||||
if (input.type === "end") return end(input.mode, input.message)
|
if (input.type === "end") return end(input.mode, input.message, input.path)
|
||||||
const connection = current()
|
const connection = current(input.type === "send" ? input.path : undefined)
|
||||||
if (!connection) throw new Error("SSE transport has no active connection")
|
if (!connection) throw new Error("SSE transport has no active connection")
|
||||||
if (input.type === "raw") {
|
if (input.type === "raw") {
|
||||||
marker(input.marker)
|
marker(input.marker)
|
||||||
@@ -235,12 +251,15 @@ export async function installSseTransport<T>(
|
|||||||
server,
|
server,
|
||||||
async waitForConnection(input = {}) {
|
async waitForConnection(input = {}) {
|
||||||
const connection = await page.waitForFunction(
|
const connection = await page.waitForFunction(
|
||||||
(after) => {
|
({ after, path }) => {
|
||||||
const transport = (window as BrowserTransport).__testSseTransport
|
const transport = (window as BrowserTransport).__testSseTransport
|
||||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||||
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
|
return connections?.findLast(
|
||||||
|
(connection) =>
|
||||||
|
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
input.after ?? 0,
|
{ after: input.after ?? 0, path: input.path },
|
||||||
{ timeout: input.timeout },
|
{ timeout: input.timeout },
|
||||||
)
|
)
|
||||||
let result: SseConnectionRecord | undefined
|
let result: SseConnectionRecord | undefined
|
||||||
@@ -252,8 +271,8 @@ export async function installSseTransport<T>(
|
|||||||
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
if (!result) throw new Error("SSE transport connection disappeared while waiting")
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
send(payload, eventOptions) {
|
send(payload, eventOptions, path) {
|
||||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
|
||||||
},
|
},
|
||||||
burst(payloads, eventOptions = []) {
|
burst(payloads, eventOptions = []) {
|
||||||
return command({
|
return command({
|
||||||
@@ -291,8 +310,8 @@ export async function installSseTransport<T>(
|
|||||||
disconnect(message) {
|
disconnect(message) {
|
||||||
return command({ type: "end", mode: "disconnect", message })
|
return command({ type: "end", mode: "disconnect", message })
|
||||||
},
|
},
|
||||||
error(message) {
|
error(message, path) {
|
||||||
return command({ type: "end", mode: "error", message })
|
return command({ type: "end", mode: "error", message, path })
|
||||||
},
|
},
|
||||||
connections() {
|
connections() {
|
||||||
return command({ type: "connections" })
|
return command({ type: "connections" })
|
||||||
|
|||||||
+10
-28
@@ -1,6 +1,7 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import * as Sentry from "@sentry/solid"
|
import * as Sentry from "@sentry/solid"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
|
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
@@ -51,10 +52,9 @@ import { DirectoryDataProvider } from "@/pages/directory-layout"
|
|||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, sessionHref } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { TargetSessionRoute } from "@/pages/session-lazy"
|
||||||
import { TargetSessionRouteContent } from "@/pages/session"
|
|
||||||
import { Home } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
@@ -75,30 +75,6 @@ const DirectoryDraftRedirect = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
|
||||||
const global = useGlobal()
|
|
||||||
const conn = createMemo(() => {
|
|
||||||
const key = requireServerKey(params.serverKey)
|
|
||||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
|
||||||
<ServerSDKProvider server={conn}>
|
|
||||||
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const TargetSessionRoute = () => (
|
|
||||||
<TargetServerRoute>
|
|
||||||
<TargetSessionRouteContent />
|
|
||||||
</TargetServerRoute>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell for that server.
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
@@ -156,7 +132,13 @@ function UiI18nBridge(props: ParentProps) {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<I18nProvider
|
<I18nProvider
|
||||||
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
|
value={{
|
||||||
|
locale: language.intl,
|
||||||
|
layoutLocale: language.layoutLocale,
|
||||||
|
t: language.t as UiI18n["t"],
|
||||||
|
plural: language.plural,
|
||||||
|
pluralForm: language.pluralForm,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
.project-settings-v2-dialog [data-slot="dialog-container"] {
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-dialog [data-slot="dialog-body"] {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2 {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden !important;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-v2-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 40px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-page-header h2 {
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 640;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-page-header > span {
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extensions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||||
|
width: auto;
|
||||||
|
padding-inline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||||
|
> [data-slot="tabs-v2-list"]
|
||||||
|
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||||
|
> [data-slot="tabs-v2-list"]
|
||||||
|
[data-slot="tabs-v2-trigger"] {
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-tabs [data-slot="tabs-v2-content"] {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-section-header > :last-child {
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-link {
|
||||||
|
color: var(--v2-text-text-accent) !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-card {
|
||||||
|
padding-inline: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0.5px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-extension-row-status-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--v2-state-fg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-shared {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-shared-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 530;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-shared-chevron {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
transition: transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-shared-chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-settings-shared-count {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--v2-background-bg-layer-02);
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
@@ -1,156 +1,223 @@
|
|||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
|
||||||
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { For, Show } from "solid-js"
|
import { For, Show, createSignal, startTransition } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||||
|
import { SettingsServerDataScope } from "./settings-server-picker"
|
||||||
|
import "./settings-v2/settings-v2.css"
|
||||||
|
import "./dialog-edit-project-v2.css"
|
||||||
|
|
||||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
return (
|
||||||
|
<SettingsServerDataScope server={props.server}>
|
||||||
|
<SDKProvider directory={props.project.worktree}>
|
||||||
|
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||||
|
</SDKProvider>
|
||||||
|
</SettingsServerDataScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectSettingsDialog(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const model = createEditProjectModel(props)
|
const model = createEditProjectModel(props)
|
||||||
|
const projectName = () => displayName(props.project)
|
||||||
|
const [tab, setTab] = createSignal("general")
|
||||||
|
|
||||||
|
const Footer = () => (
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
||||||
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog fit>
|
<Dialog size="x-large" variant="settings" class="project-settings-v2-dialog">
|
||||||
<form onSubmit={model.submit} class="contents">
|
<TabsV2
|
||||||
<DialogHeader>
|
orientation="vertical"
|
||||||
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
variant="settings"
|
||||||
</DialogHeader>
|
value={tab()}
|
||||||
<DividerV2 />
|
onChange={(value) => void startTransition(() => setTab(value))}
|
||||||
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
class="project-settings-v2"
|
||||||
<Field>
|
>
|
||||||
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
<TabsV2.List>
|
||||||
<TextInputV2
|
<div class="project-settings-v2-nav">
|
||||||
autofocus
|
<TabsV2.Trigger value="general">
|
||||||
appearance="large"
|
<ProjectAvatar
|
||||||
class="!w-full"
|
fallback={projectName()}
|
||||||
value={model.store.name}
|
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||||
placeholder={model.folderName()}
|
class="!size-4 shrink-0"
|
||||||
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
{language.t("dialog.project.edit.icon")}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={language.t("dialog.project.edit.icon.alt")}
|
|
||||||
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
|
||||||
classList={{
|
|
||||||
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => model.setStore("iconHover", true)}
|
|
||||||
onMouseLeave={() => model.setStore("iconHover", false)}
|
|
||||||
onDrop={model.drop}
|
|
||||||
onDragOver={model.dragOver}
|
|
||||||
onDragLeave={model.dragLeave}
|
|
||||||
onClick={model.iconClick}
|
|
||||||
>
|
|
||||||
<ProjectAvatar
|
|
||||||
fallback={model.store.name || model.defaultName()}
|
|
||||||
src={getProjectAvatarSource(props.project.id, {
|
|
||||||
color: model.store.color,
|
|
||||||
url: props.project.icon?.url,
|
|
||||||
override: model.store.iconOverride,
|
|
||||||
})}
|
|
||||||
variant={getProjectAvatarVariant(model.store.color)}
|
|
||||||
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
|
||||||
classList={{
|
|
||||||
"opacity-100": model.store.iconHover,
|
|
||||||
"opacity-0": !model.store.iconHover,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
ref={(element) => {
|
|
||||||
model.setIconInput(element)
|
|
||||||
}}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onChange={model.inputChange}
|
|
||||||
/>
|
/>
|
||||||
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
<span class="truncate">{projectName()}</span>
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
</TabsV2.Trigger>
|
||||||
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
<TabsV2.Trigger value="scripts">
|
||||||
</div>
|
<Icon name="code" size="small" />
|
||||||
</div>
|
{language.t("project.settings.scripts")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="extensions">
|
||||||
|
<Icon name="extensions" size="small" />
|
||||||
|
{language.t("settings.tab.extensions")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsV2.List>
|
||||||
|
|
||||||
<Show when={!model.store.iconOverride}>
|
<TabsV2.Content value="general" class="project-settings-v2-panel">
|
||||||
<div class="flex w-full flex-col gap-2">
|
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||||
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
<div class="project-settings-v2-scroll">
|
||||||
{language.t("dialog.project.edit.color")}
|
<div class="project-settings-page-header">
|
||||||
|
<h2>{language.t("dialog.project.edit.title")}</h2>
|
||||||
|
<span>{language.t("project.settings.general.description")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="-ml-1 flex gap-1.5">
|
|
||||||
<For each={PROJECT_AVATAR_VARIANTS}>
|
<Field>
|
||||||
{(color) => (
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
<button
|
<TextInputV2
|
||||||
type="button"
|
autofocus
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
appearance="large"
|
||||||
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
class="!w-full"
|
||||||
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
value={model.store.name}
|
||||||
|
placeholder={model.folderName()}
|
||||||
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.icon")}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
"opacity-100": model.store.iconHover,
|
||||||
getProjectAvatarVariant(model.store.color) === color,
|
"opacity-0": !model.store.iconHover,
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
|
||||||
model.setStore(
|
|
||||||
"color",
|
|
||||||
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectAvatar
|
<IconV2 name={model.store.iconOverride ? "close" : "share"} />
|
||||||
fallback={model.store.name || model.defaultName()}
|
</span>
|
||||||
variant={getProjectAvatarVariant(color)}
|
</button>
|
||||||
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
<input
|
||||||
/>
|
ref={(element) => model.setIconInput(element)}
|
||||||
</button>
|
type="file"
|
||||||
)}
|
accept="image/*"
|
||||||
</For>
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Field>
|
<Show when={!model.store.iconOverride}>
|
||||||
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
<div class="flex w-full flex-col gap-2">
|
||||||
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
<TextareaV2
|
{language.t("dialog.project.edit.color")}
|
||||||
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
</div>
|
||||||
rows={3}
|
<div class="-ml-1 flex gap-1.5">
|
||||||
value={model.store.startup}
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
{(color) => (
|
||||||
spellcheck={false}
|
<button
|
||||||
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
type="button"
|
||||||
/>
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
</Field>
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
</DialogBody>
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
<DialogFooter>
|
classList={{
|
||||||
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
{language.t("common.cancel")}
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
</ButtonV2>
|
}}
|
||||||
<ButtonV2 type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
|
onClick={() => {
|
||||||
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
</ButtonV2>
|
model.setStore(
|
||||||
</DialogFooter>
|
"color",
|
||||||
</form>
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
variant={getProjectAvatarVariant(color)}
|
||||||
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</form>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="scripts" class="project-settings-v2-panel">
|
||||||
|
<form onSubmit={model.submit} class="project-settings-v2-form">
|
||||||
|
<div class="project-settings-v2-scroll">
|
||||||
|
<div class="project-settings-page-header">
|
||||||
|
<h2>{language.t("project.settings.scripts")}</h2>
|
||||||
|
<span>{language.t("project.settings.scripts.description")}</span>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
|
<TextareaV2
|
||||||
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
|
rows={5}
|
||||||
|
value={model.store.startup}
|
||||||
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</form>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="extensions" class="project-settings-v2-panel">
|
||||||
|
<ProjectSettingsExtensions />
|
||||||
|
</TabsV2.Content>
|
||||||
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
|
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { ExternalLink } from "./external-link"
|
||||||
|
|
||||||
|
type SkillItem = {
|
||||||
|
name: string
|
||||||
|
location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||||
|
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||||
|
|
||||||
|
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||||
|
<div class="project-settings-extension-card">{props.children as any}</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ExtensionRow: Component<{
|
||||||
|
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
children?: unknown
|
||||||
|
}> = (props) => (
|
||||||
|
<div class="project-settings-extension-row">
|
||||||
|
<div class="project-settings-extension-row-main">
|
||||||
|
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||||
|
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={props.status}>
|
||||||
|
{(status) => (
|
||||||
|
<span class="project-settings-extension-row-status">
|
||||||
|
<span class="project-settings-extension-row-status-dot" />
|
||||||
|
{status()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
{props.children as any}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SharedSection: Component<{
|
||||||
|
count: number
|
||||||
|
children: unknown
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [open, setOpen] = createSignal(false)
|
||||||
|
return (
|
||||||
|
<Show when={props.count > 0}>
|
||||||
|
<div class="project-settings-shared">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="project-settings-shared-trigger"
|
||||||
|
aria-expanded={open()}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
|
||||||
|
<span>{language.t("project.settings.extensions.shared")}</span>
|
||||||
|
<span class="project-settings-shared-count">{props.count}</span>
|
||||||
|
</button>
|
||||||
|
<Show when={open()}>
|
||||||
|
<ExtensionCard>{props.children}</ExtensionCard>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectSettingsExtensions: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const directorySDK = useSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const sync = useSync()
|
||||||
|
const toggleMcp = useMcpToggle()
|
||||||
|
|
||||||
|
const [serverMcp] = createResource(
|
||||||
|
serverSDK,
|
||||||
|
(sdk) =>
|
||||||
|
sdk.api.mcp
|
||||||
|
.list()
|
||||||
|
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||||
|
.catch(() => ({})),
|
||||||
|
{ initialValue: {} },
|
||||||
|
)
|
||||||
|
const globalMcpNames = createMemo(() =>
|
||||||
|
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||||
|
)
|
||||||
|
const projectMcpNames = createMemo(() => {
|
||||||
|
const shared = new Set(globalMcpNames())
|
||||||
|
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||||
|
if (configured.length > 0) return configured.sort()
|
||||||
|
return Object.keys(sync().data.mcp ?? {})
|
||||||
|
.filter((name) => !shared.has(name))
|
||||||
|
.sort()
|
||||||
|
})
|
||||||
|
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||||
|
|
||||||
|
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
|
||||||
|
const projectPlugins = createMemo(() => {
|
||||||
|
const shared = new Set(globalPlugins())
|
||||||
|
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const [serverSkills] = createResource(
|
||||||
|
serverSDK,
|
||||||
|
(sdk): Promise<SkillItem[]> =>
|
||||||
|
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
const [directorySkills] = createResource(
|
||||||
|
directorySDK,
|
||||||
|
(sdk): Promise<SkillItem[]> =>
|
||||||
|
sdk.api.skill
|
||||||
|
.list({ location: { directory: sdk.directory } })
|
||||||
|
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||||
|
{ initialValue: [] },
|
||||||
|
)
|
||||||
|
const projectSkills = createMemo(() => {
|
||||||
|
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||||
|
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const mcpRows = (items: string[]) => (
|
||||||
|
<For each={items}>
|
||||||
|
{(name) => (
|
||||||
|
<ExtensionRow icon="mcp" name={name}>
|
||||||
|
<Switch
|
||||||
|
checked={mcpEnabled(name)}
|
||||||
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
|
hideLabel
|
||||||
|
onChange={() => {
|
||||||
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Switch>
|
||||||
|
</ExtensionRow>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
)
|
||||||
|
|
||||||
|
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="cube" name={name} />}</For>
|
||||||
|
|
||||||
|
const skillRows = (items: SkillItem[]) => (
|
||||||
|
<For each={items}>{(item) => <ExtensionRow icon="post-skill" name={item.name} />}</For>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="project-settings-extensions">
|
||||||
|
<div class="project-settings-page-header">
|
||||||
|
<h2>{language.t("settings.tab.extensions")}</h2>
|
||||||
|
<span>{language.t("project.settings.extensions.description")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
|
||||||
|
<TabsV2.List>
|
||||||
|
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||||
|
</TabsV2.List>
|
||||||
|
|
||||||
|
<TabsV2.Content value="mcps">
|
||||||
|
<div class="project-settings-extension-section">
|
||||||
|
<div class="project-settings-extension-section-header">
|
||||||
|
<span>{language.t("project.settings.extensions.added")}</span>
|
||||||
|
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={projectMcpNames().length > 0}>
|
||||||
|
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
|
||||||
|
</Show>
|
||||||
|
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="plugins">
|
||||||
|
<div class="project-settings-extension-section">
|
||||||
|
<div class="project-settings-extension-section-header">
|
||||||
|
<span>{language.t("project.settings.extensions.added")}</span>
|
||||||
|
<span>{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={projectPlugins().length > 0}>
|
||||||
|
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
|
||||||
|
</Show>
|
||||||
|
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="skills">
|
||||||
|
<div class="project-settings-extension-section">
|
||||||
|
<div class="project-settings-extension-section-header">
|
||||||
|
<span>{language.t("project.settings.extensions.added")}</span>
|
||||||
|
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
|
||||||
|
{language.t("settings.extensions.addSkills")}
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<Show when={projectSkills().length > 0}>
|
||||||
|
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||||
|
</Show>
|
||||||
|
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="lsps">
|
||||||
|
<div class="project-settings-extension-section">
|
||||||
|
<div class="project-settings-extension-section-header">
|
||||||
|
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||||
|
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={sync().data.lsp.length > 0}>
|
||||||
|
<ExtensionCard>
|
||||||
|
<For each={sync().data.lsp}>
|
||||||
|
{(item) => (
|
||||||
|
<ExtensionRow
|
||||||
|
icon="code"
|
||||||
|
name={item.name || item.id}
|
||||||
|
status={
|
||||||
|
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ExtensionCard>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
</TabsV2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
|
|||||||
class?: string
|
class?: string
|
||||||
controller: PromptInputV2ComposerController
|
controller: PromptInputV2ComposerController
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
|
accentSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
||||||
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<PromptInputV2
|
<PromptInputV2
|
||||||
controller={props.controller}
|
controller={props.controller}
|
||||||
|
accentSubmit={props.accentSubmit}
|
||||||
borderUnderlay={props.borderUnderlay}
|
borderUnderlay={props.borderUnderlay}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
variantControlVisible={!props.controller.model.loading}
|
variantControlVisible={!props.controller.model.loading}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import type { ModelSelection } from "@/context/local"
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const sessionCreateInputs: Array<{
|
type SessionCreateInput = {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}> = []
|
}
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -22,11 +21,9 @@ const optimistic: Array<{
|
|||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
const optimisticSeeded: boolean[] = []
|
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
|
||||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const sentShellDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
const sentPrompts: string[] = []
|
const sentPrompts: string[] = []
|
||||||
const promptInputs: unknown[] = []
|
const promptInputs: unknown[] = []
|
||||||
@@ -37,15 +34,29 @@ const switchedModels: Array<{
|
|||||||
model: { id: string; providerID: string; variant?: string }
|
model: { id: string; providerID: string; variant?: string }
|
||||||
}> = []
|
}> = []
|
||||||
const sessionRequestOrder: string[] = []
|
const sessionRequestOrder: string[] = []
|
||||||
const commands: Array<{ name: string }> = []
|
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||||
|
const syncedServers: string[] = []
|
||||||
|
const optimisticServers: string[] = []
|
||||||
|
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
let createWorktreeGate: Promise<void> | undefined
|
||||||
|
let worktreeFailure: Error | undefined
|
||||||
|
let worktreeHung = false
|
||||||
|
let worktreeCreates = 0
|
||||||
|
let activeSDK = "server-a"
|
||||||
|
let activeServerSync = "server-a"
|
||||||
|
let activeDirectorySync = "server-a"
|
||||||
|
let commands: Array<{ name: string }> = []
|
||||||
|
let worktreeDirectory = "/repo/new-0"
|
||||||
|
let worktreeID = 0
|
||||||
|
const draftServers: Record<string, string> = {}
|
||||||
|
const sessionDirectories: Record<string, string> = {}
|
||||||
|
|
||||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
@@ -73,21 +84,25 @@ const prompt = {
|
|||||||
replaceComments: () => undefined,
|
replaceComments: () => undefined,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
},
|
},
|
||||||
capture: () => prompt,
|
capture: (scope?: unknown, target?: unknown) => {
|
||||||
|
promptCaptures.push({ scope, target })
|
||||||
|
return prompt
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
session: {
|
session: {
|
||||||
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
create: async (input: SessionCreateInput) => {
|
||||||
await createSessionGate
|
await createSessionGate
|
||||||
const location = input.location?.directory ?? directory
|
const location = input.location?.directory ?? directory
|
||||||
createdSessions.push(location)
|
createdSessions.push(location)
|
||||||
sessionCreateInputs.push(input)
|
const id = `session-${createdSessions.length}`
|
||||||
|
sessionDirectories[id] = location
|
||||||
return {
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -100,7 +115,7 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
prompt: async (input: unknown) => {
|
prompt: async (input: unknown) => {
|
||||||
sessionRequestOrder.push("prompt")
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(directory)
|
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
@@ -120,6 +135,19 @@ const clientFor = (directory: string) => {
|
|||||||
},
|
},
|
||||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
sentShell.push(input)
|
sentShell.push(input)
|
||||||
|
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
projectCopy: {
|
||||||
|
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
|
||||||
|
worktreeCreates++
|
||||||
|
if (worktreeHung)
|
||||||
|
return new Promise<never>((_, reject) => {
|
||||||
|
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
|
||||||
|
})
|
||||||
|
await createWorktreeGate
|
||||||
|
if (worktreeFailure) throw worktreeFailure
|
||||||
|
return { directory: worktreeDirectory }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -127,9 +155,6 @@ const clientFor = (directory: string) => {
|
|||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
worktree: {
|
|
||||||
create: async () => ({ data: { directory: `${directory}/new` } }),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +170,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@opencode-ai/ui/toast", () => ({
|
mock.module("@opencode-ai/ui/toast", () => ({
|
||||||
Toast: { Region: () => null },
|
Toast: { Region: () => null },
|
||||||
|
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
|
||||||
showToast: () => 0,
|
showToast: () => 0,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -162,20 +188,13 @@ beforeAll(async () => {
|
|||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
promote(directory: string, sessionID: string) {
|
promote: () => undefined,
|
||||||
promoted.push({ directory, sessionID })
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => {
|
mock.module("@/context/permission", () => {
|
||||||
const state = (server: string) => ({
|
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
enabledAutoAccept.push({ server, sessionID, directory })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
@@ -184,7 +203,10 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/tabs", () => ({
|
mock.module("@/context/tabs", () => ({
|
||||||
useTabs: () => ({
|
useTabs: () => ({
|
||||||
draft: () => ({ server: "project-server" }),
|
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||||
|
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||||
|
updatedDrafts.push({ draftID, ...draft })
|
||||||
|
},
|
||||||
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
|
||||||
promotedDrafts.push({ draftID, ...session })
|
promotedDrafts.push({ draftID, ...session })
|
||||||
},
|
},
|
||||||
@@ -205,68 +227,70 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@/context/sdk", () => ({
|
mock.module("@/context/sdk", () => ({
|
||||||
useSDK: () => {
|
useSDK: () => {
|
||||||
const sdk = {
|
return () => ({
|
||||||
scope: "local",
|
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
|
||||||
directory: "/repo/main",
|
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
|
||||||
api: rootClient.api,
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
}
|
})
|
||||||
return () => sdk
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => {
|
||||||
data: { command: commands },
|
const server = activeDirectorySync
|
||||||
session: {
|
return {
|
||||||
optimistic: {
|
data: { command: commands, project: "project" },
|
||||||
add: (value: {
|
session: {
|
||||||
directory?: string
|
optimistic: {
|
||||||
sessionID?: string
|
add: (value: {
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
directory?: string
|
||||||
}) => {
|
sessionID?: string
|
||||||
optimistic.push(value)
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
optimisticSeeded.push(
|
}) => {
|
||||||
!!value.directory &&
|
optimisticServers.push(server)
|
||||||
!!value.sessionID &&
|
optimistic.push(value)
|
||||||
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
},
|
||||||
)
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
|
||||||
},
|
},
|
||||||
},
|
set: () => undefined,
|
||||||
set: () => undefined,
|
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/server-sync", () => ({
|
mock.module("@/context/server-sync", () => ({
|
||||||
useServerSync: () => () => ({
|
useServerSync: () => () => {
|
||||||
session: {
|
const server = activeServerSync
|
||||||
remember: () => undefined,
|
return {
|
||||||
set: () => undefined,
|
session: {
|
||||||
sync: async () => {
|
remember: () => undefined,
|
||||||
serverSessionSyncs++
|
set: () => undefined,
|
||||||
},
|
sync: async () => {
|
||||||
},
|
serverSessionSyncs++
|
||||||
child: (directory: string) => {
|
|
||||||
syncedDirectories.push(directory)
|
|
||||||
storedSessions[directory] ??= []
|
|
||||||
return [
|
|
||||||
{ session: storedSessions[directory] },
|
|
||||||
(...args: unknown[]) => {
|
|
||||||
if (args[0] !== "session") return
|
|
||||||
const next = args[1]
|
|
||||||
if (typeof next === "function") {
|
|
||||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Array.isArray(next)) {
|
|
||||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
]
|
},
|
||||||
},
|
child: (directory: string) => {
|
||||||
}),
|
syncedServers.push(server)
|
||||||
|
storedSessions[directory] ??= []
|
||||||
|
return [
|
||||||
|
{ session: storedSessions[directory] },
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
if (args[0] !== "session") return
|
||||||
|
const next = args[1]
|
||||||
|
if (typeof next === "function") {
|
||||||
|
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
@@ -286,205 +310,159 @@ beforeAll(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
sessionCreateInputs.length = 0
|
|
||||||
enabledAutoAccept.length = 0
|
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
|
||||||
promoted.length = 0
|
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
updatedDrafts.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
sentPrompts.length = 0
|
sentPrompts.length = 0
|
||||||
promptInputs.length = 0
|
promptInputs.length = 0
|
||||||
sentCommands.length = 0
|
|
||||||
switchedAgents.length = 0
|
switchedAgents.length = 0
|
||||||
switchedModels.length = 0
|
switchedModels.length = 0
|
||||||
sessionRequestOrder.length = 0
|
sessionRequestOrder.length = 0
|
||||||
commands.length = 0
|
syncedServers.length = 0
|
||||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
optimisticServers.length = 0
|
||||||
|
promptCaptures.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
sentShellDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
activeSDK = "server-a"
|
||||||
|
activeServerSync = "server-a"
|
||||||
|
activeDirectorySync = "server-a"
|
||||||
|
commands = []
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
serverSessionSyncs = 0
|
serverSessionSyncs = 0
|
||||||
|
createWorktreeGate = undefined
|
||||||
|
worktreeFailure = undefined
|
||||||
|
worktreeHung = false
|
||||||
|
worktreeCreates = 0
|
||||||
|
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||||
|
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
|
||||||
|
createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
test("reads the latest worktree accessor value per submit", async () => {
|
test("admits only one concurrent new-workspace submission", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
let release = () => {}
|
||||||
info: () => undefined,
|
createWorktreeGate = new Promise<void>((resolve) => {
|
||||||
imageAttachments: () => [],
|
release = resolve
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
const submit = makeSubmit()
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const first = submit.handleSubmit(event)
|
||||||
|
const duplicate = submit.handleSubmit(event)
|
||||||
|
expect(worktreeCreates).toBe(1)
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
release()
|
||||||
selected = "/repo/worktree-b"
|
await Promise.all([first, duplicate])
|
||||||
await submit.handleSubmit(event)
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(createdClients).toEqual([])
|
expect(worktreeCreates).toBe(1)
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toHaveLength(1)
|
||||||
expect(sessionCreateInputs).toEqual([
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-a" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
agent: "agent",
|
|
||||||
model: { id: "model", providerID: "provider", variant: undefined },
|
|
||||||
location: { directory: "/repo/worktree-b" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(sentShell).toEqual([
|
|
||||||
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
expect(serverSessionSyncs).toBe(0)
|
|
||||||
expect(promoted).toEqual([
|
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
|
||||||
])
|
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("aborts a hung new-workspace request and allows retry", async () => {
|
||||||
const submit = createPromptSubmit({
|
selected = "create"
|
||||||
prompt,
|
worktreeHung = true
|
||||||
info: () => undefined,
|
let resets = 0
|
||||||
imageAttachments: () => [],
|
const submit = makeSubmit({
|
||||||
commentCount: () => 0,
|
onNewSessionWorktreeReset: () => resets++,
|
||||||
autoAccept: () => true,
|
worktreeRequestTimeoutMs: 1,
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(worktreeCreates).toBe(1)
|
||||||
|
expect(createdSessions).toEqual([])
|
||||||
|
expect(selected).toBe("create")
|
||||||
|
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
|
||||||
|
expect(resets).toBe(0)
|
||||||
|
|
||||||
|
worktreeHung = false
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(worktreeCreates).toBe(2)
|
||||||
|
expect(createdSessions).toEqual([worktreeDirectory])
|
||||||
|
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||||
|
expect(resets).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps auto-accept bound to the submission server", async () => {
|
test("keeps async submission effects bound to the initiating context", async () => {
|
||||||
|
search = { draftId: "draft-1" }
|
||||||
|
draftServers["draft-1"] = "project-server-a"
|
||||||
|
draftServers["draft-2"] = "project-server-b"
|
||||||
let release = () => {}
|
let release = () => {}
|
||||||
createSessionGate = new Promise<void>((resolve) => {
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
release = resolve
|
release = resolve
|
||||||
})
|
})
|
||||||
const submit = createPromptSubmit({
|
let submitted = 0
|
||||||
prompt,
|
const submit = makeSubmit({
|
||||||
info: () => undefined,
|
onSubmit: () => submitted++,
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => true,
|
|
||||||
mode: () => "shell",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
const result = submit.handleSubmit(event)
|
||||||
permissionServer = "server-b"
|
activeSDK = "server-b"
|
||||||
|
activeServerSync = "server-b"
|
||||||
|
activeDirectorySync = "server-b"
|
||||||
|
search.draftId = "draft-2"
|
||||||
release()
|
release()
|
||||||
await result
|
await result
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||||
})
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||||
|
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
expect(optimisticServers).toEqual(["server-a"])
|
||||||
search = { draftId: "draft-1" }
|
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||||
const submit = createPromptSubmit({
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
prompt,
|
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
|
||||||
info: () => undefined,
|
expect(submitted).toBe(0)
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches the selected agent and model before prompting", async () => {
|
test("switches the selected agent and model before prompting", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({
|
info: () => ({
|
||||||
id: "session-1",
|
id: "session-1",
|
||||||
agent: "old-agent",
|
agent: "old-agent",
|
||||||
model: { id: "old-model", providerID: "old-provider" },
|
model: { id: "old-model", providerID: "old-provider" },
|
||||||
}),
|
}),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
@@ -519,24 +497,12 @@ describe("prompt submit worktree selection", () => {
|
|||||||
commands.push({ name: "review" })
|
commands.push({ name: "review" })
|
||||||
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
const submit = createPromptSubmit({
|
const submit = makeSubmit({
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(sentCommands).toEqual([
|
expect(sentCommands).toEqual([
|
||||||
{
|
{
|
||||||
@@ -552,66 +518,20 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(serverSessionSyncs).toBe(0)
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("sends an initial shell after synchronous workspace creation", async () => {
|
||||||
params = { id: "session-1" }
|
selected = "create"
|
||||||
const model = {
|
const submit = makeSubmit({
|
||||||
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
mode: () => "shell",
|
||||||
variant: { current: () => "draft-variant" },
|
|
||||||
} as unknown as ModelSelection
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => ({ id: "session-1" }),
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
model,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
|
||||||
|
|
||||||
expect(optimistic[0]).toMatchObject({
|
|
||||||
message: {
|
|
||||||
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
|
||||||
const submit = createPromptSubmit({
|
|
||||||
prompt,
|
|
||||||
info: () => undefined,
|
|
||||||
imageAttachments: () => [],
|
|
||||||
commentCount: () => 0,
|
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
|
||||||
working: () => false,
|
|
||||||
editor: () => undefined,
|
|
||||||
queueScroll: () => undefined,
|
|
||||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
|
||||||
addToHistory: () => undefined,
|
|
||||||
resetHistoryNavigation: () => undefined,
|
|
||||||
setMode: () => undefined,
|
|
||||||
setPopover: () => undefined,
|
|
||||||
newSessionWorktree: () => selected,
|
|
||||||
onNewSessionWorktreeReset: () => undefined,
|
|
||||||
onSubmit: () => undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
expect(sentShellDirectories).toEqual([worktreeDirectory])
|
||||||
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
expect(sentShell[0]).toMatchObject({
|
||||||
expect(optimisticSeeded).toEqual([true])
|
sessionID: "session-1",
|
||||||
|
command: "ls",
|
||||||
|
})
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { useSync, type DirectorySync } from "@/context/sync"
|
|||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import { createPromptSubmissionState } from "./submission-state"
|
import { createPromptSubmissionState } from "./submission-state"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { blobDataUrl } from "@/utils/draft-store"
|
import { blobDataUrl } from "@/utils/draft-store"
|
||||||
@@ -28,9 +30,13 @@ import { blobDataUrl } from "@/utils/draft-store"
|
|||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
cleanup: VoidFunction
|
cleanup: VoidFunction
|
||||||
|
scope: ServerScope
|
||||||
|
sessionID: string
|
||||||
|
serverSync: ServerSync
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
const submitting = new Set<string>()
|
||||||
|
|
||||||
export type FollowupDraft = {
|
export type FollowupDraft = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -44,6 +50,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
|
scope: ServerScope
|
||||||
serverSync: ServerSync
|
serverSync: ServerSync
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||||
@@ -58,6 +65,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
|
|||||||
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||||
|
|
||||||
export async function sendFollowupDraft(input: FollowupSendInput) {
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
|
||||||
|
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
|
||||||
const text = draftText(input.draft.prompt)
|
const text = draftText(input.draft.prompt)
|
||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
@@ -248,6 +257,7 @@ type PromptSubmitInput = {
|
|||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
|
worktreeRequestTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
@@ -263,7 +273,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||||
|
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
|
||||||
|
|
||||||
const errorMessage = (err: unknown) => {
|
const errorMessage = (err: unknown) => {
|
||||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||||
@@ -276,18 +287,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const abort = async () => {
|
const abort = async () => {
|
||||||
const sessionID = params.id
|
const routeSessionID = params.id
|
||||||
|
const owned =
|
||||||
|
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
|
||||||
|
? pending.get(pendingSubmission.key)
|
||||||
|
: undefined
|
||||||
|
const sessionID = routeSessionID ?? owned?.sessionID
|
||||||
if (!sessionID) return Promise.resolve()
|
if (!sessionID) return Promise.resolve()
|
||||||
|
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
|
||||||
serverSync().session.set("todo", sessionID, [])
|
|
||||||
|
|
||||||
input.onAbort?.()
|
input.onAbort?.()
|
||||||
|
|
||||||
const key = pendingKey(sessionID)
|
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
|
||||||
const queued = pending.get(key)
|
const queued = owned ?? pending.get(key)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
queued.cleanup()
|
queued.cleanup()
|
||||||
|
WorkspaceOperation.fail(queued.scope, queued.sessionID)
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
@@ -319,9 +335,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seed = (dir: string, info: SessionInfo) => {
|
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||||
serverSync().session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = serverSync().child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
const result = Binary.search(list, info.id, (item) => item.id)
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
const next = [...list]
|
const next = [...list]
|
||||||
@@ -353,6 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (input.working()) void abort()
|
if (input.working()) void abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
|
||||||
|
|
||||||
const modelSelection = input.model ?? local.model
|
const modelSelection = input.model ?? local.model
|
||||||
const currentModel = modelSelection.current()
|
const currentModel = modelSelection.current()
|
||||||
@@ -366,284 +383,369 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
const submissionSDK = sdk()
|
||||||
input.resetHistoryNavigation()
|
const submissionSync = sync()
|
||||||
|
const submissionServerSync = serverSync()
|
||||||
const projectDirectory = sdk().directory
|
const submissionScope = submissionSDK.scope
|
||||||
|
const projectDirectory = submissionSDK.directory
|
||||||
|
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
|
||||||
|
const sessionID = params.id
|
||||||
|
const isNewSession = !sessionID
|
||||||
|
const currentSession = input.info()
|
||||||
|
const draftID = search.draftId
|
||||||
|
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||||
|
const capturePrompt = prompt.capture
|
||||||
|
const localSession = local.session
|
||||||
|
const handoff = layout.handoff
|
||||||
|
const resetWorktree = input.onNewSessionWorktreeReset
|
||||||
|
const onSubmit = input.onSubmit
|
||||||
const permissionState = permission.currentServerState()
|
const permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
const submissionKey = ScopedKey.from(
|
||||||
|
submissionScope,
|
||||||
|
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
|
||||||
|
)
|
||||||
|
if (submitting.has(submissionKey)) return
|
||||||
|
submitting.add(submissionKey)
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
try {
|
||||||
if (isNewSession) {
|
input.addToHistory(currentPrompt, mode)
|
||||||
if (worktreeSelection === "create") {
|
input.resetHistoryNavigation()
|
||||||
const createdWorktree = await sdk()
|
|
||||||
.api.projectCopy.create({
|
let sessionDirectory = projectDirectory
|
||||||
projectID: sync().data.project,
|
if (isNewSession) {
|
||||||
strategy: "git_worktree",
|
if (worktreeSelection === "create") {
|
||||||
directory: getDirectory(projectDirectory),
|
const createdWorktree = await workspaceRequestWithTimeout(
|
||||||
location: { directory: projectDirectory },
|
(signal) =>
|
||||||
|
submissionSDK.api.projectCopy.create(
|
||||||
|
{
|
||||||
|
projectID: submissionSync.data.project,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: getDirectory(projectDirectory),
|
||||||
|
location: { directory: projectDirectory },
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
description: errorMessage(err),
|
||||||
|
})
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!createdWorktree) return
|
||||||
|
WorktreeState.ready(submissionScope, createdWorktree.directory)
|
||||||
|
sessionDirectory = createdWorktree.directory
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||||
|
sessionDirectory = worktreeSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionDirectory !== projectDirectory) {
|
||||||
|
submissionServerSync.child(sessionDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = currentSession
|
||||||
|
if (!session && isNewSession) {
|
||||||
|
const created = await submissionSDK.api.session
|
||||||
|
.create({
|
||||||
|
agent: currentAgent.name,
|
||||||
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||||
|
location: { directory: sessionDirectory },
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (!createdWorktree) return
|
if (created) {
|
||||||
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
seed(submissionServerSync, sessionDirectory, created)
|
||||||
sessionDirectory = createdWorktree.directory
|
session = created
|
||||||
}
|
await startTransition(() => {
|
||||||
|
if (!session) return
|
||||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
|
||||||
sessionDirectory = worktreeSelection
|
if (!draftID) resetWorktree?.()
|
||||||
}
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
|
localSession.promote(sessionDirectory, session.id, {
|
||||||
if (sessionDirectory !== projectDirectory) {
|
agent: currentAgent.name,
|
||||||
serverSync().child(sessionDirectory)
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
}
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
input.onNewSessionWorktreeReset?.()
|
handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
}
|
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||||
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
let session = input.info()
|
submission.retarget(
|
||||||
if (!session && isNewSession) {
|
capturePrompt(
|
||||||
const created = await sdk()
|
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||||
.api.session.create({
|
{ server: draftServer, scope: submissionScope },
|
||||||
agent: currentAgent.name,
|
),
|
||||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
)
|
||||||
location: { directory: sessionDirectory },
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
})
|
||||||
return undefined
|
}
|
||||||
})
|
}
|
||||||
if (created) {
|
if (!session) {
|
||||||
seed(sessionDirectory, created)
|
showToast({
|
||||||
session = created
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
await startTransition(() => {
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
||||||
if (!session) return
|
})
|
||||||
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
return
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
|
||||||
agent: currentAgent.name,
|
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
|
||||||
variant: variant ?? null,
|
|
||||||
})
|
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
|
||||||
const draftID = search.draftId
|
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
||||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!session) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: language.t("prompt.toast.promptSendFailed.description"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
modelID: currentModel.id,
|
modelID: currentModel.id,
|
||||||
providerID: currentModel.provider.id,
|
providerID: currentModel.provider.id,
|
||||||
}
|
}
|
||||||
const agent = currentAgent.name
|
const agent = currentAgent.name
|
||||||
const draft: FollowupDraft = {
|
const draft: FollowupDraft = {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
sessionDirectory,
|
sessionDirectory,
|
||||||
prompt: currentPrompt,
|
prompt: currentPrompt,
|
||||||
context,
|
context,
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
variant,
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
submission.clear()
|
submission.clear()
|
||||||
input.setMode("normal")
|
input.setMode("normal")
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const restoreInput = () => {
|
||||||
const restored = submission.restore()
|
const restored = submission.restore()
|
||||||
if (!restored) return false
|
if (!restored) return false
|
||||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||||
if (!submission.current(prompt.capture())) return true
|
if (!submission.current(prompt.capture())) return true
|
||||||
input.setMode(mode)
|
input.setMode(mode)
|
||||||
input.setPopover(null)
|
input.setPopover(null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const editor = input.editor()
|
const editor = input.editor()
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
editor.focus()
|
editor.focus()
|
||||||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||||
input.queueScroll()
|
input.queueScroll()
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
input.onQueue?.(draft)
|
input.onQueue?.(draft)
|
||||||
clearContext(submission.target())
|
clearContext(submission.target())
|
||||||
clearInput()
|
clearInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onSubmit?.()
|
const startWorkspaceOperation = (messageID: string) => {
|
||||||
|
if (!isNewSession) return
|
||||||
|
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
|
||||||
|
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
|
||||||
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
}
|
||||||
|
if (worktreeSelection !== "create") return
|
||||||
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
|
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
|
||||||
|
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "shell") {
|
const waitForWorktree = async (cleanup: VoidFunction) => {
|
||||||
clearInput()
|
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
|
||||||
const eventID = Event.ID.create()
|
if (!worktree) return true
|
||||||
sdk()
|
if (worktree.status === "ready") {
|
||||||
.api.session.shell({
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (worktree.status === "failed") {
|
||||||
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
throw new Error(worktree.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
submissionSync.set("session_status", session.id, { type: "busy" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const key = pendingKey(submissionScope, session.id)
|
||||||
|
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
|
||||||
|
pending.set(key, {
|
||||||
|
abort: controller,
|
||||||
|
cleanup,
|
||||||
|
scope: submissionScope,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
id: eventID,
|
serverSync: submissionServerSync,
|
||||||
command: text,
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
showToast({
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
if (controller.signal.aborted) {
|
||||||
description: errorMessage(err),
|
resolve({ status: "failed", message: "aborted" })
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
controller.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
resolve({ status: "failed", message: "aborted" })
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeoutMs = 5 * 60 * 1000
|
||||||
|
const timer = { id: undefined as number | undefined }
|
||||||
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||||
|
timer.id = window.setTimeout(() => {
|
||||||
|
resolve({
|
||||||
|
status: "failed",
|
||||||
|
message: language.t("workspace.error.stillPreparing"),
|
||||||
|
})
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await Promise.race([
|
||||||
|
WorktreeState.wait(submissionScope, sessionDirectory),
|
||||||
|
abortWait,
|
||||||
|
timeout,
|
||||||
|
]).finally(() => {
|
||||||
|
pending.delete(key)
|
||||||
|
if (pendingSubmission?.key === key) pendingSubmission = undefined
|
||||||
|
if (timer.id === undefined) return
|
||||||
|
clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
if (controller.signal.aborted) return false
|
||||||
|
if (result.status === "failed") {
|
||||||
|
WorkspaceOperation.fail(submissionScope, session.id)
|
||||||
|
throw new Error(result.message)
|
||||||
|
}
|
||||||
|
WorkspaceOperation.complete(submissionScope, session.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftID || search.draftId === draftID) onSubmit?.()
|
||||||
|
|
||||||
|
if (mode === "shell") {
|
||||||
|
clearInput()
|
||||||
|
const eventID = Event.ID.create()
|
||||||
|
startWorkspaceOperation(eventID)
|
||||||
|
void waitForWorktree(() => {
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
.then((ready) => {
|
||||||
}
|
if (!ready) return
|
||||||
|
return submissionSDK.api.session.shell({
|
||||||
if (text.startsWith("/")) {
|
sessionID: session.id,
|
||||||
const [cmdName, ...args] = text.split(" ")
|
id: eventID,
|
||||||
const commandName = cmdName.slice(1)
|
command: text,
|
||||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
})
|
||||||
if (customCommand) {
|
|
||||||
clearInput()
|
|
||||||
const messageID = Identifier.ascending("message")
|
|
||||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
|
||||||
sdk()
|
|
||||||
.api.session.command({
|
|
||||||
sessionID: session.id,
|
|
||||||
id: messageID,
|
|
||||||
command: commandName,
|
|
||||||
arguments: args.join(" "),
|
|
||||||
agent,
|
|
||||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
|
||||||
files: await Promise.all(
|
|
||||||
images.map(async (attachment) => ({
|
|
||||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
|
||||||
name: attachment.filename,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
if (text.startsWith("/")) {
|
||||||
const messageID = Identifier.ascending("message")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
|
const commandName = cmdName.slice(1)
|
||||||
const removeOptimisticMessage = () => {
|
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||||
sync().session.optimistic.remove({
|
if (customCommand) {
|
||||||
directory: sessionDirectory,
|
clearInput()
|
||||||
sessionID: session.id,
|
const messageID = Identifier.ascending("message")
|
||||||
messageID,
|
startWorkspaceOperation(messageID)
|
||||||
})
|
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||||
}
|
void waitForWorktree(() => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
restoreInput()
|
||||||
clearInput()
|
})
|
||||||
|
.then(async (ready) => {
|
||||||
const waitForWorktree = async () => {
|
if (!ready) return
|
||||||
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
return submissionSDK.api.session.command({
|
||||||
if (!worktree || worktree.status !== "pending") return true
|
sessionID: session.id,
|
||||||
|
id: messageID,
|
||||||
if (sessionDirectory === projectDirectory) {
|
command: commandName,
|
||||||
sync().set("session_status", session.id, { type: "busy" })
|
arguments: args.join(" "),
|
||||||
|
agent,
|
||||||
|
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||||
|
files: await Promise.all(
|
||||||
|
images.map(async (attachment) => ({
|
||||||
|
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
|
name: attachment.filename,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
|
})
|
||||||
|
restoreInput()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
|
const messageID = Identifier.ascending("message")
|
||||||
|
startWorkspaceOperation(messageID)
|
||||||
|
|
||||||
|
const removeOptimisticMessage = () => {
|
||||||
|
submissionSync.session.optimistic.remove({
|
||||||
|
directory: sessionDirectory,
|
||||||
|
sessionID: session.id,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
|
clearInput()
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
removeOptimisticMessage()
|
removeOptimisticMessage()
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
void sendFollowupDraft({
|
||||||
|
api: submissionSDK.api.session,
|
||||||
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
scope: submissionScope,
|
||||||
if (controller.signal.aborted) {
|
sync: submissionSync,
|
||||||
resolve({ status: "failed", message: "aborted" })
|
serverSync: submissionServerSync,
|
||||||
return
|
session: () => session,
|
||||||
|
draft,
|
||||||
|
messageID,
|
||||||
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
|
before: () => waitForWorktree(cleanup),
|
||||||
|
}).catch((err) => {
|
||||||
|
pending.delete(pendingKey(submissionScope, session.id))
|
||||||
|
if (sessionDirectory === projectDirectory) {
|
||||||
|
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||||
}
|
}
|
||||||
controller.signal.addEventListener(
|
showToast({
|
||||||
"abort",
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
() => {
|
description: errorMessage(err),
|
||||||
resolve({ status: "failed", message: "aborted" })
|
})
|
||||||
},
|
removeOptimisticMessage()
|
||||||
{ once: true },
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
const timeoutMs = 5 * 60 * 1000
|
submitting.delete(submissionKey)
|
||||||
const timer = { id: undefined as number | undefined }
|
|
||||||
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
||||||
timer.id = window.setTimeout(() => {
|
|
||||||
resolve({
|
|
||||||
status: "failed",
|
|
||||||
message: language.t("workspace.error.stillPreparing"),
|
|
||||||
})
|
|
||||||
}, timeoutMs)
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await Promise.race([
|
|
||||||
WorktreeState.wait(sdk().scope, sessionDirectory),
|
|
||||||
abortWait,
|
|
||||||
timeout,
|
|
||||||
]).finally(() => {
|
|
||||||
if (timer.id === undefined) return
|
|
||||||
clearTimeout(timer.id)
|
|
||||||
})
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (controller.signal.aborted) return false
|
|
||||||
if (result.status === "failed") throw new Error(result.message)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendFollowupDraft({
|
|
||||||
api: sdk().api.session,
|
|
||||||
sync: sync(),
|
|
||||||
serverSync: serverSync(),
|
|
||||||
session: () => input.info() ?? session,
|
|
||||||
draft,
|
|
||||||
messageID,
|
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
|
||||||
before: waitForWorktree,
|
|
||||||
}).catch((err) => {
|
|
||||||
pending.delete(pendingKey(session.id))
|
|
||||||
if (sessionDirectory === projectDirectory) {
|
|
||||||
sync().set("session_status", session.id, { type: "idle" })
|
|
||||||
}
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
||||||
description: errorMessage(err),
|
|
||||||
})
|
|
||||||
removeOptimisticMessage()
|
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
projectRoot: string
|
projectRoot: string
|
||||||
workspaces: string[]
|
workspaces: string[]
|
||||||
branch?: string
|
branch?: string
|
||||||
|
onboarding?: boolean
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
|
onViewAll: () => void
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
let pending: string | undefined
|
const [search, setSearch] = createSignal("")
|
||||||
|
let searchInput: HTMLInputElement | undefined
|
||||||
|
let focusSearch = false
|
||||||
|
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
|
||||||
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
|
||||||
|
const workspaces = createMemo(() => {
|
||||||
|
const query = search().trim().toLowerCase()
|
||||||
|
if (!query) return props.workspaces
|
||||||
|
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
|
||||||
|
})
|
||||||
const icon = () => {
|
const icon = () => {
|
||||||
if (selected() === "main") return "monitor"
|
if (selected() === "main") return "monitor"
|
||||||
if (selected() === "create") return "workspace-new"
|
if (selected() === "create") return "workspace-new"
|
||||||
return "workspace"
|
return "workspace-isolated"
|
||||||
}
|
}
|
||||||
const select = (value: string) => {
|
const select = (value: string) => {
|
||||||
pending = value
|
pending = { type: "select", value }
|
||||||
}
|
}
|
||||||
const onOpenChange = (open: boolean) => {
|
const onOpenChange = (open: boolean) => {
|
||||||
if (open) return
|
if (open) {
|
||||||
const value = pending
|
setSearch("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const action = pending
|
||||||
pending = undefined
|
pending = undefined
|
||||||
if (value) props.onChange(value)
|
if (action?.type === "select") props.onChange(action.value)
|
||||||
|
if (action?.type === "viewAll") {
|
||||||
|
props.onViewAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
props.onDone()
|
props.onDone()
|
||||||
}
|
}
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -41,87 +57,220 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
<TooltipV2
|
||||||
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
|
placement="top"
|
||||||
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
openDelay={800}
|
||||||
<span class="min-w-0 truncate">{label()}</span>
|
value={
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
props.onboarding ? (
|
||||||
</MenuV2.Trigger>
|
<div class="flex flex-col gap-1 text-left">
|
||||||
<MenuV2.Portal>
|
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||||
<MenuV2.Content class="w-[180px]">
|
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||||
<MenuV2.Group>
|
<span>{language.t("workspace.onboarding.title")}</span>
|
||||||
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
</div>
|
||||||
<MenuV2.Item onSelect={() => select("main")}>
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
<IconV2 name="monitor" />
|
{language.t("workspace.onboarding.description")}
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
|
</span>
|
||||||
<Show when={selected() === "main"}>
|
</div>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
) : (
|
||||||
</Show>
|
language.t("session.new.workspace.trigger.tooltip")
|
||||||
</MenuV2.Item>
|
)
|
||||||
<MenuV2.Item onSelect={() => select("create")}>
|
}
|
||||||
<IconV2 name="workspace-new" />
|
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
class="min-w-0"
|
||||||
<Show when={selected() === "create"}>
|
>
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||||
</Show>
|
<MenuV2.Trigger
|
||||||
</MenuV2.Item>
|
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||||
</MenuV2.Group>
|
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||||
<Show when={props.workspaces.length > 0}>
|
>
|
||||||
<MenuV2.Separator />
|
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
<span class="min-w-0 truncate">{label()}</span>
|
||||||
<MenuV2.SubTrigger>
|
<Show when={props.onboarding}>
|
||||||
<IconV2 name="workspace" />
|
<span
|
||||||
{language.t("session.new.workspace.existing")}
|
data-slot="workspace-onboarding-dot"
|
||||||
</MenuV2.SubTrigger>
|
aria-hidden="true"
|
||||||
<MenuV2.Portal>
|
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
|
||||||
<MenuV2.SubContent class="max-w-[200px]">
|
/>
|
||||||
<For each={props.workspaces}>
|
|
||||||
{(workspace) => (
|
|
||||||
<MenuV2.Item onSelect={() => select(workspace)}>
|
|
||||||
<IconV2 name="workspace-isolated" />
|
|
||||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
|
||||||
<Show when={selected() === workspace}>
|
|
||||||
<Icon name="check" size="small" class="shrink-0" />
|
|
||||||
</Show>
|
|
||||||
</MenuV2.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</MenuV2.SubContent>
|
|
||||||
</MenuV2.Portal>
|
|
||||||
</MenuV2.Sub>
|
|
||||||
</Show>
|
</Show>
|
||||||
</MenuV2.Content>
|
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
</MenuV2.Portal>
|
</MenuV2.Trigger>
|
||||||
</MenuV2>
|
<MenuV2.Portal>
|
||||||
<PromptGitStatus branch={props.branch} />
|
<MenuV2.Content class="w-[200px]">
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
|
||||||
|
<MenuV2.Item onSelect={() => select("main")}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
openDelay={800}
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("session.new.workspace.local")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.local.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "main"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
<MenuV2.Item onSelect={() => select("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
<TooltipV2
|
||||||
|
placement="right"
|
||||||
|
openDelay={800}
|
||||||
|
value={
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span>{language.t("workspace.new")}</span>
|
||||||
|
<span class="font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("session.new.workspace.new.tooltip")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||||
|
</TooltipV2>
|
||||||
|
<Show when={selected() === "create"}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<Show
|
||||||
|
when={props.workspaces.length > 0}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Sub
|
||||||
|
gutter={0}
|
||||||
|
overlap
|
||||||
|
overflowPadding={8}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
focusSearch = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!focusSearch || props.workspaces.length < 10) return
|
||||||
|
focusSearch = false
|
||||||
|
requestAnimationFrame(() => searchInput?.focus())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuV2.SubTrigger
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "ArrowRight" ||
|
||||||
|
event.key === "ArrowLeft" ||
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " "
|
||||||
|
)
|
||||||
|
focusSearch = true
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<Show when={props.workspaces.length >= 10}>
|
||||||
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
|
||||||
|
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
searchInput = element
|
||||||
|
}}
|
||||||
|
value={search()}
|
||||||
|
placeholder={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
aria-label={language.t("session.new.workspace.search.placeholder")}
|
||||||
|
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||||
|
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === "Escape" ||
|
||||||
|
event.key === "ArrowDown" ||
|
||||||
|
event.key === "ArrowUp" ||
|
||||||
|
event.key === "Enter"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item onSelect={() => select(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
<Show when={selected() === workspace}>
|
||||||
|
<Icon name="check" size="small" class="shrink-0" />
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<MenuV2.Separator class="h-[0.5px]" />
|
||||||
|
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</TooltipV2>
|
||||||
|
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const label = () => {
|
const label = () => {
|
||||||
if (props.noGit) return language.t("session.new.git.none")
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
if (!props.branch) return undefined
|
||||||
|
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
|
||||||
return props.branch
|
return props.branch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const icon = () => {
|
||||||
|
if (props.noGit) return "monitor"
|
||||||
|
if (props.from) return "branch-out"
|
||||||
|
return "branch"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={label()}>
|
<Show when={label()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
<>
|
<TooltipV2
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
placement="top"
|
||||||
<TooltipV2
|
value={value()}
|
||||||
placement="top"
|
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||||
value={value()}
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
class="min-w-0 max-w-[220px]"
|
>
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||||
>
|
<Icon
|
||||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
name={icon()}
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
size="small"
|
||||||
<span class="min-w-0 truncate">{value()}</span>
|
class="shrink-0 text-v2-icon-icon-muted"
|
||||||
</div>
|
/>
|
||||||
</TooltipV2>
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
</>
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||||
|
import type { Project } from "@/types"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { Worktree } from "@/utils/worktree"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { workspaceDirectories } from "@/utils/workspace"
|
||||||
|
import {
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
workspaceRequestWithTimeout,
|
||||||
|
} from "@/utils/workspace-request"
|
||||||
|
|
||||||
|
export function SessionWorkspaceMenu(props: {
|
||||||
|
eligible?: boolean
|
||||||
|
sessionID: string
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
messageID?: string
|
||||||
|
placement?: ComponentProps<typeof MenuV2>["placement"]
|
||||||
|
gutter?: number
|
||||||
|
class?: string
|
||||||
|
contentClass?: string
|
||||||
|
children: JSX.Element
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
|
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
|
||||||
|
const blocked = () =>
|
||||||
|
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
|
||||||
|
const workspaces = () =>
|
||||||
|
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
|
||||||
|
|
||||||
|
const fail = (scope: ServerScope, sessionID: string, message: string) => {
|
||||||
|
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
|
||||||
|
WorkspaceOperation.fail(scope, sessionID)
|
||||||
|
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
|
||||||
|
}
|
||||||
|
const move = async (selection: "create" | string) => {
|
||||||
|
if (store.selected || blocked()) return
|
||||||
|
const sdk = serverSDK()
|
||||||
|
const sync = serverSync()
|
||||||
|
const scope = sdk.scope
|
||||||
|
const sessionID = props.sessionID
|
||||||
|
const messageID = props.messageID
|
||||||
|
const source = props.directory
|
||||||
|
setStore("selected", selection)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const destination =
|
||||||
|
selection === "create"
|
||||||
|
? await createWorkspace(
|
||||||
|
props.project,
|
||||||
|
source,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
sdk,
|
||||||
|
(message) => fail(scope, sessionID, message),
|
||||||
|
{
|
||||||
|
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: selection
|
||||||
|
if (!destination) return
|
||||||
|
|
||||||
|
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
|
||||||
|
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
|
||||||
|
await workspaceRequestWithTimeout(
|
||||||
|
(signal) => sdk.api.session.move({ sessionID, directory: destination }, { signal }),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
const session = await workspaceRequestWithTimeout(
|
||||||
|
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
|
||||||
|
language.t("workspace.move.failed"),
|
||||||
|
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
if (!session || pathKey(session.location.directory) !== pathKey(destination))
|
||||||
|
throw new Error(language.t("workspace.move.failed"))
|
||||||
|
WorkspaceOperation.complete(scope, sessionID, destination)
|
||||||
|
sync.reindexSession(sessionID, source)
|
||||||
|
} catch (error) {
|
||||||
|
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
|
||||||
|
} finally {
|
||||||
|
setStore("selected", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuV2
|
||||||
|
placement={props.placement ?? "bottom-end"}
|
||||||
|
gutter={props.gutter ?? 4}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
>
|
||||||
|
<MenuV2.Trigger class={props.class} disabled={blocked()}>
|
||||||
|
{props.children}
|
||||||
|
</MenuV2.Trigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
|
||||||
|
<MenuV2.Group>
|
||||||
|
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
|
||||||
|
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
|
||||||
|
<Icon name="monitor" />
|
||||||
|
{language.t("session.new.workspace.local")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||||
|
<Icon name="workspace-new" />
|
||||||
|
{language.t("workspace.new")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
<Show when={workspaces().length > 0}>
|
||||||
|
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
|
||||||
|
<MenuV2.SubTrigger>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||||
|
</MenuV2.SubTrigger>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||||
|
<For each={workspaces()}>
|
||||||
|
{(workspace) => (
|
||||||
|
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||||
|
<Icon name="workspace-isolated" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</MenuV2.SubContent>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2.Sub>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.Group>
|
||||||
|
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||||
|
<MenuV2.Item onSelect={() => openWorkspaces()}>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(
|
||||||
|
project: Project,
|
||||||
|
source: string,
|
||||||
|
sessionID: string,
|
||||||
|
messageID: string | undefined,
|
||||||
|
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
|
||||||
|
fail: (message: string) => void,
|
||||||
|
messages: { createFailed: string },
|
||||||
|
) {
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", project.worktree, messageID)
|
||||||
|
const created = await workspaceRequestWithTimeout(
|
||||||
|
(signal) =>
|
||||||
|
serverSDK.api.projectCopy.create(
|
||||||
|
{
|
||||||
|
projectID: project.id,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
directory: getDirectory(source),
|
||||||
|
location: { directory: source },
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
messages.createFailed,
|
||||||
|
WORKSPACE_PREPARATION_TIMEOUT_MS,
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
fail(error instanceof Error ? error.message : messages.createFailed)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!created?.directory) return
|
||||||
|
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
|
||||||
|
Worktree.ready(serverSDK.scope, created.directory)
|
||||||
|
return created.directory
|
||||||
|
}
|
||||||
@@ -451,7 +451,10 @@ function SettingsKeybindsV2View(props: {
|
|||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.shortcuts.description")}</span>
|
||||||
|
</div>
|
||||||
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
|
||||||
{language.t("settings.shortcuts.reset.button")}
|
{language.t("settings.shortcuts.reset.button")}
|
||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { type ParentProps, Show } from "solid-js"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { ModelsProvider } from "@/context/models"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
|
|
||||||
|
export function SettingsServerScope(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
return (
|
||||||
|
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||||
|
{(server) => <SettingsServerDataScope server={server}>{props.children}</SettingsServerDataScope>}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsServerDataScope(props: ParentProps<{ server: ServerConnection.Any }>) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = () => global.ensureServerCtx(props.server)
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={serverCtx().queryClient}>
|
||||||
|
<ServerSDKProvider server={() => props.server}>
|
||||||
|
<ServerSyncProvider server={() => props.server}>
|
||||||
|
<ModelsProvider>{props.children}</ModelsProvider>
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Component, createMemo } from "solid-js"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||||
|
const fontSettings = {
|
||||||
|
ui: {
|
||||||
|
action: "settings-ui-font",
|
||||||
|
title: "settings.general.row.uiFont.title",
|
||||||
|
description: "settings.general.row.uiFont.description",
|
||||||
|
font: "ui",
|
||||||
|
input: "setUI",
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
action: "settings-code-font",
|
||||||
|
title: "settings.general.row.font.title",
|
||||||
|
description: "settings.general.row.font.description",
|
||||||
|
font: "code",
|
||||||
|
input: "setCode",
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
action: "settings-terminal-font",
|
||||||
|
title: "settings.general.row.terminalFont.title",
|
||||||
|
description: "settings.general.row.terminalFont.description",
|
||||||
|
font: "terminal",
|
||||||
|
input: "setTerminal",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const FontSetting: Component<{
|
||||||
|
kind: "ui" | "code" | "terminal"
|
||||||
|
fonts: AppearanceSettingsController["fonts"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => fontSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<div class="w-full sm:w-[220px]">
|
||||||
|
<TextInputV2
|
||||||
|
data-action={config().action}
|
||||||
|
type="text"
|
||||||
|
appearance="base"
|
||||||
|
value={props.fonts[config().font]().value}
|
||||||
|
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
|
||||||
|
placeholder={props.fonts[config().font]().placeholder}
|
||||||
|
spellcheck={false}
|
||||||
|
autocorrect="off"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label={language.t(config().title)}
|
||||||
|
style={{ "font-family": props.fonts[config().font]().family }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsAppearanceV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const appearance = createAppearanceSettingsController()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-color-scheme"
|
||||||
|
options={schemeOptions}
|
||||||
|
current={schemeOptions.find((option) => option === appearance.scheme.current())}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
label={(option) => {
|
||||||
|
if (option === "system") return language.t("theme.scheme.system")
|
||||||
|
if (option === "light") return language.t("theme.scheme.light")
|
||||||
|
return language.t("theme.scheme.dark")
|
||||||
|
}}
|
||||||
|
onSelect={(option) => option && appearance.scheme.select(option)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.theme.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.general.row.theme.description")}{" "}
|
||||||
|
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||||
|
{language.t("common.learnMore")}
|
||||||
|
</ExternalLink>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-theme"
|
||||||
|
options={appearance.theme.options()}
|
||||||
|
current={appearance.theme.current()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.name}
|
||||||
|
onSelect={appearance.theme.select}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<FontSetting kind="ui" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="code" fonts={appearance.fonts} />
|
||||||
|
<FontSetting kind="terminal" fonts={appearance.fonts} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,15 +5,23 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SettingsGeneralV2 } from "./general"
|
import { SettingsGeneralV2 } from "./general"
|
||||||
|
import { SettingsAppearanceV2 } from "./appearance"
|
||||||
import { SettingsKeybinds } from "../settings-keybinds"
|
import { SettingsKeybinds } from "../settings-keybinds"
|
||||||
|
import { SettingsNotificationsV2 } from "./notifications"
|
||||||
import { SettingsProvidersV2 } from "./providers"
|
import { SettingsProvidersV2 } from "./providers"
|
||||||
import { SettingsModelsV2 } from "./models"
|
import { SettingsModelsV2 } from "./models"
|
||||||
import "./settings-v2.css"
|
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
import { SettingsWorkspacesV2 } from "./workspaces"
|
||||||
|
import { SettingsProjectsV2 } from "./projects"
|
||||||
|
import { SettingsExtensionsV2 } from "./extensions"
|
||||||
|
import { SettingsServerScope } from "../settings-server-picker"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component<{
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -25,8 +33,13 @@ export const DialogSettings: Component<{
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const global = useGlobal()
|
||||||
|
const currentServer = global.servers.list().find((server) => global.ensureServerCtx(server).sync === serverSync())
|
||||||
|
if (currentServer) global.settings.server.set(ServerConnection.key(currentServer))
|
||||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
const directory = createMemo(() => {
|
const directory = createMemo(() => {
|
||||||
|
const server = global.settings.server.selected()
|
||||||
|
if (!server || serverSync() !== global.ensureServerCtx(server).sync) return
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
if (route.type === "dir-new-sesssion") return route.dir
|
if (route.type === "dir-new-sesssion") return route.dir
|
||||||
if (route.type === "draft") {
|
if (route.type === "draft") {
|
||||||
@@ -52,62 +65,96 @@ export const DialogSettings: Component<{
|
|||||||
>
|
>
|
||||||
<TabsV2.List>
|
<TabsV2.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full">
|
<div class="flex flex-col justify-between h-full w-full">
|
||||||
<div class="flex flex-col gap-3 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<div class="flex flex-col gap-1.5">
|
<TabsV2.Trigger value="general">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
|
<Icon name="sliders" />
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
{language.t("settings.tab.preferences")}
|
||||||
<TabsV2.Trigger value="general">
|
</TabsV2.Trigger>
|
||||||
<Icon name="sliders" />
|
<TabsV2.Trigger value="appearance">
|
||||||
{language.t("settings.tab.general")}
|
<Icon name="appearance" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.general.section.appearance")}
|
||||||
<TabsV2.Trigger value="shortcuts">
|
</TabsV2.Trigger>
|
||||||
<Icon name="keyboard" />
|
<TabsV2.Trigger value="notifications">
|
||||||
{language.t("settings.tab.shortcuts")}
|
<Icon name="notifications" />
|
||||||
</TabsV2.Trigger>
|
{language.t("settings.tab.notifications")}
|
||||||
</div>
|
</TabsV2.Trigger>
|
||||||
</div>
|
<TabsV2.Trigger value="shortcuts">
|
||||||
|
<Icon name="keyboard" />
|
||||||
|
{language.t("settings.tab.shortcuts")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1 w-full">
|
||||||
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
|
<TabsV2.Trigger value="servers">
|
||||||
<div class="flex flex-col gap-1.5 w-full">
|
<Icon name="server" />
|
||||||
<TabsV2.Trigger value="servers">
|
{language.t("status.popover.tab.servers")}
|
||||||
<Icon name="server" />
|
</TabsV2.Trigger>
|
||||||
{language.t("status.popover.tab.servers")}
|
<TabsV2.Trigger value="projects">
|
||||||
</TabsV2.Trigger>
|
<Icon name="folder" />
|
||||||
<TabsV2.Trigger value="providers">
|
{language.t("settings.tab.projects")}
|
||||||
<Icon name="providers" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.providers.title")}
|
<TabsV2.Trigger value="workspaces">
|
||||||
</TabsV2.Trigger>
|
<Icon name="workspace-isolated" />
|
||||||
<TabsV2.Trigger value="models">
|
{language.t("settings.tab.workspaces")}
|
||||||
<Icon name="models" />
|
</TabsV2.Trigger>
|
||||||
{language.t("settings.models.title")}
|
</div>
|
||||||
</TabsV2.Trigger>
|
|
||||||
</div>
|
<div class="flex flex-col gap-1 w-full">
|
||||||
</div>
|
<TabsV2.Trigger value="providers">
|
||||||
|
<Icon name="providers" />
|
||||||
|
{language.t("settings.providers.title")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="models">
|
||||||
|
<Icon name="models" />
|
||||||
|
{language.t("settings.models.title")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="extensions">
|
||||||
|
<Icon name="extensions" />
|
||||||
|
{language.t("settings.tab.extensions")}
|
||||||
|
</TabsV2.Trigger>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-nav-footer">
|
<div class="settings-v2-nav-footer">
|
||||||
<span>{language.t("app.name.desktop")}</span>
|
<span>{language.t("app.name.desktop")}</span>
|
||||||
<span>v{platform.version}</span>
|
<span>v{platform.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneralV2 sessionID={props.sessionID} />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="appearance" class="settings-v2-panel">
|
||||||
|
<SettingsAppearanceV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="notifications" class="settings-v2-panel">
|
||||||
|
<SettingsNotificationsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="workspaces" class="settings-v2-panel">
|
||||||
|
<SettingsWorkspacesV2 activeDirectory={directory()} />
|
||||||
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="servers" class="settings-v2-panel">
|
<TabsV2.Content value="servers" class="settings-v2-panel">
|
||||||
<SettingsServersV2 />
|
<SettingsServersV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
<TabsV2.Content value="projects" class="settings-v2-panel">
|
||||||
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
<SettingsProjectsV2 />
|
||||||
</TabsV2.Content>
|
|
||||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
|
||||||
<SettingsModelsV2 />
|
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
<SettingsServerScope>
|
||||||
|
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||||
|
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||||
|
<SettingsModelsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
|
<TabsV2.Content value="extensions" class="settings-v2-panel">
|
||||||
|
<SettingsExtensionsV2 />
|
||||||
|
</TabsV2.Content>
|
||||||
|
</SettingsServerScope>
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Component, For, createMemo, createResource } from "solid-js"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { ExternalLink } from "../external-link"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
interface McpRowItem {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRowItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsExtensionsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSdk = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
|
const configMcp = serverSync().data.config.mcp ?? {}
|
||||||
|
return Object.entries(configMcp).map(([name, config]) => ({
|
||||||
|
name,
|
||||||
|
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||||
|
const before = serverSync().data.config.mcp ?? {}
|
||||||
|
const config = before[item.name]
|
||||||
|
if (typeof config !== "object" || config === null) return
|
||||||
|
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||||
|
serverSync().set("config", "mcp", next)
|
||||||
|
void serverSync()
|
||||||
|
.updateConfig({ mcp: next })
|
||||||
|
.catch(() => serverSync().set("config", "mcp", before))
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||||
|
const raw = serverSync().data.config.plugin ?? []
|
||||||
|
return raw.map((item) => {
|
||||||
|
const name = typeof item === "string" ? item : item[0]
|
||||||
|
return { name }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||||
|
initialValue: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
|
||||||
|
<TabsV2.List>
|
||||||
|
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||||
|
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||||
|
</TabsV2.List>
|
||||||
|
|
||||||
|
<TabsV2.Content value="mcps">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={mcps()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
|
||||||
|
{item.name}
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="plugins">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={plugins()}>
|
||||||
|
{(plugin) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="cube" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
<TabsV2.Content value="skills">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-13-medium text-v2-text-text-base">
|
||||||
|
{language.t("settings.extensions.availableAll")}
|
||||||
|
</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="text-13-regular text-v2-text-accent hover:underline"
|
||||||
|
href="https://opencode.ai/docs/skills/"
|
||||||
|
>
|
||||||
|
{language.t("settings.extensions.addSkills")}
|
||||||
|
</ExternalLink>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
|
||||||
|
<For each={skills()}>
|
||||||
|
{(skill) => (
|
||||||
|
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Icon name="post-skill" class="text-v2-icon-icon-muted shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsV2.Content>
|
||||||
|
</TabsV2>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import { useSettings } from "@/context/settings"
|
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WorkspaceDestinationSetting: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
|
||||||
|
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
|
||||||
|
{ value: "local", label: language.t("settings.workspaces.default.local") },
|
||||||
|
{ value: "new", label: language.t("settings.workspaces.default.new") },
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.workspaces.default.title")}
|
||||||
|
description={language.t("settings.workspaces.default.description")}
|
||||||
|
>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={options()}
|
||||||
|
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
|
||||||
|
value={(option) => option.value}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
@@ -279,8 +307,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||||
const shell = createShellSettingsController()
|
const shell = createShellSettingsController()
|
||||||
const appearance = createAppearanceSettingsController()
|
|
||||||
const sounds = createSoundSettingsController()
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||||
@@ -298,9 +324,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
const GeneralSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="settings-v2-section">
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.general")}</h3>
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<LanguageSetting />
|
<LanguageSetting />
|
||||||
|
|
||||||
|
<WorkspaceDestinationSetting />
|
||||||
<PermissionScopeSetting controller={permissionScope} />
|
<PermissionScopeSetting controller={permissionScope} />
|
||||||
|
|
||||||
<ShellSetting controller={shell} />
|
<ShellSetting controller={shell} />
|
||||||
@@ -363,18 +391,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
|
||||||
|
|
||||||
<SettingsListV2>
|
<SettingsListV2>
|
||||||
<SettingsRowV2
|
|
||||||
title={language.t("settings.general.row.showFileTree.title")}
|
|
||||||
description={language.t("settings.general.row.showFileTree.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-show-file-tree">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showFileTree()}
|
|
||||||
onChange={(checked) => settings.general.setShowFileTree(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRowV2>
|
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.showSearch.title")}
|
title={language.t("settings.general.row.showSearch.title")}
|
||||||
description={language.t("settings.general.row.showSearch.description")}
|
description={language.t("settings.general.row.showSearch.description")}
|
||||||
@@ -510,18 +526,19 @@ export const SettingsGeneralV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.preferences")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">
|
||||||
|
{language.t("settings.preferences.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body">
|
<div class="settings-v2-tab-body">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<AppearanceSection controller={appearance} />
|
|
||||||
|
|
||||||
<NotificationsSection />
|
|
||||||
|
|
||||||
<SoundsSection controller={sounds} />
|
|
||||||
|
|
||||||
<Show when={desktop()}>
|
<Show when={desktop()}>
|
||||||
<UpdatesSection />
|
<UpdatesSection />
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useModels } from "@/context/models"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import { SettingsRowV2 } from "./parts/row"
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -53,7 +54,13 @@ export const SettingsModelsV2: Component = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.models.title")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.models.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect />
|
||||||
|
</div>
|
||||||
<div class="settings-v2-tab-search">
|
<div class="settings-v2-tab-search">
|
||||||
<TextInputV2
|
<TextInputV2
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Component } from "solid-js"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { SettingsRowV2 } from "./parts/row"
|
||||||
|
import { createSoundSettingsController, soundOptions, type SoundSettingsController } from "./general-controllers"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
const soundSettings = {
|
||||||
|
agent: {
|
||||||
|
action: "settings-sounds-agent",
|
||||||
|
title: "settings.general.sounds.agent.title",
|
||||||
|
description: "settings.general.sounds.agent.description",
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
action: "settings-sounds-permissions",
|
||||||
|
title: "settings.general.sounds.permissions.title",
|
||||||
|
description: "settings.general.sounds.permissions.description",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
action: "settings-sounds-errors",
|
||||||
|
title: "settings.general.sounds.errors.title",
|
||||||
|
description: "settings.general.sounds.errors.description",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const SoundSetting: Component<{
|
||||||
|
kind: "agent" | "permissions" | "errors"
|
||||||
|
channel: SoundSettingsController["agent"]
|
||||||
|
}> = (props) => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const config = () => soundSettings[props.kind]
|
||||||
|
return (
|
||||||
|
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action={config().action}
|
||||||
|
options={soundOptions}
|
||||||
|
current={props.channel.current()}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => language.t(option.label)}
|
||||||
|
onHighlight={props.channel.highlight}
|
||||||
|
onSelect={props.channel.select}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
/>
|
||||||
|
</SettingsRowV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsNotificationsV2: Component = () => {
|
||||||
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const sounds = createSoundSettingsController()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.notifications")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">
|
||||||
|
{language.t("settings.notifications.description")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-agent">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.agent()}
|
||||||
|
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.permissions.title")}
|
||||||
|
description={language.t("settings.general.notifications.permissions.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-permissions">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.permissions()}
|
||||||
|
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.notifications.errors.title")}
|
||||||
|
description={language.t("settings.general.notifications.errors.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-notifications-errors">
|
||||||
|
<Switch
|
||||||
|
checked={settings.notifications.errors()}
|
||||||
|
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
<SettingsListV2>
|
||||||
|
<SoundSetting kind="agent" channel={sounds.agent} />
|
||||||
|
<SoundSetting kind="permissions" channel={sounds.permissions} />
|
||||||
|
<SoundSetting kind="errors" channel={sounds.errors} />
|
||||||
|
</SettingsListV2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Show, createMemo, type Component } from "solid-js"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
|
|
||||||
|
const allServers = { type: "all" } as const
|
||||||
|
type ServerOption = ServerConnection.Any | typeof allServers
|
||||||
|
|
||||||
|
export const InlineServerSelect: Component<{
|
||||||
|
all?: {
|
||||||
|
label: string
|
||||||
|
selected: () => boolean
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
onServerSelect?: () => void
|
||||||
|
}> = (props) => {
|
||||||
|
const global = useGlobal()
|
||||||
|
const options = createMemo<ServerOption[]>(() => [...(props.all ? [allServers] : []), ...global.servers.list()])
|
||||||
|
const current = () => (props.all?.selected() ? allServers : global.settings.server.selected())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={options().length > 1}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
data-action="settings-server-select"
|
||||||
|
options={options()}
|
||||||
|
current={current()}
|
||||||
|
value={(server) => (server.type === "all" ? server.type : ServerConnection.key(server))}
|
||||||
|
label={(server) =>
|
||||||
|
server.type === "all" ? (props.all?.label ?? "") : serverName(server) || ServerConnection.key(server)
|
||||||
|
}
|
||||||
|
optionDisabled={(server) =>
|
||||||
|
server.type === "all" ? false : global.servers.health[ServerConnection.key(server)]?.healthy === false
|
||||||
|
}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(server) => {
|
||||||
|
if (!server) return
|
||||||
|
if (server.type === "all") {
|
||||||
|
props.all?.onSelect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
global.settings.server.set(ServerConnection.key(server))
|
||||||
|
props.onServerSelect?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import { ServerConnection, serverName } from "@/context/server"
|
||||||
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
|
import { DialogEditProjectV2 } from "../dialog-edit-project-v2"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
export const SettingsProjectsV2: Component = () => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const global = useGlobal()
|
||||||
|
const [allServers, setAllServers] = createSignal(true)
|
||||||
|
const selected = global.settings.server.selected
|
||||||
|
const projects = createMemo(() => {
|
||||||
|
const server = selected()
|
||||||
|
if (!server) return []
|
||||||
|
return global.ensureServerCtx(server).projects.list()
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProjectItem = ReturnType<typeof projects>[number]
|
||||||
|
|
||||||
|
const groups = createMemo(() =>
|
||||||
|
global.servers
|
||||||
|
.list()
|
||||||
|
.map((server) => ({ server, projects: global.ensureServerCtx(server).projects.list() }))
|
||||||
|
.filter((group) => group.projects.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const openProjectSettings = (project: ProjectItem, server = selected()) => {
|
||||||
|
if (!server) return
|
||||||
|
dialog.push(() => <DialogEditProjectV2 project={project} server={server} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
|
||||||
|
const name = () => displayName(props.project)
|
||||||
|
const color = () => getProjectAvatarVariant(props.project.icon?.color)
|
||||||
|
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"
|
||||||
|
onClick={() => openProjectSettings(props.project, props.server)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
|
||||||
|
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
icon={<IconV2 name="settings-gear" size="small" class="text-v2-icon-icon-muted" />}
|
||||||
|
onClick={(event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
openProjectSettings(props.project, props.server)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header">
|
||||||
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body">
|
||||||
|
<Show
|
||||||
|
when={allServers()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<Show
|
||||||
|
when={projects().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||||
|
{language.t("settings.projects.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={selected()} keyed>
|
||||||
|
{(server) => (
|
||||||
|
<For each={projects()}>{(project) => <ProjectRow project={project} server={server} />}</For>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-8 w-full">
|
||||||
|
<Show
|
||||||
|
when={groups().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="py-12 text-center text-v2-text-text-muted text-13-regular">
|
||||||
|
{language.t("settings.projects.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="settings-v2-section">
|
||||||
|
<h3 class="settings-v2-section-title">
|
||||||
|
{serverName(group.server) || ServerConnection.key(group.server)}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-2 w-full">
|
||||||
|
<For each={group.projects}>
|
||||||
|
{(project) => <ProjectRow project={project} server={group.server} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { useServerSDK } from "@/context/server-sdk"
|
|||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||||
|
import { SettingsServerScope } from "../settings-server-picker"
|
||||||
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import { SettingsListV2 } from "./parts/list"
|
import { SettingsListV2 } from "./parts/list"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
|
|
||||||
@@ -42,13 +44,23 @@ export const SettingsProvidersV2: Component<{
|
|||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
|
void dialog.show(() => (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
|
||||||
|
</SettingsServerScope>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
const connected = createMemo(() => {
|
const connected = createMemo(() => {
|
||||||
return providers
|
return providers.connected().filter(
|
||||||
.connected()
|
(provider) =>
|
||||||
.filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
|
provider.id !== "opencode" ||
|
||||||
|
Object.values(provider.models).some((model) => {
|
||||||
|
if (typeof model !== "object" || model === null || !("cost" in model)) return false
|
||||||
|
const cost = model.cost
|
||||||
|
return typeof cost === "object" && cost !== null && "input" in cost
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const popular = createMemo(() => {
|
const popular = createMemo(() => {
|
||||||
@@ -92,29 +104,6 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const disableProvider = async (providerID: string, name: string) => {
|
|
||||||
return
|
|
||||||
const before = serverSync().data.config.disabled_providers ?? []
|
|
||||||
const next = before.includes(providerID) ? before : [...before, providerID]
|
|
||||||
serverSync().set("config", "disabled_providers", next)
|
|
||||||
|
|
||||||
await serverSync()
|
|
||||||
.updateConfig({ disabled_providers: next })
|
|
||||||
.then(() => {
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
|
||||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
serverSync().set("config", "disabled_providers", before)
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
const location = props.directory() ? { directory: props.directory() } : undefined
|
const location = props.directory() ? { directory: props.directory() } : undefined
|
||||||
await serverSdk()
|
await serverSdk()
|
||||||
@@ -141,7 +130,13 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="settings-v2-tab-header">
|
<div class="settings-v2-tab-header">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
<div class="settings-v2-tab-header-row">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
|
||||||
|
</div>
|
||||||
|
<InlineServerSelect />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-v2-tab-body settings-v2-providers">
|
<div class="settings-v2-tab-body settings-v2-providers">
|
||||||
@@ -244,7 +239,11 @@ export const SettingsProvidersV2: Component<{
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
dialog.show(() => (
|
||||||
|
<SettingsServerScope>
|
||||||
|
<DialogCustomProvider onBack={dialog.close} />
|
||||||
|
</SettingsServerScope>
|
||||||
|
))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export const SettingsServersV2: Component = () => {
|
|||||||
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
|
||||||
>
|
>
|
||||||
<div class="settings-v2-tab-header-row">
|
<div class="settings-v2-tab-header-row">
|
||||||
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
|
||||||
|
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.servers.description")}</span>
|
||||||
|
</div>
|
||||||
<AddServerMenu onAddServer={openAdd} />
|
<AddServerMenu onAddServer={openAdd} />
|
||||||
</div>
|
</div>
|
||||||
<Show when={showSearch()}>
|
<Show when={showSearch()}>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-v2-tab-title {
|
.settings-v2-tab-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 640;
|
font-weight: 640;
|
||||||
@@ -174,13 +182,13 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
|
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] {
|
||||||
background-color: var(--v2-background-bg-layer-01);
|
background-color: var(--v2-background-bg-layer-01);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
.settings-v2[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"]
|
||||||
[data-slot="tabs-v2-list"] {
|
> [data-slot="tabs-v2-list"] {
|
||||||
width: 144px;
|
width: 144px;
|
||||||
min-width: 144px;
|
min-width: 144px;
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
@@ -684,6 +692,223 @@
|
|||||||
color: var(--v2-text-text-base);
|
color: var(--v2-text-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-header.settings-v2-workspaces-header {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-header .settings-v2-tab-title {
|
||||||
|
font-weight: 610;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-count {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-delete-all {
|
||||||
|
color: var(--v2-state-fg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row:not(:last-child) {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-copy {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active,
|
||||||
|
.settings-v2-workspaces-more {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-sessions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 0.5px solid var(--v2-border-border-base);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--v2-background-bg-base);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session:not(:last-child) {
|
||||||
|
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-session-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-block: 48px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.settings-v2-workspaces-header {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-tab-body.settings-v2-workspaces {
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar,
|
||||||
|
.settings-v2-workspaces-main {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-toolbar-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-path {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-workspaces-active {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
@@ -727,3 +952,24 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--v2-state-fg-danger);
|
color: var(--v2-state-fg-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||||
|
width: 280px;
|
||||||
|
padding-inline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||||
|
> [data-slot="tabs-v2-list"]
|
||||||
|
[data-slot="tabs-v2-trigger-wrapper"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-v2-extensions-tabs[data-component="tabs-v2"][data-variant="pill"]
|
||||||
|
> [data-slot="tabs-v2-list"]
|
||||||
|
[data-slot="tabs-v2-trigger"] {
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { SettingsListV2 } from "./parts/list"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Project } from "@/types"
|
||||||
|
import {
|
||||||
|
containsDirectory,
|
||||||
|
filterWorkspaceInventory,
|
||||||
|
inspectWorkspaceDeletion,
|
||||||
|
mergeWorkspaceSessionInventory,
|
||||||
|
removeWorkspacesSequentially,
|
||||||
|
sessionsForWorkspace,
|
||||||
|
type WorkspaceDeleteInspection,
|
||||||
|
workspaceInventory,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
import { listAllSessions } from "@/utils/session"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import "./settings-v2.css"
|
||||||
|
|
||||||
|
type Workspace = {
|
||||||
|
directory: string
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
project: "all",
|
||||||
|
transaction: undefined as "confirm" | "running" | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
||||||
|
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
||||||
|
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||||
|
const projectOptions = createMemo(() => [
|
||||||
|
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
||||||
|
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
||||||
|
])
|
||||||
|
const selectedProject = createMemo(() =>
|
||||||
|
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
||||||
|
)
|
||||||
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||||
|
const captureDeleteContext = () => {
|
||||||
|
const sdk = serverSDK()
|
||||||
|
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||||
|
}
|
||||||
|
const loadSessions = async (context = captureDeleteContext()) => {
|
||||||
|
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
|
return mergeWorkspaceSessionInventory(
|
||||||
|
fetched,
|
||||||
|
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const sessionQuery = useQuery(() => ({
|
||||||
|
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
||||||
|
queryFn: () => loadSessions(),
|
||||||
|
refetchOnMount: "always",
|
||||||
|
}))
|
||||||
|
const workspaceSessions = (workspace: Workspace) => {
|
||||||
|
if (!sessionQuery.isSuccess) return []
|
||||||
|
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
||||||
|
}
|
||||||
|
const sessionCount = (workspace: Workspace) => {
|
||||||
|
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||||
|
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||||
|
const count = workspaceSessions(workspace).length
|
||||||
|
return language.plural("settings.workspaces.sessions", count, {
|
||||||
|
count,
|
||||||
|
project: projectName(workspace.project),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const lastActive = (workspace: Workspace) => {
|
||||||
|
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||||
|
if (!updated) return undefined
|
||||||
|
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
const sessionTime = (session: SessionInfo) => {
|
||||||
|
if (!session.time.updated) return undefined
|
||||||
|
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
||||||
|
const [working, branch, sessions] = await Promise.all([
|
||||||
|
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
||||||
|
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
||||||
|
loadSessions(context),
|
||||||
|
])
|
||||||
|
const result = inspectWorkspaceDeletion({
|
||||||
|
workspace: workspace.directory,
|
||||||
|
activeDirectory: context.activeDirectory,
|
||||||
|
sessions,
|
||||||
|
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
||||||
|
})
|
||||||
|
return { result, sessions }
|
||||||
|
}
|
||||||
|
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
||||||
|
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
||||||
|
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
||||||
|
if (result === "dirty") return language.t("workspace.status.dirty")
|
||||||
|
return language.t("workspace.status.clean")
|
||||||
|
}
|
||||||
|
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: inspectionMessage(result),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
||||||
|
const preflight = await inspect(workspace, context)
|
||||||
|
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
||||||
|
blocked(preflight.result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const removed = await context.sdk.api.projectCopy
|
||||||
|
.remove({
|
||||||
|
projectID: workspace.project.id,
|
||||||
|
location: { directory: workspace.project.worktree },
|
||||||
|
directory: workspace.directory,
|
||||||
|
force: allowDirty,
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!removed) return
|
||||||
|
tabs.store.forEach((tab) => {
|
||||||
|
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||||
|
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||||
|
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||||
|
if (!directoryMatches && !worktreeMatches) return
|
||||||
|
tabs.updateDraft(tab.draftID, {
|
||||||
|
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||||
|
worktree: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
clearWorkspaceTerminals(
|
||||||
|
workspace.directory,
|
||||||
|
preflight.sessions.map((session) => session.id),
|
||||||
|
platform,
|
||||||
|
context.sdk.scope,
|
||||||
|
)
|
||||||
|
context.sync.set(
|
||||||
|
"project",
|
||||||
|
produce((draft) => {
|
||||||
|
const project = draft.find((item) => item.id === workspace.project.id)
|
||||||
|
if (!project) return
|
||||||
|
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||||
|
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspectionID = 0
|
||||||
|
const releaseConfirmation = () => {
|
||||||
|
if (store.transaction === "confirm") setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
const transact = async (task: () => Promise<void>) => {
|
||||||
|
if (store.transaction !== "confirm") return
|
||||||
|
setStore("transaction", "running")
|
||||||
|
try {
|
||||||
|
await task()
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("workspace.delete.failed.title"),
|
||||||
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setStore("transaction", undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const confirmDelete = (workspace: Workspace) => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const current = ++inspectionID
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteWorkspace
|
||||||
|
workspace={workspace}
|
||||||
|
scope={context.sdk.scope}
|
||||||
|
inspectionID={current}
|
||||||
|
inspect={() => inspect(workspace, context)}
|
||||||
|
inspectionMessage={inspectionMessage}
|
||||||
|
onDelete={() => transact(() => remove(workspace, true, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
||||||
|
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
||||||
|
}
|
||||||
|
const confirmDeleteAll = () => {
|
||||||
|
if (store.transaction) return
|
||||||
|
const context = captureDeleteContext()
|
||||||
|
const inventory = [...filtered()]
|
||||||
|
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||||
|
setStore("transaction", "confirm")
|
||||||
|
void dialog.push(
|
||||||
|
() => (
|
||||||
|
<DialogDeleteAllWorkspaces
|
||||||
|
count={inventory.length}
|
||||||
|
project={project}
|
||||||
|
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
releaseConfirmation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
||||||
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-tab-body settings-v2-workspaces">
|
||||||
|
<div class="settings-v2-workspaces-toolbar">
|
||||||
|
<span class="settings-v2-workspaces-count">
|
||||||
|
{language.plural("settings.workspaces.count", filtered().length)}
|
||||||
|
</span>
|
||||||
|
<div class="settings-v2-workspaces-toolbar-actions">
|
||||||
|
<Show when={projects().length > 1}>
|
||||||
|
<SelectV2
|
||||||
|
appearance="inline"
|
||||||
|
options={projectOptions()}
|
||||||
|
current={projectOptions().find((option) => option.id === selectedProject())}
|
||||||
|
value={(option) => option.id}
|
||||||
|
label={(option) => option.label}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onSelect={(option) => option && setStore("project", option.id)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={filtered().length > 0}>
|
||||||
|
<MenuV2 placement="bottom-end" gutter={4}>
|
||||||
|
<MenuV2.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("common.moreOptions")}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="outline-dots" size="small" />}
|
||||||
|
/>
|
||||||
|
<MenuV2.Portal>
|
||||||
|
<MenuV2.Content>
|
||||||
|
<MenuV2.Item onSelect={confirmDeleteAll}>
|
||||||
|
<span class="settings-v2-workspaces-delete-all">
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</span>
|
||||||
|
</MenuV2.Item>
|
||||||
|
</MenuV2.Content>
|
||||||
|
</MenuV2.Portal>
|
||||||
|
</MenuV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-v2-workspaces-inventory">
|
||||||
|
<Show
|
||||||
|
when={filtered().length > 0}
|
||||||
|
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
||||||
|
>
|
||||||
|
<SettingsListV2>
|
||||||
|
<For each={filtered()}>
|
||||||
|
{(workspace) => {
|
||||||
|
const linked = () => workspaceSessions(workspace)
|
||||||
|
return (
|
||||||
|
<div class="settings-v2-workspaces-row">
|
||||||
|
<div class="settings-v2-workspaces-row-header">
|
||||||
|
<div class="settings-v2-workspaces-copy">
|
||||||
|
<div class="settings-v2-workspaces-main">
|
||||||
|
<TooltipV2
|
||||||
|
value={workspace.directory}
|
||||||
|
placement="top-start"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
||||||
|
{workspace.directory}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
</div>
|
||||||
|
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-v2-workspaces-row-actions">
|
||||||
|
<Show when={lastActive(workspace)}>
|
||||||
|
{(value) => (
|
||||||
|
<TooltipV2
|
||||||
|
value={language.t("settings.workspaces.lastActiveSession")}
|
||||||
|
placement="top-end"
|
||||||
|
>
|
||||||
|
<span tabIndex={0} class="settings-v2-workspaces-active">
|
||||||
|
{value()}
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<IconButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="small"
|
||||||
|
aria-label={language.t("workspace.delete.confirm", {
|
||||||
|
name: getFilename(workspace.directory),
|
||||||
|
})}
|
||||||
|
disabled={!!store.transaction}
|
||||||
|
icon={<Icon name="trash" size="small" />}
|
||||||
|
onClick={() => confirmDelete(workspace)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={linked().length > 0}>
|
||||||
|
<div class="settings-v2-workspaces-sessions">
|
||||||
|
<For each={linked()}>
|
||||||
|
{(session) => (
|
||||||
|
<div class="settings-v2-workspaces-session">
|
||||||
|
<span>{session.title}</span>
|
||||||
|
<Show when={sessionTime(session)}>
|
||||||
|
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SettingsListV2>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("settings.workspaces.deleteAll")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
||||||
|
{language.t("settings.workspaces.deleteAll")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDeleteWorkspace(props: {
|
||||||
|
workspace: Workspace
|
||||||
|
scope: ServerScope
|
||||||
|
inspectionID: number
|
||||||
|
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }>
|
||||||
|
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
||||||
|
onDelete: () => Promise<void>
|
||||||
|
}) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
|
const status = useQuery(() => ({
|
||||||
|
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
||||||
|
queryFn: props.inspect,
|
||||||
|
staleTime: 0,
|
||||||
|
}))
|
||||||
|
const description = () => {
|
||||||
|
if (status.isPending) return language.t("workspace.status.checking")
|
||||||
|
if (status.isError) return language.t("workspace.status.error")
|
||||||
|
return props.inspectionMessage(status.data?.result ?? "unknown")
|
||||||
|
}
|
||||||
|
const remove = () => {
|
||||||
|
const deleting = props.onDelete()
|
||||||
|
dialog.close()
|
||||||
|
void deleting
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitleGroup
|
||||||
|
title={language.t("workspace.delete.title")}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||||
|
<br />
|
||||||
|
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||||
|
{props.workspace.directory}
|
||||||
|
</code>
|
||||||
|
<br />
|
||||||
|
{language.t("settings.workspaces.delete.warning")}
|
||||||
|
<br />
|
||||||
|
{description()}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabled={
|
||||||
|
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
||||||
|
}
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
{language.t("workspace.delete.button")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -106,16 +106,26 @@ describe("query keys", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
|
const calls: string[] = []
|
||||||
const api = {
|
const api = {
|
||||||
list: async () => [
|
list: async () => [
|
||||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||||
],
|
],
|
||||||
|
directories: async ({ projectID }: { projectID: string }) => {
|
||||||
|
calls.push(projectID)
|
||||||
|
return [
|
||||||
|
{ directory: `/${projectID}` },
|
||||||
|
{ directory: `/${projectID}/copy`, strategy: "git_worktree" },
|
||||||
|
]
|
||||||
|
},
|
||||||
} as unknown as ProjectApi
|
} as unknown as ProjectApi
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||||
|
|
||||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||||
|
expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]])
|
||||||
|
expect(calls.toSorted()).toEqual(["a", "b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads references from the current location-scoped endpoint", async () => {
|
test("loads references from the current location-scoped endpoint", async () => {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
|||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
readonly list: () => Promise<ProjectListOutput>
|
readonly list: () => Promise<ProjectListOutput>
|
||||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||||
|
readonly directories: ServerApi["project"]["directories"]
|
||||||
}
|
}
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
@@ -116,10 +117,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
queryKey: [scope, "project"],
|
queryKey: [scope, "project"],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
api.list().then((projects) => {
|
api.list().then(async (projects) => {
|
||||||
return projects
|
return (await Promise.all(
|
||||||
.filter((p) => !!p?.id)
|
projects.filter((project) => !!project?.id).map(async (project) => {
|
||||||
.map(normalizeProjectInfo)
|
const directories = await api.directories({ projectID: project.id })
|
||||||
|
return normalizeProjectInfo({
|
||||||
|
...project,
|
||||||
|
sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
))
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
|
|||||||
import { createEffect, createMemo, createResource } from "solid-js"
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
import {
|
||||||
|
pluralCategory,
|
||||||
|
type UiI18nPluralLookupKey,
|
||||||
|
type UiI18nPluralKey,
|
||||||
|
type UiPluralCategory,
|
||||||
|
} from "@opencode-ai/ui/context/i18n"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
@@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction {
|
|||||||
|
|
||||||
type RawDictionary = typeof en & typeof uiEn
|
type RawDictionary = typeof en & typeof uiEn
|
||||||
type Dictionary = i18n.Flatten<RawDictionary>
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
type PluralKey =
|
type AppI18nKey = Extract<keyof typeof en, string>
|
||||||
| UiI18nPluralKey
|
type AppI18nPluralKey = {
|
||||||
| "session.question.pending"
|
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
|
||||||
| "session.followupDock.summary"
|
}[AppI18nKey]
|
||||||
| "session.revertDock.summary"
|
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
|
||||||
|
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
|
||||||
|
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
|
||||||
type Source = { dict: Record<string, string> }
|
type Source = { dict: Record<string, string> }
|
||||||
|
|
||||||
function cookie(locale: Locale) {
|
function cookie(locale: Locale) {
|
||||||
@@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
initialValue: dicts.get(initial) ?? base,
|
initialValue: dicts.get(initial) ?? base,
|
||||||
})
|
})
|
||||||
|
|
||||||
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
|
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
|
||||||
key: keyof Dictionary,
|
key: TranslationKey<Key>,
|
||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
) => string
|
) => string
|
||||||
|
|
||||||
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
|
const pluralForm = (
|
||||||
const category = pluralCategory(intl(), count)
|
key: PluralKey,
|
||||||
|
category: UiPluralCategory,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
) => {
|
||||||
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
|
||||||
const candidate = `${key}.${category}`
|
const candidate = `${key}.${category}`
|
||||||
const fallback = `${key}.other`
|
const fallback = `${key}.other`
|
||||||
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
|
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
|
||||||
}
|
}
|
||||||
|
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
|
||||||
|
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
|
||||||
|
|
||||||
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
|
||||||
|
|
||||||
@@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||||||
label,
|
label,
|
||||||
t,
|
t,
|
||||||
plural,
|
plural,
|
||||||
|
pluralForm,
|
||||||
setLocale(next: Locale) {
|
setLocale(next: Locale) {
|
||||||
setStore("locale", normalizeLocale(next))
|
setStore("locale", normalizeLocale(next))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
|
|||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
createPromptReady,
|
createPromptReady,
|
||||||
createPromptSession,
|
createPromptSession,
|
||||||
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = (): PromptScope =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: PromptScope) => {
|
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns()
|
||||||
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
|
||||||
|
: undefined
|
||||||
|
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
const entry = createRoot(
|
const entry = createRoot(
|
||||||
(dispose) => ({
|
(dispose) => ({
|
||||||
value: createPromptSession(serverSDK().scope, scope),
|
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
|
||||||
dispose,
|
dispose,
|
||||||
}),
|
}),
|
||||||
owner,
|
owner,
|
||||||
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
const session = createMemo(() => load(scope()))
|
||||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
scope ? load(scope, target) : session()
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||||
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
|
||||||
|
pick(scope, target).capture(),
|
||||||
current: withSuspense(() => session().current()),
|
current: withSuspense(() => session().current()),
|
||||||
cursor: withSuspense(() => session().cursor()),
|
cursor: withSuspense(() => session().cursor()),
|
||||||
dirty: withSuspense(() => session().dirty()),
|
dirty: withSuspense(() => session().dirty()),
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import {
|
||||||
|
adaptServerEvent,
|
||||||
|
applyWorkspaceOperationEvent,
|
||||||
|
coalesceServerEvents,
|
||||||
|
enqueueServerEvent,
|
||||||
|
resumeStreamAfterPageShow,
|
||||||
|
} from "./server-sdk"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
|
||||||
|
test("a moved event completes the matching workspace operation", () => {
|
||||||
|
WorkspaceOperation.start(ServerScope.local, "current", "move", "/workspace")
|
||||||
|
applyWorkspaceOperationEvent(ServerScope.local, {
|
||||||
|
directory: "/workspace",
|
||||||
|
payload: adaptServerEvent({
|
||||||
|
id: "moved-current",
|
||||||
|
created: Date.now(),
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: "current", seq: 1, version: 1 },
|
||||||
|
data: { sessionID: "current", location: { directory: "/workspace" } },
|
||||||
|
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>),
|
||||||
|
})
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "current")?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
@@ -72,7 +95,7 @@ describe("current event buffering", () => {
|
|||||||
type: "session.tool.input.delta",
|
type: "session.tool.input.delta",
|
||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||||
} as OpenCodeEvent)
|
} satisfies Extract<OpenCodeEvent, { type: "session.tool.input.delta" }>)
|
||||||
const result = coalesceServerEvents([
|
const result = coalesceServerEvents([
|
||||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ServerConnection, useServer } from "./server"
|
|||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||||
@@ -68,6 +69,16 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
|
||||||
|
if (event.payload.current?.type !== "session.moved") return false
|
||||||
|
WorkspaceOperation.complete(
|
||||||
|
scope,
|
||||||
|
event.payload.current.data.sessionID,
|
||||||
|
event.payload.current.data.location.directory,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
if (
|
if (
|
||||||
event?.type === "session.text.delta" ||
|
event?.type === "session.text.delta" ||
|
||||||
@@ -151,7 +162,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => {
|
||||||
|
applyWorkspaceOperationEvent(scope, event)
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
|
|||||||
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
|
||||||
const source: SessionMessageInfo[] = [
|
|
||||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
|
||||||
{
|
|
||||||
id: "msg_previous_model",
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: "old", providerID: "provider" },
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const reducer = createV2SessionReducer()
|
|
||||||
|
|
||||||
const agent = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_agent",
|
|
||||||
type: "session.agent.selected",
|
|
||||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const model = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_model",
|
|
||||||
type: "session.model.selected",
|
|
||||||
data: {
|
|
||||||
sessionID: "ses_1",
|
|
||||||
model: { id: "new", providerID: "provider" },
|
|
||||||
previous: { id: "durable", providerID: "provider" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const legacyAgent = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_legacy_agent",
|
|
||||||
type: "session.agent.selected",
|
|
||||||
data: { sessionID: "ses_1", agent: "plan" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
|
||||||
expect(model?.messages.at(-1)).toMatchObject({
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: "new" },
|
|
||||||
previous: { id: "durable" },
|
|
||||||
})
|
|
||||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
|
||||||
type: "agent-switched",
|
|
||||||
agent: "plan",
|
|
||||||
previous: "build",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("folds tool, retry, and completion events", () => {
|
test("folds tool, retry, and completion events", () => {
|
||||||
const reducer = createV2SessionReducer()
|
const reducer = createV2SessionReducer()
|
||||||
let messages: SessionMessageInfo[] = []
|
let messages: SessionMessageInfo[] = []
|
||||||
|
|||||||
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
|
|||||||
type: "agent-switched",
|
type: "agent-switched",
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
agent: event.data.agent,
|
agent: event.data.agent,
|
||||||
previous:
|
|
||||||
event.data.previous ??
|
|
||||||
source.findLast(
|
|
||||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
|
||||||
item.type === "agent-switched" || item.type === "assistant",
|
|
||||||
)?.agent,
|
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.model.selected":
|
case "session.model.selected":
|
||||||
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
|
|||||||
type: "model-switched",
|
type: "model-switched",
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
model: event.data.model,
|
model: event.data.model,
|
||||||
previous:
|
previous: source.findLast(
|
||||||
event.data.previous ??
|
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||||
source.findLast(
|
item.type === "model-switched" || item.type === "assistant",
|
||||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
)?.model,
|
||||||
item.type === "model-switched" || item.type === "assistant",
|
|
||||||
)?.model,
|
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.synthetic":
|
case "session.synthetic":
|
||||||
|
|||||||
@@ -358,6 +358,23 @@ describe("server session", () => {
|
|||||||
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
expect(ctx.store.lineage.peek("child")).toEqual(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies moved session locations without evicting cached state", () => {
|
||||||
|
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
|
||||||
|
const ctx = setup({ child: current })
|
||||||
|
ctx.store.remember(current)
|
||||||
|
|
||||||
|
ctx.store.applyV2({
|
||||||
|
id: "evt_moved",
|
||||||
|
created: 2,
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
location: current.location,
|
||||||
|
data: { sessionID: "child", location: { directory: "/repo" }, subpath: "packages/app" },
|
||||||
|
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||||
|
|
||||||
|
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
|
||||||
|
})
|
||||||
|
|
||||||
test("loads session content through the server client", async () => {
|
test("loads session content through the server client", async () => {
|
||||||
const ctx = setup({ root: session("root") })
|
const ctx = setup({ root: session("root") })
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type MessageApi = ServerApi["message"]
|
|||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 20
|
const initialMessagePageSize = 20
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 50
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
@@ -45,6 +45,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function yieldToMain() {
|
||||||
|
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||||
|
if (scheduler) return scheduler.yield()
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
const boundary = source.find(
|
const boundary = source.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -241,7 +247,13 @@ export function createServerSession(
|
|||||||
const indexProjectedMessage = (message: Message) => {
|
const indexProjectedMessage = (message: Message) => {
|
||||||
const current = data.session_message[message.sessionID] ?? []
|
const current = data.session_message[message.sessionID] ?? []
|
||||||
if (current.some((item) => item.id === message.id)) return
|
if (current.some((item) => item.id === message.id)) return
|
||||||
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
|
const projected = projectMessageSource(message)
|
||||||
|
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||||
|
setData(
|
||||||
|
"session_message",
|
||||||
|
message.sessionID,
|
||||||
|
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const remember = (session: SessionInfo) => {
|
const remember = (session: SessionInfo) => {
|
||||||
@@ -288,17 +300,19 @@ export function createServerSession(
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolve = (sessionID: string, options?: { force?: boolean }) => {
|
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
|
||||||
const cached = data.info[sessionID]
|
const cached = data.info[sessionID]
|
||||||
if (cached && !options?.force) return Promise.resolve(cached)
|
if (cached && !options?.force) return Promise.resolve(cached)
|
||||||
const pending = requests.get(sessionID)
|
const pending = options?.signal ? undefined : requests.get(sessionID)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = sessionApi.get({ sessionID })
|
const request = sessionApi.get({ sessionID }, { signal: options?.signal })
|
||||||
const resolved = request.then((result) => {
|
const resolved = request.then((result) => {
|
||||||
|
if (options?.signal?.aborted) return result
|
||||||
if (generations.get(sessionID) !== active) return result
|
if (generations.get(sessionID) !== active) return result
|
||||||
return remember(result)
|
return remember(result)
|
||||||
})
|
})
|
||||||
|
if (options?.signal) return resolved
|
||||||
requests.set(sessionID, resolved)
|
requests.set(sessionID, resolved)
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||||
@@ -530,6 +544,7 @@ export function createServerSession(
|
|||||||
if (!response.data.length) break
|
if (!response.data.length) break
|
||||||
}
|
}
|
||||||
const response = pages.at(-1)!
|
const response = pages.at(-1)!
|
||||||
|
await yieldToMain()
|
||||||
const source = pages.flatMap((page) => page.data).toReversed()
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
@@ -1300,6 +1315,7 @@ export function createServerSession(
|
|||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||||
if (!items)
|
if (!items)
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||||
|
indexProjectedMessage(input.message)
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
@@ -1333,6 +1349,9 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setData("session_message", input.sessionID, (messages) =>
|
||||||
|
messages?.filter((message) => message.id !== input.messageID),
|
||||||
|
)
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import type {
|
|||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionListInput,
|
SessionListInput,
|
||||||
|
OpenCodeEvent,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
import {
|
||||||
|
captureSessionMove,
|
||||||
|
loadActiveSessionsQuery,
|
||||||
|
loadMcpQuery,
|
||||||
|
loadMcpResourcesQuery,
|
||||||
|
seedActiveSessionStatuses,
|
||||||
|
} from "./server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
@@ -101,6 +109,30 @@ describe("active session query", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session move normalization", () => {
|
||||||
|
test("captures and applies current moves from the source placement", () => {
|
||||||
|
const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"])
|
||||||
|
session.remember(sessionAt("/source"))
|
||||||
|
const current = {
|
||||||
|
id: "event-current-move",
|
||||||
|
created: 10,
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: "session", seq: 1, version: 1 },
|
||||||
|
location: { directory: "/source" },
|
||||||
|
data: { sessionID: "session", location: { directory: "/destination" } },
|
||||||
|
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>
|
||||||
|
const event = adaptServerEvent(current)
|
||||||
|
|
||||||
|
expect(captureSessionMove(event, session.get)).toEqual({
|
||||||
|
sessionID: "session",
|
||||||
|
from: "/source",
|
||||||
|
})
|
||||||
|
session.applyV2(current)
|
||||||
|
session.apply(event)
|
||||||
|
expect(session.get("session")?.location.directory).toBe("/destination")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
const now = 5_000
|
const now = 5_000
|
||||||
@@ -171,6 +203,18 @@ function sessionInfo(id: string) {
|
|||||||
} as SessionInfo
|
} as SessionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionAt(directory: string): SessionInfo {
|
||||||
|
return {
|
||||||
|
id: "session",
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory },
|
||||||
|
title: "Session",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("estimateRootSessionTotal", () => {
|
describe("estimateRootSessionTotal", () => {
|
||||||
test("keeps exact total for full fetches", () => {
|
test("keeps exact total for full fetches", () => {
|
||||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { ServerSDK } from "./server-sdk"
|
import { ServerSDK, type ServerEvent } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
@@ -55,6 +55,28 @@ import { toggleMcp } from "./global-sync/mcp"
|
|||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
|
export function captureSessionMove(
|
||||||
|
event: ServerEvent,
|
||||||
|
get: (sessionID: string) => { location: { directory: string } } | undefined,
|
||||||
|
) {
|
||||||
|
if (event.current?.type !== "session.moved") return
|
||||||
|
return {
|
||||||
|
sessionID: event.current.data.sessionID,
|
||||||
|
from: get(event.current.data.sessionID)?.location.directory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
|
const type = event.current?.type ?? event.type
|
||||||
|
return (
|
||||||
|
type === "session.created" ||
|
||||||
|
type === "session.deleted" ||
|
||||||
|
type === "session.moved" ||
|
||||||
|
type === "session.renamed" ||
|
||||||
|
type === "session.forked"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
error?: InitError
|
error?: InitError
|
||||||
@@ -458,15 +480,51 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reindexSession = (sessionID: string, from?: string) => {
|
||||||
|
const next = session.get(sessionID)
|
||||||
|
if (!next) return
|
||||||
|
indexSession(next)
|
||||||
|
if (!from) return
|
||||||
|
const source = children.children[directoryKey(from)]
|
||||||
|
if (!source) return
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: {
|
||||||
|
type: "session.moved",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
projectID: next.projectID,
|
||||||
|
location: next.location,
|
||||||
|
subpath: next.subpath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directory: from,
|
||||||
|
store: source[0],
|
||||||
|
setStore: source[1],
|
||||||
|
push: queue.push,
|
||||||
|
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
|
||||||
|
sessionContent: false,
|
||||||
|
permission: session.data.permission,
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const eventType: string = event.type
|
const eventType: string = event.type
|
||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
const moved = captureSessionMove(event, session.get)
|
||||||
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
|
if (moved) reindexSession(moved.sessionID, moved.from)
|
||||||
|
if (shouldRefreshWorkspaceSessions(event)) {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
|
||||||
|
})
|
||||||
|
}
|
||||||
if (event.current?.type === "session.created")
|
if (event.current?.type === "session.created")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -519,10 +577,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.current?.type === "session.moved") {
|
|
||||||
const info = session.get(event.current.data.sessionID)
|
|
||||||
if (info) indexSession(info)
|
|
||||||
}
|
|
||||||
if (event.current?.type === "session.forked")
|
if (event.current?.type === "session.forked")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
@@ -639,6 +693,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session,
|
session,
|
||||||
|
reindexSession,
|
||||||
homeSessions,
|
homeSessions,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
|
||||||
|
export type WorkspaceLastUsed = "local" | "workspace"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -44,6 +48,10 @@ export interface Settings {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: boolean
|
autoApprove: boolean
|
||||||
}
|
}
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: WorkspaceDefaultDestination
|
||||||
|
lastUsed: Record<string, WorkspaceLastUsed>
|
||||||
|
}
|
||||||
notifications: NotificationSettings
|
notifications: NotificationSettings
|
||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
}
|
}
|
||||||
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
|
|||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: false,
|
autoApprove: false,
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: "last-used",
|
||||||
|
lastUsed: {},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: true,
|
agent: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("permissions", "autoApprove", value)
|
setStore("permissions", "autoApprove", value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
workspaces: {
|
||||||
|
defaultDestination: withFallback(
|
||||||
|
() => store.workspaces?.defaultDestination,
|
||||||
|
defaultSettings.workspaces.defaultDestination,
|
||||||
|
),
|
||||||
|
setDefaultDestination(value: WorkspaceDefaultDestination) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
defaultDestination: value,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
lastUsed(scope: ServerScope, projectID: string) {
|
||||||
|
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
|
||||||
|
},
|
||||||
|
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
|
||||||
|
setStore("workspaces", (current) => ({
|
||||||
|
...defaultSettings.workspaces,
|
||||||
|
...current,
|
||||||
|
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
||||||
setAgent(value: boolean) {
|
setAgent(value: boolean) {
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
active() {
|
||||||
|
if (location.pathname === "/") return
|
||||||
|
const key = recentKey()
|
||||||
|
return store.find((tab) => tabKey(tab) === key)
|
||||||
|
},
|
||||||
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
||||||
const next = { type: "session" as const, ...tab }
|
const next = { type: "session" as const, ...tab }
|
||||||
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
||||||
|
|||||||
@@ -892,14 +892,46 @@ export const dict = {
|
|||||||
"settings.section.desktop": "Desktop",
|
"settings.section.desktop": "Desktop",
|
||||||
"settings.section.server": "Server",
|
"settings.section.server": "Server",
|
||||||
"settings.tab.general": "General",
|
"settings.tab.general": "General",
|
||||||
|
"settings.tab.preferences": "Preferences",
|
||||||
"settings.tab.shortcuts": "Shortcuts",
|
"settings.tab.shortcuts": "Shortcuts",
|
||||||
|
"settings.tab.notifications": "Notifications",
|
||||||
|
"settings.tab.projects": "Projects",
|
||||||
|
"settings.tab.extensions": "Extensions",
|
||||||
|
"settings.preferences.description": "Customize preferences and theme and default behavior",
|
||||||
|
"settings.appearance.description": "Customize theme and fonts",
|
||||||
|
"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",
|
||||||
|
"settings.projects.title": "Projects",
|
||||||
|
"settings.projects.description": "Manage project settings on this server",
|
||||||
|
"settings.projects.empty": "No projects found",
|
||||||
|
"settings.projects.server.all": "All servers",
|
||||||
|
"settings.mcps.description": "Manage Model Context Protocol (MCP) servers and tools",
|
||||||
|
"settings.extensions.description": "Manage extensions available on this server",
|
||||||
|
"settings.extensions.tab.mcps": "MCPs",
|
||||||
|
"settings.extensions.tab.skills": "Skills",
|
||||||
|
"settings.extensions.availableAll": "Available to all projects",
|
||||||
|
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||||
|
"settings.extensions.addSkills": "How to add skills",
|
||||||
"settings.desktop.section.wsl": "WSL",
|
"settings.desktop.section.wsl": "WSL",
|
||||||
"settings.desktop.wsl.title": "WSL integration",
|
"settings.desktop.wsl.title": "WSL integration",
|
||||||
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
|
||||||
|
"dialog.server.authenticate.title": "Authenticate",
|
||||||
|
"project.settings.general.description": "Manage project name and appearance",
|
||||||
|
"project.settings.scripts": "Scripts",
|
||||||
|
"project.settings.scripts.description": "Configure scripts for this project",
|
||||||
|
"project.settings.extensions.description": "View extensions available to this project",
|
||||||
|
"project.settings.extensions.tab.lsps": "LSPs",
|
||||||
|
"project.settings.extensions.added": "Added to this project",
|
||||||
|
"project.settings.extensions.shared": "Shared with all projects",
|
||||||
|
"project.settings.extensions.lsp.detected": "Detected language servers",
|
||||||
|
"project.settings.extensions.lsp.description": "Auto-detected from file types",
|
||||||
|
"project.settings.extensions.setupRequired": "Setup required",
|
||||||
|
|
||||||
"settings.general.section.appearance": "Appearance",
|
"settings.general.section.appearance": "Appearance",
|
||||||
|
"settings.general.section.general": "General",
|
||||||
"settings.general.section.advanced": "Advanced",
|
"settings.general.section.advanced": "Advanced",
|
||||||
"settings.general.section.notifications": "System notifications",
|
"settings.general.section.notifications": "Desktop notifications",
|
||||||
"settings.general.section.updates": "Updates",
|
"settings.general.section.updates": "Updates",
|
||||||
"settings.general.section.sounds": "Sound effects",
|
"settings.general.section.sounds": "Sound effects",
|
||||||
"settings.general.section.feed": "Feed",
|
"settings.general.section.feed": "Feed",
|
||||||
@@ -1060,7 +1092,7 @@ export const dict = {
|
|||||||
"settings.shortcuts.group.prompt": "Prompt",
|
"settings.shortcuts.group.prompt": "Prompt",
|
||||||
|
|
||||||
"settings.providers.title": "Providers",
|
"settings.providers.title": "Providers",
|
||||||
"settings.providers.description": "Provider settings will be configurable here.",
|
"settings.providers.description": "Connect and manage model providers",
|
||||||
"settings.providers.section.connected": "Connected providers",
|
"settings.providers.section.connected": "Connected providers",
|
||||||
"settings.providers.connected.empty": "No connected providers",
|
"settings.providers.connected.empty": "No connected providers",
|
||||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||||
@@ -1071,7 +1103,7 @@ export const dict = {
|
|||||||
"settings.providers.tag.custom": "Custom",
|
"settings.providers.tag.custom": "Custom",
|
||||||
"settings.providers.tag.other": "Other",
|
"settings.providers.tag.other": "Other",
|
||||||
"settings.models.title": "Models",
|
"settings.models.title": "Models",
|
||||||
"settings.models.description": "Model settings will be configurable here.",
|
"settings.models.description": "Choose which models appear in model picker",
|
||||||
"settings.agents.title": "Agents",
|
"settings.agents.title": "Agents",
|
||||||
"settings.agents.description": "Agent settings will be configurable here.",
|
"settings.agents.description": "Agent settings will be configurable here.",
|
||||||
"settings.commands.title": "Commands",
|
"settings.commands.title": "Commands",
|
||||||
@@ -1123,6 +1155,46 @@ export const dict = {
|
|||||||
"session.delete.button": "Delete session",
|
"session.delete.button": "Delete session",
|
||||||
|
|
||||||
"workspace.new": "New workspace",
|
"workspace.new": "New workspace",
|
||||||
|
"common.viewAll": "View all",
|
||||||
|
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||||
|
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||||
|
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||||
|
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||||
|
"session.new.workspace.search.placeholder": "Search workspaces",
|
||||||
|
"settings.tab.workspaces": "Workspaces",
|
||||||
|
"settings.workspaces.filter.all": "All projects",
|
||||||
|
"settings.workspaces.empty": "No workspaces",
|
||||||
|
"settings.workspaces.count.one": "{{count}} workspace",
|
||||||
|
"settings.workspaces.count.other": "{{count}} workspaces",
|
||||||
|
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||||
|
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||||
|
"settings.workspaces.lastActiveSession": "Last active session",
|
||||||
|
"settings.workspaces.deleteAll": "Delete all workspaces",
|
||||||
|
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
|
||||||
|
"settings.workspaces.delete.warning":
|
||||||
|
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
|
||||||
|
"settings.workspaces.deleteAll.warning":
|
||||||
|
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||||
|
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
|
||||||
|
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
|
||||||
|
"settings.workspaces.default.title": "Default environment",
|
||||||
|
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||||
|
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||||
|
"settings.workspaces.default.local": "Local directory",
|
||||||
|
"settings.workspaces.default.new": "New workspace",
|
||||||
|
"workspace.move.title": "Move to workspace",
|
||||||
|
"workspace.move.menu.title": "Move session to",
|
||||||
|
"workspace.move.failed": "Failed to move session",
|
||||||
|
"workspace.lifecycle.creating": "Creating workspace",
|
||||||
|
"workspace.lifecycle.created": "Workspace created",
|
||||||
|
"workspace.lifecycle.starting": "Starting session",
|
||||||
|
"workspace.onboarding.title": "Isolate sessions with workspaces",
|
||||||
|
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||||
|
"workspace.lifecycle.moving": "Moving to workspace",
|
||||||
|
"workspace.lifecycle.set": "Workspace set",
|
||||||
|
"session.summary.title": "Session details",
|
||||||
|
"session.summary.noBranch": "No branch",
|
||||||
|
"session.summary.basedOn": "Based on {{branch}}",
|
||||||
"workspace.type.local": "local",
|
"workspace.type.local": "local",
|
||||||
"workspace.type.sandbox": "sandbox",
|
"workspace.type.sandbox": "sandbox",
|
||||||
"workspace.create.failed.title": "Failed to create workspace",
|
"workspace.create.failed.title": "Failed to create workspace",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { desktopNativePluralCategories } from "./desktop-native"
|
|
||||||
|
|
||||||
const appLocales = [
|
const appLocales = [
|
||||||
"ar",
|
"ar",
|
||||||
@@ -65,15 +64,54 @@ const appLocales = [
|
|||||||
"uz",
|
"uz",
|
||||||
] as const
|
] as const
|
||||||
const desktopLocales = appLocales
|
const desktopLocales = appLocales
|
||||||
const pluralCategories = new Map(
|
const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"])
|
||||||
appLocales.map(
|
const appFallbackKeys = new Set([
|
||||||
(locale) =>
|
"dialog.provider.custom.label",
|
||||||
[
|
"dialog.model.unpaid.viewMoreProviders",
|
||||||
locale,
|
"session.header.reveal.finder",
|
||||||
desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"),
|
"session.header.reveal.fileExplorer",
|
||||||
] as const,
|
"session.header.reveal.containingFolder",
|
||||||
),
|
"command.session.export",
|
||||||
)
|
"command.session.export.description",
|
||||||
|
"context.export.session",
|
||||||
|
"toast.session.export.success.title",
|
||||||
|
"toast.session.export.success.description",
|
||||||
|
"toast.session.export.failed.title",
|
||||||
|
"toast.session.export.failed.description",
|
||||||
|
"common.export",
|
||||||
|
"settings.tab.preferences",
|
||||||
|
"settings.tab.notifications",
|
||||||
|
"settings.tab.projects",
|
||||||
|
"settings.tab.extensions",
|
||||||
|
"settings.preferences.description",
|
||||||
|
"settings.appearance.description",
|
||||||
|
"settings.notifications.description",
|
||||||
|
"settings.shortcuts.description",
|
||||||
|
"settings.servers.description",
|
||||||
|
"settings.projects.title",
|
||||||
|
"settings.projects.description",
|
||||||
|
"settings.projects.empty",
|
||||||
|
"settings.projects.server.all",
|
||||||
|
"settings.mcps.description",
|
||||||
|
"settings.extensions.description",
|
||||||
|
"settings.extensions.tab.mcps",
|
||||||
|
"settings.extensions.tab.skills",
|
||||||
|
"settings.extensions.availableAll",
|
||||||
|
"settings.extensions.manageConfig",
|
||||||
|
"settings.extensions.addSkills",
|
||||||
|
"settings.general.section.general",
|
||||||
|
"dialog.server.authenticate.title",
|
||||||
|
"project.settings.general.description",
|
||||||
|
"project.settings.scripts",
|
||||||
|
"project.settings.scripts.description",
|
||||||
|
"project.settings.extensions.description",
|
||||||
|
"project.settings.extensions.tab.lsps",
|
||||||
|
"project.settings.extensions.added",
|
||||||
|
"project.settings.extensions.shared",
|
||||||
|
"project.settings.extensions.lsp.detected",
|
||||||
|
"project.settings.extensions.lsp.description",
|
||||||
|
"project.settings.extensions.setupRequired",
|
||||||
|
])
|
||||||
|
|
||||||
const domains = [
|
const domains = [
|
||||||
{
|
{
|
||||||
@@ -97,23 +135,22 @@ const domains = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
describe("i18n parity", () => {
|
describe("i18n parity", () => {
|
||||||
test("non-English locales have every English key and required plural variants", async () => {
|
test("non-English locales contain only English keys and their plural variants", async () => {
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const source = await dictionary(domain.source)
|
const source = await dictionary(domain.source)
|
||||||
|
const families = new Set(pluralFamilies(source))
|
||||||
for (const locale of domain.locales) {
|
for (const locale of domain.locales) {
|
||||||
const target = await dictionary(domain.target(locale))
|
const target = await dictionary(domain.target(locale))
|
||||||
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
|
const missing = Object.keys(source).filter(
|
||||||
|
(key) => !Object.hasOwn(target, key) && (domain.name !== "app" || !appFallbackKeys.has(key)),
|
||||||
|
)
|
||||||
const extra = Object.keys(target)
|
const extra = Object.keys(target)
|
||||||
.filter((key) => !Object.hasOwn(source, key))
|
.filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families))
|
||||||
.sort()
|
.sort()
|
||||||
const expected = pluralFamilies(source)
|
expect({ domain: domain.name, locale, extra }).toEqual({
|
||||||
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
|
|
||||||
.sort()
|
|
||||||
expect({ domain: domain.name, locale, missing, extra }).toEqual({
|
|
||||||
domain: domain.name,
|
domain: domain.name,
|
||||||
locale,
|
locale,
|
||||||
missing: [],
|
extra: [],
|
||||||
extra: expected,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,11 +164,11 @@ describe("i18n parity", () => {
|
|||||||
const mismatched = Object.keys(source).filter(
|
const mismatched = Object.keys(source).filter(
|
||||||
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
(key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(),
|
||||||
)
|
)
|
||||||
const pluralMismatched = pluralFamilies(source).flatMap((key) =>
|
const pluralMismatched = Object.keys(target).filter((key) => {
|
||||||
(pluralCategories.get(locale) ?? [])
|
const family = pluralFamily(key)
|
||||||
.map((category) => `${key}.${category}`)
|
if (!family || !Object.hasOwn(source, `${family}.other`)) return false
|
||||||
.filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()),
|
return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join()
|
||||||
)
|
})
|
||||||
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({
|
||||||
domain: domain.name,
|
domain: domain.name,
|
||||||
locale,
|
locale,
|
||||||
@@ -169,38 +206,6 @@ describe("i18n parity", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("i18n plural parity", () => {
|
|
||||||
test("locale-specific categories exist and preserve count placeholders", async () => {
|
|
||||||
for (const domain of domains.slice(0, 2)) {
|
|
||||||
const source = await dictionary(domain.source)
|
|
||||||
const families = pluralFamilies(source)
|
|
||||||
for (const locale of domain.locales) {
|
|
||||||
const target = await dictionary(domain.target(locale))
|
|
||||||
const missing = families.flatMap((key) =>
|
|
||||||
(pluralCategories.get(locale) ?? [])
|
|
||||||
.map((category) => `${key}.${category}`)
|
|
||||||
.filter((variant) => !Object.hasOwn(target, variant)),
|
|
||||||
)
|
|
||||||
const mismatched = families.flatMap((key) =>
|
|
||||||
(pluralCategories.get(locale) ?? [])
|
|
||||||
.map((category) => `${key}.${category}`)
|
|
||||||
.filter(
|
|
||||||
(variant) =>
|
|
||||||
Object.hasOwn(target, variant) &&
|
|
||||||
placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
expect({ domain: domain.name, locale, missing, mismatched }).toEqual({
|
|
||||||
domain: domain.name,
|
|
||||||
locale,
|
|
||||||
missing: [],
|
|
||||||
mismatched: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function dictionary(file: string) {
|
async function dictionary(file: string) {
|
||||||
const module: unknown = await import(file)
|
const module: unknown = await import(file)
|
||||||
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) {
|
||||||
@@ -220,11 +225,17 @@ function placeholders(value: string) {
|
|||||||
|
|
||||||
function pluralFamilies(dictionary: Record<string, string>) {
|
function pluralFamilies(dictionary: Record<string, string>) {
|
||||||
return Object.keys(dictionary)
|
return Object.keys(dictionary)
|
||||||
.filter(
|
.filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`))
|
||||||
(key) =>
|
|
||||||
key.endsWith(".one") &&
|
|
||||||
dictionary[key].includes("{{count}}") &&
|
|
||||||
dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"),
|
|
||||||
)
|
|
||||||
.map((key) => key.slice(0, -4))
|
.map((key) => key.slice(0, -4))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pluralFamily(key: string) {
|
||||||
|
const split = key.lastIndexOf(".")
|
||||||
|
if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return
|
||||||
|
return key.slice(0, split)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPluralVariant(key: string, families: Set<string>) {
|
||||||
|
const family = pluralFamily(key)
|
||||||
|
return family !== undefined && families.has(family)
|
||||||
|
}
|
||||||
|
|||||||
@@ -327,4 +327,9 @@
|
|||||||
animation-range: 0 0.1px;
|
animation-range: 0 0.1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
|
||||||
|
height: 24px;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ export {
|
|||||||
} from "./wsl/types"
|
} from "./wsl/types"
|
||||||
export { ServerConnection } from "./context/server"
|
export { ServerConnection } from "./context/server"
|
||||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
||||||
|
export { preloadSessionRoute } from "./pages/session-lazy"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { createHomeController } from "./home/home-controller"
|
import { createHomeController } from "./home/home-controller"
|
||||||
import { createHomeProjectsController } from "./home/home-projects-controller"
|
import { createHomeProjectsController } from "./home/home-projects-controller"
|
||||||
import { HomeUtilityNav } from "./home/home-projects-view"
|
import { HomeUtilityNav } from "./home/home-projects-view"
|
||||||
@@ -7,8 +8,23 @@ import { createHomeScrollController } from "./home/home-scroll-controller"
|
|||||||
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
|
||||||
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
import { createHomeSessionsController } from "./home/home-sessions-controller"
|
||||||
import { HomeSessions } from "./home/home-sessions"
|
import { HomeSessions } from "./home/home-sessions"
|
||||||
|
import { preloadSessionRoute } from "./session-lazy"
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
|
onMount(() => {
|
||||||
|
let idle: number | undefined
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if ("requestIdleCallback" in window) {
|
||||||
|
idle = requestIdleCallback(() => void preloadSessionRoute(), { timeout: 3_000 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void preloadSessionRoute()
|
||||||
|
}, 1_500)
|
||||||
|
onCleanup(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (idle !== undefined) cancelIdleCallback(idle)
|
||||||
|
})
|
||||||
|
})
|
||||||
const home = createHomeController()
|
const home = createHomeController()
|
||||||
const projects = createHomeProjectsController(home)
|
const projects = createHomeProjectsController(home)
|
||||||
const sessions = createHomeSessionsController(home)
|
const sessions = createHomeSessionsController(home)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from
|
|||||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { archiveHomeSession } from "../home-session-archive"
|
import { archiveHomeSession } from "../home-session-archive"
|
||||||
import type { HomeController } from "./home-controller"
|
import type { HomeController } from "./home-controller"
|
||||||
@@ -208,6 +209,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const conn = home.server.focused()
|
const conn = home.server.focused()
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!conn || !ctx) return
|
if (!conn || !ctx) return
|
||||||
|
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
|
||||||
const [, setStore] = ctx.sync.child(session.location.directory)
|
const [, setStore] = ctx.sync.child(session.location.directory)
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||||
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { createEffect, createResource } from "solid-js"
|
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||||
|
import { useSearchParams } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, createResource } from "solid-js"
|
||||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||||
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
|
||||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||||
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
|
|||||||
export default function NewSessionPage() {
|
export default function NewSessionPage() {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
const workspace = createNewSessionWorkspaceController()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
|
const draftTab = createMemo(() =>
|
||||||
|
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
|
||||||
|
)
|
||||||
|
const workspace = createNewSessionWorkspaceController({
|
||||||
|
selected: () => draftTab()?.worktree,
|
||||||
|
setSelected: (worktree) => {
|
||||||
|
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
|
||||||
|
},
|
||||||
|
onViewAll: openWorkspaces,
|
||||||
|
})
|
||||||
const draft = createNewSessionDraftController({
|
const draft = createNewSessionDraftController({
|
||||||
worktree: workspace.selection.value,
|
worktree: workspace.selection.value,
|
||||||
resetWorktree: workspace.selection.reset,
|
resetWorktree: workspace.selection.reset,
|
||||||
|
onSubmit: workspace.selection.remember,
|
||||||
})
|
})
|
||||||
const project = createPromptProjectController({
|
const project = createPromptProjectController({
|
||||||
controls: draft.project.controls,
|
controls: draft.project.controls,
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
|
|||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
|
|
||||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
export function createNewSessionDraftController(workspace: {
|
||||||
|
worktree: () => string
|
||||||
|
resetWorktree: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
|
|||||||
return workspace.worktree()
|
return workspace.worktree()
|
||||||
},
|
},
|
||||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||||
onSubmit: comments.clear,
|
onSubmit: () => {
|
||||||
|
workspace.onSubmit()
|
||||||
|
comments.clear()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
|
||||||
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
|
||||||
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
|
|||||||
project: PromptProjectController
|
project: PromptProjectController
|
||||||
workspace: NewSessionWorkspaceController
|
workspace: NewSessionWorkspaceController
|
||||||
}) {
|
}) {
|
||||||
|
const [onboarding, setOnboarding, , onboardingReady] = persisted(
|
||||||
|
Persist.global("workspace-onboarding"),
|
||||||
|
createStore({ used: false }),
|
||||||
|
)
|
||||||
|
const select = (value: string) => {
|
||||||
|
props.workspace.selection.set(value)
|
||||||
|
if (value !== "main") setOnboarding("used", true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||||
<div
|
<div
|
||||||
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
|
|||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||||
<div class="mt-8 flex flex-col gap-8">
|
<div class="mt-8 flex flex-col gap-8">
|
||||||
<PromptInputV2Composer controller={props.input} />
|
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
|
||||||
<Show when={props.project.empty()}>
|
<Show when={props.project.empty()}>
|
||||||
<PromptProjectAddButton controller={props.project} />
|
<PromptProjectAddButton controller={props.project} />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
|
|||||||
projectRoot={props.workspace.project.root()}
|
projectRoot={props.workspace.project.root()}
|
||||||
workspaces={props.workspace.project.workspaces()}
|
workspaces={props.workspace.project.workspaces()}
|
||||||
branch={props.workspace.bar.branch()}
|
branch={props.workspace.bar.branch()}
|
||||||
onChange={props.workspace.selection.set}
|
onboarding={onboardingReady() && !onboarding.used}
|
||||||
|
onChange={select}
|
||||||
onDone={props.input.restoreFocus}
|
onDone={props.input.restoreFocus}
|
||||||
|
onViewAll={props.workspace.project.openAll}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +148,7 @@ function ProviderTip() {
|
|||||||
>
|
>
|
||||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<TooltipV2
|
<TooltipV2
|
||||||
@@ -152,7 +163,7 @@ function ProviderTip() {
|
|||||||
aria-label={language.t("common.dismiss")}
|
aria-label={language.t("common.dismiss")}
|
||||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||||
>
|
>
|
||||||
<IconV2 name="xmark-small" />
|
<Icon name="xmark-small" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
import {
|
||||||
|
isWorkspaceDirectory,
|
||||||
|
isWorkspaceSelection,
|
||||||
|
workspaceDefaultSelection,
|
||||||
|
workspaceDirectories,
|
||||||
|
} from "@/utils/workspace"
|
||||||
|
|
||||||
export function resolveNewSessionWorktree(input: {
|
export function resolveNewSessionWorktree(input: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
selected?: string
|
selected?: string
|
||||||
directory: string
|
directory: string
|
||||||
projectWorktree?: string
|
projectWorktree?: string
|
||||||
|
fallback?: string
|
||||||
}) {
|
}) {
|
||||||
if (!input.enabled) return "main"
|
if (!input.enabled) return "main"
|
||||||
if (input.selected) return input.selected
|
if (input.selected) return input.selected
|
||||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||||
return "main"
|
return input.fallback ?? "main"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||||
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
|
|||||||
return input.worktreeBranch(input.worktree) ?? input.local
|
return input.worktreeBranch(input.worktree) ?? input.local
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNewSessionWorkspaceController() {
|
export function createNewSessionWorkspaceController(input: {
|
||||||
|
selected: () => string | undefined
|
||||||
|
setSelected: (worktree: string | undefined) => void
|
||||||
|
onViewAll: () => void
|
||||||
|
}) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const [worktree, setWorktree] = createSignal<string>()
|
const settings = useSettings()
|
||||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||||
|
const selected = createMemo(() => {
|
||||||
|
const project = sync().project
|
||||||
|
const worktree = input.selected()
|
||||||
|
if (!project || !worktree) return
|
||||||
|
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||||
|
})
|
||||||
|
const fallback = createMemo(() => {
|
||||||
|
const project = sync().project
|
||||||
|
if (!project) return "main"
|
||||||
|
return workspaceDefaultSelection(
|
||||||
|
settings.workspaces.defaultDestination(),
|
||||||
|
settings.workspaces.lastUsed(serverSDK().scope, project.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
const value = createMemo(() =>
|
const value = createMemo(() =>
|
||||||
resolveNewSessionWorktree({
|
resolveNewSessionWorktree({
|
||||||
enabled: visible(),
|
enabled: visible(),
|
||||||
selected: worktree(),
|
selected: selected(),
|
||||||
directory: sdk().directory,
|
directory: sdk().directory,
|
||||||
projectWorktree: sync().project?.worktree,
|
projectWorktree: sync().project?.worktree,
|
||||||
|
fallback: fallback(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
|
|||||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const remember = (worktree = value()) => {
|
||||||
|
const project = sync().project
|
||||||
|
if (!project) return
|
||||||
|
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
|
||||||
|
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selection: {
|
selection: {
|
||||||
value,
|
value,
|
||||||
reset: () => setWorktree(),
|
workspace: createMemo(() => {
|
||||||
set: (worktree: string) =>
|
const project = sync().project
|
||||||
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
const current = value()
|
||||||
|
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||||
|
}),
|
||||||
|
reset: () => input.setSelected(undefined),
|
||||||
|
remember,
|
||||||
|
set: (worktree: string) => {
|
||||||
|
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||||
|
remember(worktree)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
project: {
|
project: {
|
||||||
root: projectRoot,
|
root: projectRoot,
|
||||||
workspaces: () => sync().project?.sandboxes ?? [],
|
workspaces: () => {
|
||||||
|
const project = sync().project
|
||||||
|
return project ? workspaceDirectories(project) : []
|
||||||
|
},
|
||||||
git: () => sync().project?.vcs === "git",
|
git: () => sync().project?.vcs === "git",
|
||||||
|
openAll: input.onViewAll,
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
visible,
|
visible,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { lazy } from "solid-js"
|
||||||
|
|
||||||
|
export const TargetSessionRoute = lazy(() => import("./target-session-route"))
|
||||||
|
export const preloadSessionRoute = TargetSessionRoute.preload
|
||||||
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
|||||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
@@ -101,6 +102,7 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
|
import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionLineage } from "./session/session-lineage"
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
@@ -521,6 +523,9 @@ export default function Page() {
|
|||||||
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
|
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceSession = createMemo(() =>
|
||||||
|
isWorkspaceDirectory(sync().project, controller.data.info()?.location.directory ?? sdk().directory),
|
||||||
|
)
|
||||||
const timeline = createTimelineModel({ session: controller })
|
const timeline = createTimelineModel({ session: controller })
|
||||||
const historyLoading = timeline.history.loading
|
const historyLoading = timeline.history.loading
|
||||||
const historyMore = timeline.history.more
|
const historyMore = timeline.history.more
|
||||||
@@ -575,6 +580,7 @@ export default function Page() {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
...sessionViewState(),
|
...sessionViewState(),
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
|
sessionDetailsOpen: false,
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -677,6 +683,19 @@ export default function Page() {
|
|||||||
: skipToken,
|
: skipToken,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const sessionDetailsQuery = createQuery(() => ({
|
||||||
|
queryKey: [...vcsKey(), "git"] as const,
|
||||||
|
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
|
||||||
|
queryFn: () =>
|
||||||
|
sdk()
|
||||||
|
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch((error) => {
|
||||||
|
console.debug("[session-review] failed to load session details diff", { error })
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
|
||||||
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -1671,6 +1690,8 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||||
|
const workspaceOperationPending = (sessionID: string) =>
|
||||||
|
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||||
|
|
||||||
const queuedFollowups = createMemo(() => {
|
const queuedFollowups = createMemo(() => {
|
||||||
const id = controller.identity.params.id
|
const id = controller.identity.params.id
|
||||||
@@ -1684,8 +1705,20 @@ export default function Page() {
|
|||||||
return followup.edit[id]
|
return followup.edit[id]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const workspaceMoveEligible = createMemo(() => {
|
||||||
|
const id = controller.identity.params.id
|
||||||
|
if (!id) return false
|
||||||
|
return canMoveSessionToWorkspace({
|
||||||
|
queued: followup.items[id]?.length ?? 0,
|
||||||
|
failed: !!followup.failed[id],
|
||||||
|
paused: !!followup.paused[id],
|
||||||
|
editing: !!followup.edit[id],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const followupMutation = useMutation(() => ({
|
const followupMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||||
|
if (workspaceOperationPending(input.sessionID)) return
|
||||||
const owner = controller.ownership.capture()
|
const owner = controller.ownership.capture()
|
||||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -1695,6 +1728,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api: sdk().api.session,
|
||||||
|
scope: serverSDK().scope,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync(),
|
serverSync: serverSync(),
|
||||||
session: () => sync().session.get(input.sessionID),
|
session: () => sync().session.get(input.sessionID),
|
||||||
@@ -1763,6 +1797,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||||
|
if (workspaceOperationPending(sessionID)) return Promise.resolve()
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
@@ -1802,6 +1837,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const revertMutation = useMutation(() => ({
|
const revertMutation = useMutation(() => ({
|
||||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||||
|
if (workspaceOperationPending(input.sessionID)) return
|
||||||
const api = sdk().api.session
|
const api = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
const last = target.session.get(input.sessionID)?.revert
|
const last = target.session.get(input.sessionID)?.revert
|
||||||
@@ -1824,6 +1860,7 @@ export default function Page() {
|
|||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
const sessionID = controller.identity.params.id
|
const sessionID = controller.identity.params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
|
|
||||||
const api = sdk().api.session
|
const api = sdk().api.session
|
||||||
const target = sync()
|
const target = sync()
|
||||||
@@ -1853,7 +1890,10 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
const reverting = createMemo(() => {
|
||||||
|
const id = controller.identity.params.id
|
||||||
|
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
|
||||||
|
})
|
||||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||||
|
|
||||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||||
@@ -1913,6 +1953,7 @@ export default function Page() {
|
|||||||
if (controller.data.isChild()) return
|
if (controller.data.isChild()) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
if (controller.data.working()) return
|
if (controller.data.working()) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
|
|
||||||
void sendFollowup(sessionID, item.id)
|
void sendFollowup(sessionID, item.id)
|
||||||
})
|
})
|
||||||
@@ -2020,7 +2061,7 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
{hasReview()
|
{hasReview()
|
||||||
? language.t("session.review.filesChanged", { count: reviewCount() })
|
? language.t("session.review.filesChanged", { count: reviewCount() })
|
||||||
: language.t("session.review.change.other")}
|
: language.plural("session.review.change", 0)}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -2088,6 +2129,9 @@ export default function Page() {
|
|||||||
if (root) scheduleScrollState(root)
|
if (root) scheduleScrollState(root)
|
||||||
}}
|
}}
|
||||||
userMessages={visibleUserMessages()}
|
userMessages={visibleUserMessages()}
|
||||||
|
diffs={sessionDetailsDiffs}
|
||||||
|
workspaceMoveEligible={workspaceMoveEligible()}
|
||||||
|
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
|
||||||
setHistoryAnchor={(handlers) => {
|
setHistoryAnchor={(handlers) => {
|
||||||
captureHistoryAnchor = handlers.capture
|
captureHistoryAnchor = handlers.capture
|
||||||
restoreHistoryAnchor = handlers.restore
|
restoreHistoryAnchor = handlers.restore
|
||||||
@@ -2215,7 +2259,13 @@ export default function Page() {
|
|||||||
setFollowup("paused", id, true)
|
setFollowup("paused", id, true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
|
return (
|
||||||
|
<PromptInputV2Composer
|
||||||
|
controller={promptInputController}
|
||||||
|
borderUnderlay
|
||||||
|
accentSubmit={workspaceSession()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function createPromptProjectControls() {
|
|||||||
const target = global.ensureServerCtx(conn)
|
const target = global.ensureServerCtx(conn)
|
||||||
target.projects.open(worktree)
|
target.projects.open(worktree)
|
||||||
target.projects.touch(worktree)
|
target.projects.touch(worktree)
|
||||||
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
|
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
|
|||||||
when={settings.general.newLayoutDesigns()}
|
when={settings.general.newLayoutDesigns()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
{props.reviewCount()}{" "}
|
{props.reviewCount()} {language.plural("session.review.change", props.reviewCount())}
|
||||||
{language.t(
|
|
||||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||||
@@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||||
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
|
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||||
@@ -49,11 +51,21 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
|||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
import { scheduleConnectedMeasure } from "./measure"
|
import { scheduleConnectedMeasure } from "./measure"
|
||||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||||
import { filterVirtualIndexes } from "./virtual-items"
|
import { filterVirtualIndexes } from "./virtual-items"
|
||||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||||
|
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||||
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
|
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
|
||||||
|
import { getProjectAvatarVariant } from "@/context/layout"
|
||||||
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
|
||||||
const emptyTools: ToolPart[] = []
|
const emptyTools: ToolPart[] = []
|
||||||
const emptyAssistantMessages: AssistantMessage[] = []
|
const emptyAssistantMessages: AssistantMessage[] = []
|
||||||
@@ -108,7 +120,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const maxFiles = 10
|
const maxFiles = 10
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -136,6 +148,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|||||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
{props.action}
|
||||||
</div>
|
</div>
|
||||||
<div data-component="session-turn-diffs-content">
|
<div data-component="session-turn-diffs-content">
|
||||||
<Accordion
|
<Accordion
|
||||||
@@ -190,6 +203,179 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceLocationLoader() {
|
||||||
|
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
|
||||||
|
return (
|
||||||
|
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
|
||||||
|
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
|
||||||
|
<For each={dots}>
|
||||||
|
{(position, index) => (
|
||||||
|
<span
|
||||||
|
class={`absolute size-1 bg-current ${position} animate-pulse`}
|
||||||
|
style={{ "animation-delay": `${index() * -180}ms` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceMoveAction(props: {
|
||||||
|
variant: "inline" | "panel"
|
||||||
|
eligible: boolean
|
||||||
|
sessionID: string
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
messageID?: string
|
||||||
|
dismissed: boolean
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const inline = () => props.variant === "inline"
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"group/workspace-move relative shrink-0": true,
|
||||||
|
"ml-auto h-5 w-[167px]": inline(),
|
||||||
|
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
|
||||||
|
!inline(),
|
||||||
|
invisible: props.dismissed,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionWorkspaceMenu
|
||||||
|
eligible={props.eligible}
|
||||||
|
sessionID={props.sessionID}
|
||||||
|
project={props.project}
|
||||||
|
directory={props.directory}
|
||||||
|
messageID={props.messageID}
|
||||||
|
placement={inline() ? "bottom-end" : "left-start"}
|
||||||
|
gutter={inline() ? 4 : -22}
|
||||||
|
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||||
|
class={
|
||||||
|
inline()
|
||||||
|
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||||
|
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||||
|
</SessionWorkspaceMenu>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
|
||||||
|
inline()
|
||||||
|
? "right-0 top-1/2"
|
||||||
|
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
|
||||||
|
}`}
|
||||||
|
aria-label={language.t("common.dismiss")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
props.onDismiss()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconV2 name="xmark-small" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionSummaryPanel(props: {
|
||||||
|
project: Project
|
||||||
|
directory: string
|
||||||
|
local: boolean
|
||||||
|
branch?: string
|
||||||
|
baseBranch?: string
|
||||||
|
diffs: { additions: number; deletions: number }[]
|
||||||
|
sessionID: string
|
||||||
|
moveEligible: boolean
|
||||||
|
messageID?: string
|
||||||
|
moveDismissed: boolean
|
||||||
|
onMoveDismiss: () => void
|
||||||
|
onReview: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
|
||||||
|
const branch = () => props.branch ?? props.baseBranch
|
||||||
|
const row =
|
||||||
|
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="session-summary-panel" class="w-[280px]">
|
||||||
|
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||||
|
<div class={row}>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={displayName(props.project)}
|
||||||
|
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||||
|
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||||
|
</div>
|
||||||
|
<SessionWorkspaceMenu
|
||||||
|
eligible={props.moveEligible}
|
||||||
|
sessionID={props.sessionID}
|
||||||
|
project={props.project}
|
||||||
|
directory={props.directory}
|
||||||
|
messageID={props.messageID}
|
||||||
|
placement="left-start"
|
||||||
|
gutter={-22}
|
||||||
|
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||||
|
>
|
||||||
|
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
|
||||||
|
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</SessionWorkspaceMenu>
|
||||||
|
<div class={row}>
|
||||||
|
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<Show
|
||||||
|
when={props.branch}
|
||||||
|
fallback={
|
||||||
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span>{language.t("session.summary.noBranch")}</span>
|
||||||
|
<Show when={props.baseBranch}>
|
||||||
|
{(base) => (
|
||||||
|
<>
|
||||||
|
<span class="text-v2-text-text-muted">·</span>
|
||||||
|
<span class="truncate text-v2-text-text-faint">
|
||||||
|
{language.t("session.summary.basedOn", { branch: base() })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{branch()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
|
||||||
|
onClick={props.onReview}
|
||||||
|
>
|
||||||
|
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
|
||||||
|
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
|
||||||
|
<span class="text-v2-text-text-muted">·</span>
|
||||||
|
<DiffChanges changes={props.diffs} />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
|
||||||
|
<WorkspaceMoveAction
|
||||||
|
variant="panel"
|
||||||
|
eligible={props.moveEligible}
|
||||||
|
sessionID={props.sessionID}
|
||||||
|
project={props.project}
|
||||||
|
directory={props.directory}
|
||||||
|
messageID={props.messageID}
|
||||||
|
dismissed={props.moveDismissed}
|
||||||
|
onDismiss={props.onMoveDismiss}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
const view = normalize(props.diff)
|
const view = normalize(props.diff)
|
||||||
@@ -218,6 +404,9 @@ type MessageTimelineProps = {
|
|||||||
centered: boolean
|
centered: boolean
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
userMessages: UserMessage[]
|
userMessages: UserMessage[]
|
||||||
|
diffs: Accessor<{ additions: number; deletions: number }[]>
|
||||||
|
workspaceMoveEligible: boolean
|
||||||
|
onSummaryOpenChange: (open: boolean) => void
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
setRevealMessage?: (fn: (id: string) => void) => void
|
setRevealMessage?: (fn: (id: string) => void) => void
|
||||||
setScrollToEnd?: (fn: () => void) => void
|
setScrollToEnd?: (fn: () => void) => void
|
||||||
@@ -240,6 +429,11 @@ function MessageTimelineView(
|
|||||||
) {
|
) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const command = useCommand()
|
||||||
const ownerSessionKey = props.data.sessionKey()
|
const ownerSessionKey = props.data.sessionKey()
|
||||||
const cached = timelineCache.get(ownerSessionKey)
|
const cached = timelineCache.get(ownerSessionKey)
|
||||||
const initialMeasurements = cached?.measurements
|
const initialMeasurements = cached?.measurements
|
||||||
@@ -254,18 +448,87 @@ function MessageTimelineView(
|
|||||||
const parentID = props.data.parentID
|
const parentID = props.data.parentID
|
||||||
const parentTitle = props.data.parentTitle
|
const parentTitle = props.data.parentTitle
|
||||||
const childTitle = props.data.childTitle
|
const childTitle = props.data.childTitle
|
||||||
const showHeader = props.data.showHeader
|
|
||||||
const getMsgParts = props.data.parts
|
const getMsgParts = props.data.parts
|
||||||
const getMsgPart = props.data.part
|
const getMsgPart = props.data.part
|
||||||
const projection = props.data.projection
|
const projection = props.data.projection
|
||||||
|
const sessionDirectory = createMemo(
|
||||||
|
() => props.session.data.info()?.location.directory ?? sdk().directory,
|
||||||
|
)
|
||||||
|
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||||
|
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||||
|
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||||
|
const setSummary = (open: boolean) => {
|
||||||
|
setSummaryOpen(open)
|
||||||
|
props.onSummaryOpenChange(open)
|
||||||
|
}
|
||||||
|
const sessionDiffs = createMemo(props.diffs)
|
||||||
|
createEffect(
|
||||||
|
on(sessionID, () => {
|
||||||
|
setSummary(false)
|
||||||
|
setWorkspaceSuggestionDismissed(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const turnPadding = () => "px-4 md:px-5"
|
||||||
|
const workspaceOperation = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
return WorkspaceOperation.get(serverSDK().scope, id)
|
||||||
|
})
|
||||||
|
const lifecycleTitle = createMemo(() => {
|
||||||
|
const operation = workspaceOperation()
|
||||||
|
if (operation?.status === "pending") {
|
||||||
|
return {
|
||||||
|
kind: "pending" as const,
|
||||||
|
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (operation?.type === "create" && !props.data.titleValue())
|
||||||
|
return { kind: "created" as const, text: language.t("workspace.lifecycle.created") }
|
||||||
|
if (!props.data.titleValue())
|
||||||
|
return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") }
|
||||||
|
return
|
||||||
|
})
|
||||||
|
const workspaceOperationPending = (sessionID: string) =>
|
||||||
|
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
|
||||||
|
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||||
const activeMessageID = projection.activeMessageID
|
const activeMessageID = projection.activeMessageID
|
||||||
const assistantMessagesByParent = projection.assistantMessagesByParent
|
const assistantMessagesByParent = projection.assistantMessagesByParent
|
||||||
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
const lastAssistantGroupKey = projection.lastAssistantGroupKey
|
||||||
const messageByID = projection.messageByID
|
const messageByID = projection.messageByID
|
||||||
const messageLastRowIndex = projection.messageLastRowIndex
|
const timelineRows = createMemo(() => {
|
||||||
const messageRowIndex = projection.messageRowIndex
|
const rows = projection.rows()
|
||||||
const timelineRowByKey = projection.rowByKey
|
const operation = workspaceOperation()
|
||||||
const timelineRows = projection.rows
|
const userMessageID = operation?.messageID ?? props.userMessages.at(-1)?.id
|
||||||
|
if (!operation || !userMessageID) return rows
|
||||||
|
const index = rows.findIndex((row) => row._tag === "UserMessage" && row.userMessageID === userMessageID)
|
||||||
|
if (index < 0) return rows
|
||||||
|
return [
|
||||||
|
...rows.slice(0, index + 1),
|
||||||
|
new TimelineRow.WorkspaceLifecycle({
|
||||||
|
userMessageID,
|
||||||
|
notice: { type: "operation", operation },
|
||||||
|
}),
|
||||||
|
...rows.slice(index + 1),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const timelineRowByKey = createMemo(
|
||||||
|
() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)),
|
||||||
|
)
|
||||||
|
const messageRowIndex = createMemo(() => {
|
||||||
|
const result = new Map<string, number>()
|
||||||
|
timelineRows().forEach((row, index) => {
|
||||||
|
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
|
||||||
|
result.set(row.userMessageID, index)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const messageLastRowIndex = createMemo(() => {
|
||||||
|
const result = new Map<string, number>()
|
||||||
|
timelineRows().forEach((row, index) => {
|
||||||
|
if ("userMessageID" in row) result.set(row.userMessageID, index)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
let prependAnchor: { key: string; offset: number } | undefined
|
let prependAnchor: { key: string; offset: number } | undefined
|
||||||
let prependAnchorFrame: number | undefined
|
let prependAnchorFrame: number | undefined
|
||||||
@@ -749,7 +1012,7 @@ function MessageTimelineView(
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={commentStripRow}>
|
<TimelineRowFrame row={commentStripRow}>
|
||||||
<div class="w-full px-4 md:px-5 pb-2">
|
<div class={`w-full pb-2 ${turnPadding()}`}>
|
||||||
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
<Index each={comments()}>
|
<Index each={comments()}>
|
||||||
@@ -800,7 +1063,7 @@ function MessageTimelineView(
|
|||||||
<TimelineRowFrame row={userMessageRow}>
|
<TimelineRowFrame row={userMessageRow}>
|
||||||
<Show when={message()}>
|
<Show when={message()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<div data-slot="session-turn-message-content" aria-live="off">
|
<div data-slot="session-turn-message-content" aria-live="off">
|
||||||
<Message
|
<Message
|
||||||
message={message()}
|
message={message()}
|
||||||
@@ -816,11 +1079,55 @@ function MessageTimelineView(
|
|||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case "WorkspaceLifecycle": {
|
||||||
|
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
|
||||||
|
const operation = () => workspaceRow().notice.operation
|
||||||
|
const pending = () => operation().status === "pending"
|
||||||
|
const status = () => {
|
||||||
|
if (operation().status === "failed") return language.t("workspace.move.failed")
|
||||||
|
if (operation().type === "create")
|
||||||
|
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
|
||||||
|
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
|
||||||
|
}
|
||||||
|
const directory = () => getFilename(operation().directory)
|
||||||
|
return (
|
||||||
|
<TimelineRowFrame row={workspaceRow}>
|
||||||
|
<div class={`w-full ${turnPadding()}`} aria-live="polite">
|
||||||
|
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
|
||||||
|
<Show
|
||||||
|
when={!pending()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<TextShimmer text={status()} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex items-center gap-1.5": true,
|
||||||
|
"text-v2-state-fg-danger": operation().status === "failed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
|
||||||
|
<Show when={operation().status !== "failed"}>
|
||||||
|
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
|
||||||
|
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-accent" />
|
||||||
|
<Show when={directory()}>
|
||||||
|
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TimelineRowFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
case "TurnDivider": {
|
case "TurnDivider": {
|
||||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={turnDividerRow}>
|
<TimelineRowFrame row={turnDividerRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<div data-slot="session-turn-compaction">
|
<div data-slot="session-turn-compaction">
|
||||||
<MessageDivider
|
<MessageDivider
|
||||||
label={language.t(
|
label={language.t(
|
||||||
@@ -836,7 +1143,7 @@ function MessageTimelineView(
|
|||||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={assistantPartRow}>
|
<TimelineRowFrame row={assistantPartRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<div
|
<div
|
||||||
data-slot="session-turn-assistant-content"
|
data-slot="session-turn-assistant-content"
|
||||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||||
@@ -851,7 +1158,7 @@ function MessageTimelineView(
|
|||||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={thinkingRow}>
|
<TimelineRowFrame row={thinkingRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<TimelineThinkingRow
|
<TimelineThinkingRow
|
||||||
reasoningHeading={thinkingRow().reasoningHeading}
|
reasoningHeading={thinkingRow().reasoningHeading}
|
||||||
showReasoningSummaries={props.data.showReasoningSummaries()}
|
showReasoningSummaries={props.data.showReasoningSummaries()}
|
||||||
@@ -864,7 +1171,7 @@ function MessageTimelineView(
|
|||||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={retryRow}>
|
<TimelineRowFrame row={retryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
@@ -872,10 +1179,35 @@ function MessageTimelineView(
|
|||||||
}
|
}
|
||||||
case "DiffSummary": {
|
case "DiffSummary": {
|
||||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||||
|
const canMove = () =>
|
||||||
|
props.data.newLayoutDesigns() &&
|
||||||
|
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||||
|
!workspaceSession() &&
|
||||||
|
props.workspaceMoveEligible &&
|
||||||
|
sync().project?.vcs === "git" &&
|
||||||
|
sessionStatus().type === "idle"
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={diffSummaryRow}>
|
<TimelineRowFrame row={diffSummaryRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
<TimelineDiffSummaryRow
|
||||||
|
diffs={diffSummaryRow().diffs}
|
||||||
|
action={
|
||||||
|
<Show when={canMove() && sync().project}>
|
||||||
|
{(project) => (
|
||||||
|
<WorkspaceMoveAction
|
||||||
|
variant="inline"
|
||||||
|
eligible={props.workspaceMoveEligible}
|
||||||
|
sessionID={sessionID()!}
|
||||||
|
project={project()}
|
||||||
|
directory={sessionDirectory()}
|
||||||
|
messageID={diffSummaryRow().userMessageID}
|
||||||
|
dismissed={workspaceSuggestionDismissed()}
|
||||||
|
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TimelineRowFrame>
|
</TimelineRowFrame>
|
||||||
)
|
)
|
||||||
@@ -884,7 +1216,7 @@ function MessageTimelineView(
|
|||||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||||
return (
|
return (
|
||||||
<TimelineRowFrame row={errorRow}>
|
<TimelineRowFrame row={errorRow}>
|
||||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
|
||||||
<Card variant="error" class="error-card">
|
<Card variant="error" class="error-card">
|
||||||
{errorRow().text}
|
{errorRow().text}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -966,7 +1298,7 @@ function MessageTimelineView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative w-full h-full min-w-0">
|
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||||
classList={{
|
classList={{
|
||||||
@@ -1061,6 +1393,39 @@ function MessageTimelineView(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||||
|
<Show when={props.data.newLayoutDesigns()}>
|
||||||
|
<Show
|
||||||
|
when={workspaceOperation()?.status !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||||
|
<WorkspaceLocationLoader />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={workspaceSession()}
|
||||||
|
fallback={
|
||||||
|
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||||
|
<IconV2 name="monitor" />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipV2
|
||||||
|
placement="bottom-start"
|
||||||
|
value={sessionDirectory()}
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={sessionDirectory()}
|
||||||
|
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
|
||||||
|
>
|
||||||
|
<IconV2 name="workspace-isolated" />
|
||||||
|
</span>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
<Show when={parentID()}>
|
<Show when={parentID()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1078,56 +1443,71 @@ function MessageTimelineView(
|
|||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={childTitle() || title.editing}>
|
<Show
|
||||||
<Show
|
when={!lifecycleTitle()}
|
||||||
when={title.editing}
|
fallback={
|
||||||
fallback={
|
<span
|
||||||
<h1
|
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
|
||||||
data-slot="session-title-child"
|
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
|
||||||
classList={{
|
aria-live="polite"
|
||||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
>
|
||||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
|
||||||
props.data.newLayoutDesigns(),
|
<TextShimmer text={lifecycleTitle()!.text} />
|
||||||
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
</Show>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={childTitle() || title.editing}>
|
||||||
|
<Show
|
||||||
|
when={title.editing}
|
||||||
|
fallback={
|
||||||
|
<h1
|
||||||
|
data-slot="session-title-child"
|
||||||
|
classList={{
|
||||||
|
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||||
|
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||||
|
props.data.newLayoutDesigns(),
|
||||||
|
"grow-1 min-w-0": !props.data.newLayoutDesigns(),
|
||||||
|
}}
|
||||||
|
onClick={openTitleEditor}
|
||||||
|
>
|
||||||
|
{childTitle()}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InlineInput
|
||||||
|
ref={(el) => {
|
||||||
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
onClick={openTitleEditor}
|
data-slot="session-title-child"
|
||||||
>
|
value={title.draft}
|
||||||
{childTitle()}
|
disabled={props.pending.rename()}
|
||||||
</h1>
|
classList={{
|
||||||
}
|
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||||
>
|
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
||||||
<InlineInput
|
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
||||||
ref={(el) => {
|
}}
|
||||||
titleRef = el
|
style={{
|
||||||
}}
|
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||||
data-slot="session-title-child"
|
? "none"
|
||||||
value={title.draft}
|
: "var(--shadow-xs-border-select)",
|
||||||
disabled={props.pending.rename()}
|
}}
|
||||||
classList={{
|
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
onKeyDown={(event) => {
|
||||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
|
event.stopPropagation()
|
||||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
|
if (event.key === "Enter") {
|
||||||
}}
|
event.preventDefault()
|
||||||
style={{
|
void saveTitleEditor()
|
||||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
return
|
||||||
? "none"
|
}
|
||||||
: "var(--shadow-xs-border-select)",
|
if (event.key === "Escape") {
|
||||||
}}
|
event.preventDefault()
|
||||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
closeTitleEditor()
|
||||||
onKeyDown={(event) => {
|
}
|
||||||
event.stopPropagation()
|
}}
|
||||||
if (event.key === "Enter") {
|
onBlur={closeTitleEditor}
|
||||||
event.preventDefault()
|
/>
|
||||||
void saveTitleEditor()
|
</Show>
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault()
|
|
||||||
closeTitleEditor()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={closeTitleEditor}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -1145,6 +1525,47 @@ function MessageTimelineView(
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||||
/>
|
/>
|
||||||
|
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
|
||||||
|
{(project) => (
|
||||||
|
<KobaltePopover
|
||||||
|
open={summaryOpen()}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={6}
|
||||||
|
onOpenChange={setSummary}
|
||||||
|
>
|
||||||
|
<KobaltePopover.Trigger
|
||||||
|
as={IconButtonV2}
|
||||||
|
icon={<IconV2 name="window-analytics" />}
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="large"
|
||||||
|
state={summaryOpen() ? "pressed" : undefined}
|
||||||
|
aria-label={language.t("session.summary.title")}
|
||||||
|
aria-expanded={summaryOpen()}
|
||||||
|
/>
|
||||||
|
<KobaltePopover.Portal>
|
||||||
|
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||||
|
<SessionSummaryPanel
|
||||||
|
project={project()}
|
||||||
|
directory={sessionDirectory()}
|
||||||
|
local={!workspaceSession()}
|
||||||
|
branch={sync().data.vcs?.branch}
|
||||||
|
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
|
||||||
|
diffs={sessionDiffs()}
|
||||||
|
sessionID={id}
|
||||||
|
moveEligible={props.workspaceMoveEligible}
|
||||||
|
messageID={props.userMessages.at(-1)?.id}
|
||||||
|
moveDismissed={workspaceSuggestionDismissed()}
|
||||||
|
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||||
|
onReview={() => {
|
||||||
|
setSummary(false)
|
||||||
|
command.trigger("review.toggle")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KobaltePopover.Content>
|
||||||
|
</KobaltePopover.Portal>
|
||||||
|
</KobaltePopover>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={!parentID()}>
|
<Show when={!parentID()}>
|
||||||
<Show
|
<Show
|
||||||
when={props.data.newLayoutDesigns()}
|
when={props.data.newLayoutDesigns()}
|
||||||
@@ -1210,12 +1631,21 @@ function MessageTimelineView(
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Item onSelect={() => void props.action.export(id)}>
|
<DropdownMenu.Item
|
||||||
|
disabled={workspaceOperationPending(id)}
|
||||||
|
onSelect={() => void props.action.export(id)}
|
||||||
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/* TODO: Need a V2 session archive API. */}
|
{/* TODO: Need a V2 session archive API. */}
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
|
<DropdownMenu.Item
|
||||||
|
disabled={workspaceOperationPending(id)}
|
||||||
|
onSelect={() => {
|
||||||
|
if (workspaceOperationPending(id)) return
|
||||||
|
props.action.showDelete(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
@@ -1280,12 +1710,21 @@ function MessageTimelineView(
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Item onSelect={() => void props.action.export(id)}>
|
<MenuV2.Item
|
||||||
|
disabled={workspaceOperationPending(id)}
|
||||||
|
onSelect={() => void props.action.export(id)}
|
||||||
|
>
|
||||||
{language.t("common.export")}...
|
{language.t("common.export")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
{/* TODO: Need a V2 session archive API. */}
|
{/* TODO: Need a V2 session archive API. */}
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item onSelect={() => props.action.showDelete(id)}>
|
<MenuV2.Item
|
||||||
|
disabled={workspaceOperationPending(id)}
|
||||||
|
onSelect={() => {
|
||||||
|
if (workspaceOperationPending(id)) return
|
||||||
|
props.action.showDelete(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
{language.t("common.delete")}...
|
{language.t("common.delete")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||||
import { reuseTimelineRows } from "./row-reconciliation"
|
import { insertAfterUserMessage, reuseTimelineRows } from "./row-reconciliation"
|
||||||
import { TimelineRow } from "./timeline-row"
|
import { TimelineRow } from "./timeline-row"
|
||||||
|
|
||||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||||
@@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => {
|
|||||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("inserts lifecycle extensions immediately after the user message", () => {
|
||||||
|
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
|
||||||
|
const lifecycle = new TimelineRow.WorkspaceLifecycle({
|
||||||
|
userMessageID: "user-1",
|
||||||
|
notice: {
|
||||||
|
type: "operation",
|
||||||
|
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(insertAfterUserMessage(rows, [lifecycle]).map((row) => row._tag)).toEqual([
|
||||||
|
"UserMessage",
|
||||||
|
"WorkspaceLifecycle",
|
||||||
|
"DiffSummary",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
|
|||||||
status: Accessor<SessionStatus>
|
status: Accessor<SessionStatus>
|
||||||
showReasoningSummaries: Accessor<boolean>
|
showReasoningSummaries: Accessor<boolean>
|
||||||
inlineComments: Accessor<boolean>
|
inlineComments: Accessor<boolean>
|
||||||
|
extensionRevision?: Accessor<unknown>
|
||||||
|
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[]
|
||||||
}) {
|
}) {
|
||||||
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
|
||||||
const assistantMessagesByParent = createMemo(() => {
|
const assistantMessagesByParent = createMemo(() => {
|
||||||
@@ -29,8 +31,9 @@ export function createTimelineProjection(input: {
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
const projection = createMemo(() =>
|
const projection = createMemo(() => {
|
||||||
Timeline.constructSessionMessageRows(
|
const extension = input.extensionRevision?.()
|
||||||
|
return Timeline.constructSessionMessageRows(
|
||||||
input.sessionMessages(),
|
input.sessionMessages(),
|
||||||
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
|
||||||
input.parts,
|
input.parts,
|
||||||
@@ -38,8 +41,9 @@ export function createTimelineProjection(input: {
|
|||||||
input.status().type,
|
input.status().type,
|
||||||
input.inlineComments(),
|
input.inlineComments(),
|
||||||
input.userMessages(),
|
input.userMessages(),
|
||||||
),
|
input.extensionRevision && extension === undefined ? undefined : input.afterUser,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
const activeMessageID = createMemo(() => projection().activeMessageID)
|
const activeMessageID = createMemo(() => projection().activeMessageID)
|
||||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||||
reuseTimelineRows(previous, projection().rows),
|
reuseTimelineRows(previous, projection().rows),
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row"
|
|||||||
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
|
||||||
type PriorContext = { index: number; row: ContextRow }
|
type PriorContext = { index: number; row: ContextRow }
|
||||||
|
|
||||||
|
export function insertAfterUserMessage(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
|
||||||
|
const index = rows.findIndex((row) => row._tag === "UserMessage")
|
||||||
|
rows.splice(index + 1, 0, ...extensions)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||||
if (!previous?.length) return rows
|
if (!previous?.length) return rows
|
||||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ mock.module("@opencode-ai/session-ui/message-part", () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const { Timeline, TimelineRow } = await import("./rows")
|
const { Timeline, TimelineRow } = await import("./rows")
|
||||||
|
const lifecycle = (userMessageID: string) =>
|
||||||
|
new TimelineRow.WorkspaceLifecycle({
|
||||||
|
userMessageID,
|
||||||
|
notice: {
|
||||||
|
type: "operation",
|
||||||
|
operation: { type: "create", status: "complete", directory: "/workspace", messageID: userMessageID },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
describe("current session timeline rows", () => {
|
describe("current session timeline rows", () => {
|
||||||
test("derives turns and tagged rows from chronological current messages", () => {
|
test("derives turns and tagged rows from chronological current messages", () => {
|
||||||
@@ -47,6 +55,7 @@ describe("current session timeline rows", () => {
|
|||||||
"busy",
|
"busy",
|
||||||
true,
|
true,
|
||||||
normalized.messages.filter((message) => message.role === "user"),
|
normalized.messages.filter((message) => message.role === "user"),
|
||||||
|
(message) => (message.id === "msg_3" ? [lifecycle(message.id)] : []),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.activeMessageID).toBe("msg_3")
|
expect(result.activeMessageID).toBe("msg_3")
|
||||||
@@ -55,6 +64,7 @@ describe("current session timeline rows", () => {
|
|||||||
"assistant-part:msg_1:msg_2:text:0",
|
"assistant-part:msg_1:msg_2:text:0",
|
||||||
"turn-gap:msg_3",
|
"turn-gap:msg_3",
|
||||||
"user-message:msg_3",
|
"user-message:msg_3",
|
||||||
|
"workspace-lifecycle:msg_3:operation",
|
||||||
"assistant-part:msg_3:msg_4:reasoning:0",
|
"assistant-part:msg_3:msg_4:reasoning:0",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -83,11 +93,13 @@ describe("current session timeline rows", () => {
|
|||||||
"idle",
|
"idle",
|
||||||
true,
|
true,
|
||||||
normalized.messages.filter((message) => message.role === "user"),
|
normalized.messages.filter((message) => message.role === "user"),
|
||||||
|
(message) => [lifecycle(message.id)],
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.activeMessageID).toBe("msg_shell")
|
expect(result.activeMessageID).toBe("msg_shell")
|
||||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||||
"user-message:msg_shell",
|
"user-message:msg_shell",
|
||||||
|
"workspace-lifecycle:msg_shell:operation",
|
||||||
"assistant-part:msg_shell:msg_shell:tool",
|
"assistant-part:msg_shell:msg_shell:tool",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -157,6 +169,7 @@ describe("current session timeline rows", () => {
|
|||||||
"busy",
|
"busy",
|
||||||
true,
|
true,
|
||||||
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
|
||||||
|
(message) => (message.id === optimistic.id ? [lifecycle(message.id)] : []),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(result.activeMessageID).toBe(optimistic.id)
|
expect(result.activeMessageID).toBe(optimistic.id)
|
||||||
@@ -164,6 +177,7 @@ describe("current session timeline rows", () => {
|
|||||||
"user-message:msg_z",
|
"user-message:msg_z",
|
||||||
"turn-gap:msg_a",
|
"turn-gap:msg_a",
|
||||||
"user-message:msg_a",
|
"user-message:msg_a",
|
||||||
|
"workspace-lifecycle:msg_a:operation",
|
||||||
"thinking:msg_a",
|
"thinking:msg_a",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/
|
|||||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||||
import { compareMessages } from "@/utils/session-message"
|
import { compareMessages } from "@/utils/session-message"
|
||||||
|
import { insertAfterUserMessage } from "./row-reconciliation"
|
||||||
|
|
||||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ export type TimelineRowMap = {
|
|||||||
}
|
}
|
||||||
Thinking: { userMessageID: string; reasoningHeading?: string }
|
Thinking: { userMessageID: string; reasoningHeading?: string }
|
||||||
Retry: { userMessageID: string }
|
Retry: { userMessageID: string }
|
||||||
|
WorkspaceLifecycle: {
|
||||||
|
userMessageID: string
|
||||||
|
notice: TimelineRow.WorkspaceLifecycle["notice"]
|
||||||
|
}
|
||||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||||
Error: { userMessageID: string; text: string }
|
Error: { userMessageID: string; text: string }
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,7 @@ export namespace Timeline {
|
|||||||
status: SessionStatus["type"],
|
status: SessionStatus["type"],
|
||||||
inlineComments: boolean,
|
inlineComments: boolean,
|
||||||
projectedUserMessages: UserMessage[],
|
projectedUserMessages: UserMessage[],
|
||||||
|
afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[],
|
||||||
) {
|
) {
|
||||||
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||||
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||||
@@ -83,8 +89,8 @@ export namespace Timeline {
|
|||||||
const activeMessageID = turns.at(-1)?.user.id
|
const activeMessageID = turns.at(-1)?.user.id
|
||||||
return {
|
return {
|
||||||
activeMessageID,
|
activeMessageID,
|
||||||
rows: turns.flatMap((turn, index) =>
|
rows: turns.flatMap((turn, index) => {
|
||||||
constructMessageRows(
|
const rows = constructMessageRows(
|
||||||
turn.user,
|
turn.user,
|
||||||
getMessageParts,
|
getMessageParts,
|
||||||
turn.assistants,
|
turn.assistants,
|
||||||
@@ -93,8 +99,10 @@ export namespace Timeline {
|
|||||||
status,
|
status,
|
||||||
turn.user.id === activeMessageID,
|
turn.user.id === activeMessageID,
|
||||||
inlineComments,
|
inlineComments,
|
||||||
),
|
)
|
||||||
),
|
if (!afterUser) return rows
|
||||||
|
return insertAfterUserMessage(rows, afterUser(turn.user))
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||||
import { Data, Equal } from "effect"
|
import { Data, Equal } from "effect"
|
||||||
|
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
|
||||||
|
|
||||||
export type SummaryDiff = FileDiffInfo
|
export type SummaryDiff = FileDiffInfo
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ export namespace TimelineRow {
|
|||||||
export class Retry extends Data.TaggedClass("Retry")<{
|
export class Retry extends Data.TaggedClass("Retry")<{
|
||||||
userMessageID: string
|
userMessageID: string
|
||||||
}> {}
|
}> {}
|
||||||
|
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
|
||||||
|
userMessageID: string
|
||||||
|
notice: { type: "operation"; operation: WorkspaceOperationState }
|
||||||
|
}> {}
|
||||||
|
|
||||||
export type TimelineRow =
|
export type TimelineRow =
|
||||||
| TurnGap
|
| TurnGap
|
||||||
@@ -50,6 +55,7 @@ export namespace TimelineRow {
|
|||||||
| DiffSummary
|
| DiffSummary
|
||||||
| Error
|
| Error
|
||||||
| Retry
|
| Retry
|
||||||
|
| WorkspaceLifecycle
|
||||||
|
|
||||||
export const key = (row: TimelineRow) => {
|
export const key = (row: TimelineRow) => {
|
||||||
switch (row._tag) {
|
switch (row._tag) {
|
||||||
@@ -71,6 +77,8 @@ export namespace TimelineRow {
|
|||||||
return `error:${row.userMessageID}`
|
return `error:${row.userMessageID}`
|
||||||
case "Retry":
|
case "Retry":
|
||||||
return `retry:${row.userMessageID}`
|
return `retry:${row.userMessageID}`
|
||||||
|
case "WorkspaceLifecycle":
|
||||||
|
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import type { UserMessage } from "@/types"
|
import type { UserMessage } from "@/types"
|
||||||
import { useLocal } from "@/context/local"
|
import { WorkspaceOperation } from "@/utils/workspace-operation"
|
||||||
import type { SessionController } from "./session-controller"
|
import type { SessionController } from "./session-controller"
|
||||||
|
|
||||||
type SessionCommandSource = {
|
type SessionCommandSource = {
|
||||||
@@ -54,7 +53,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
|
||||||
const owner = actions.session.ownership.capture()
|
const owner = actions.session.ownership.capture()
|
||||||
@@ -73,6 +71,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
input.owner.run(input.updateViewport)
|
input.owner.run(input.updateViewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceOperationPending = (sessionID: string) =>
|
||||||
|
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
|
||||||
const shown = settings.visibility.fileTree
|
const shown = settings.visibility.fileTree
|
||||||
|
|
||||||
const showAllFiles = () => {
|
const showAllFiles = () => {
|
||||||
@@ -291,6 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const undo = async () => {
|
const undo = async () => {
|
||||||
const sessionID = actions.session.identity.params.id
|
const sessionID = actions.session.identity.params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
const owner = actions.session.ownership.capture()
|
const owner = actions.session.ownership.capture()
|
||||||
const session = sdk().api.session
|
const session = sdk().api.session
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
@@ -321,6 +322,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const redo = async () => {
|
const redo = async () => {
|
||||||
const sessionID = actions.session.identity.params.id
|
const sessionID = actions.session.identity.params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
const owner = actions.session.ownership.capture()
|
const owner = actions.session.ownership.capture()
|
||||||
const session = sdk().api.session
|
const session = sdk().api.session
|
||||||
const messages = actions.session.history.userMessages()
|
const messages = actions.session.history.userMessages()
|
||||||
@@ -355,11 +357,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const compact = async () => {
|
const compact = async () => {
|
||||||
const sessionID = actions.session.identity.params.id
|
const sessionID = actions.session.identity.params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
|
|
||||||
await sdk().api.session.compact({ sessionID })
|
await sdk().api.session.compact({ sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fork = () => {
|
const fork = () => {
|
||||||
|
const sessionID = actions.session.identity.params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
if (workspaceOperationPending(sessionID)) return
|
||||||
void openDialog(
|
void openDialog(
|
||||||
() => import("@/components/dialog-fork"),
|
() => import("@/components/dialog-fork"),
|
||||||
(x) => dialog.show(() => <x.DialogFork />),
|
(x) => dialog.show(() => <x.DialogFork />),
|
||||||
@@ -415,7 +421,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.session.undo"),
|
title: language.t("command.session.undo"),
|
||||||
description: language.t("command.session.undo.description"),
|
description: language.t("command.session.undo.description"),
|
||||||
slash: "undo",
|
slash: "undo",
|
||||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
disabled:
|
||||||
|
!actions.session.identity.params.id ||
|
||||||
|
actions.session.history.visibleUserMessages().length === 0 ||
|
||||||
|
workspaceOperationPending(actions.session.identity.params.id),
|
||||||
onSelect: undo,
|
onSelect: undo,
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
@@ -423,7 +432,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.session.redo"),
|
title: language.t("command.session.redo"),
|
||||||
description: language.t("command.session.redo.description"),
|
description: language.t("command.session.redo.description"),
|
||||||
slash: "redo",
|
slash: "redo",
|
||||||
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
|
disabled:
|
||||||
|
!actions.session.identity.params.id ||
|
||||||
|
!actions.session.data.info()?.revert?.messageID ||
|
||||||
|
workspaceOperationPending(actions.session.identity.params.id),
|
||||||
onSelect: redo,
|
onSelect: redo,
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
@@ -431,7 +443,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.session.compact"),
|
title: language.t("command.session.compact"),
|
||||||
description: language.t("command.session.compact.description"),
|
description: language.t("command.session.compact.description"),
|
||||||
slash: "compact",
|
slash: "compact",
|
||||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
disabled:
|
||||||
|
!actions.session.identity.params.id ||
|
||||||
|
actions.session.history.visibleUserMessages().length === 0 ||
|
||||||
|
workspaceOperationPending(actions.session.identity.params.id),
|
||||||
onSelect: compact,
|
onSelect: compact,
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
@@ -439,7 +454,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.session.fork"),
|
title: language.t("command.session.fork"),
|
||||||
description: language.t("command.session.fork.description"),
|
description: language.t("command.session.fork.description"),
|
||||||
slash: "fork",
|
slash: "fork",
|
||||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
disabled:
|
||||||
|
!actions.session.identity.params.id ||
|
||||||
|
actions.session.history.visibleUserMessages().length === 0 ||
|
||||||
|
workspaceOperationPending(actions.session.identity.params.id),
|
||||||
onSelect: fork,
|
onSelect: fork,
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
|
import { ServerSyncProvider } from "@/context/server-sync"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import { TargetSessionRouteContent } from "./session"
|
||||||
|
|
||||||
|
export default function TargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string }>()
|
||||||
|
const global = useGlobal()
|
||||||
|
const connection = createMemo(() => {
|
||||||
|
const key = requireServerKey(params.serverKey)
|
||||||
|
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
|
<ServerSDKProvider server={connection}>
|
||||||
|
<ServerSyncProvider server={connection}>
|
||||||
|
<TargetSessionRouteContent />
|
||||||
|
</ServerSyncProvider>
|
||||||
|
</ServerSDKProvider>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ServerScope } from "./server-scope"
|
||||||
|
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
|
||||||
|
|
||||||
|
test("workspace moves require settled followup state", () => {
|
||||||
|
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
|
||||||
|
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
|
||||||
|
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
|
||||||
|
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
|
||||||
|
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("WorkspaceOperation", () => {
|
||||||
|
test("settles only the matching pending operation", () => {
|
||||||
|
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||||
|
WorkspaceOperation.complete(ServerScope.local, "session", "/other")
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
|
||||||
|
WorkspaceOperation.complete(ServerScope.local, "session", "/workspace")
|
||||||
|
WorkspaceOperation.fail(ServerScope.local, "session")
|
||||||
|
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export type WorkspaceOperationType = "create" | "move"
|
||||||
|
export type WorkspaceOperationState = {
|
||||||
|
type: WorkspaceOperationType
|
||||||
|
status: "pending" | "complete" | "failed"
|
||||||
|
directory: string
|
||||||
|
messageID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMoveSessionToWorkspace(input: {
|
||||||
|
queued: number
|
||||||
|
failed: boolean
|
||||||
|
paused: boolean
|
||||||
|
editing: boolean
|
||||||
|
}) {
|
||||||
|
return input.queued === 0 && !input.failed && !input.paused && !input.editing
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = new Map<string, WorkspaceOperationState>()
|
||||||
|
const [version, setVersion] = createSignal(0)
|
||||||
|
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
|
||||||
|
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
|
||||||
|
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
|
||||||
|
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
|
||||||
|
if (terminal) state.delete(terminal)
|
||||||
|
}
|
||||||
|
state.set(key(scope, sessionID), value)
|
||||||
|
setVersion((current) => current + 1)
|
||||||
|
}
|
||||||
|
export const WorkspaceOperation = {
|
||||||
|
get(scope: ServerScope, sessionID: string) {
|
||||||
|
version()
|
||||||
|
return state.get(key(scope, sessionID))
|
||||||
|
},
|
||||||
|
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
|
||||||
|
write(scope, sessionID, { type, directory, messageID, status: "pending" })
|
||||||
|
},
|
||||||
|
complete(scope: ServerScope, sessionID: string, directory?: string) {
|
||||||
|
const current = state.get(key(scope, sessionID))
|
||||||
|
if (!current) return
|
||||||
|
if (directory && pathKey(directory) !== pathKey(current.directory)) return
|
||||||
|
write(scope, sessionID, { ...current, status: "complete" })
|
||||||
|
},
|
||||||
|
fail(scope: ServerScope, sessionID: string) {
|
||||||
|
const current = state.get(key(scope, sessionID))
|
||||||
|
if (!current || current.status === "complete") return
|
||||||
|
write(scope, sessionID, { ...current, status: "failed" })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export async function workspaceRequestWithTimeout<T>(
|
||||||
|
request: (signal: AbortSignal) => Promise<T>,
|
||||||
|
message: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer.id = setTimeout(() => {
|
||||||
|
controller.abort()
|
||||||
|
reject(new Error(message))
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
return Promise.race([request(controller.signal), timeout])
|
||||||
|
.catch((error) => {
|
||||||
|
if (controller.signal.aborted) throw new Error(message)
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (timer.id !== undefined) clearTimeout(timer.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import {
|
||||||
|
filterWorkspaceInventory,
|
||||||
|
inspectWorkspaceDeletion,
|
||||||
|
isWorkspaceDirectory,
|
||||||
|
isWorkspaceSelection,
|
||||||
|
mergeWorkspaceSessionInventory,
|
||||||
|
sessionsForWorkspace,
|
||||||
|
workspaceInventory,
|
||||||
|
} from "./workspace"
|
||||||
|
|
||||||
|
describe("isWorkspaceDirectory", () => {
|
||||||
|
const project = {
|
||||||
|
worktree: "C:\\repo\\",
|
||||||
|
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
|
||||||
|
}
|
||||||
|
|
||||||
|
test("distinguishes managed workspaces from the local repository", () => {
|
||||||
|
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
|
||||||
|
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
|
||||||
|
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
|
||||||
|
expect(
|
||||||
|
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
|
||||||
|
).toBe(true)
|
||||||
|
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
|
||||||
|
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isWorkspaceSelection", () => {
|
||||||
|
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
|
||||||
|
|
||||||
|
test("accepts local, new, and managed workspace selections", () => {
|
||||||
|
expect(isWorkspaceSelection(project, "main")).toBe(true)
|
||||||
|
expect(isWorkspaceSelection(project, "create")).toBe(true)
|
||||||
|
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
|
||||||
|
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
|
||||||
|
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
|
||||||
|
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groups and filters workspace inventory by project", () => {
|
||||||
|
const inventory = workspaceInventory([
|
||||||
|
{ id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] },
|
||||||
|
{ id: "b", worktree: "/b", sandboxes: ["/b/one"] },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
|
||||||
|
["a", "/a/one"],
|
||||||
|
["a", "/a/two"],
|
||||||
|
["b", "/b/one"],
|
||||||
|
])
|
||||||
|
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
|
||||||
|
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocks unsafe workspace deletion", () => {
|
||||||
|
const session = (directory: string) =>
|
||||||
|
({ location: { directory }, time: { created: 1, updated: 1 } }) as SessionInfo
|
||||||
|
expect(
|
||||||
|
inspectWorkspaceDeletion({
|
||||||
|
workspace: "/workspace",
|
||||||
|
activeDirectory: "/workspace/app",
|
||||||
|
sessions: [],
|
||||||
|
status: "dirty",
|
||||||
|
}),
|
||||||
|
).toBe("active")
|
||||||
|
expect(
|
||||||
|
inspectWorkspaceDeletion({
|
||||||
|
workspace: "/workspace",
|
||||||
|
sessions: [session("/workspace/packages/app")],
|
||||||
|
status: "dirty",
|
||||||
|
}),
|
||||||
|
).toBe("linked")
|
||||||
|
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
|
||||||
|
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
|
||||||
|
expect(
|
||||||
|
inspectWorkspaceDeletion({
|
||||||
|
workspace: "/workspace",
|
||||||
|
sessions: [
|
||||||
|
{ location: { directory: "/workspace" }, time: { created: 1, updated: 1, archived: 2 } } as SessionInfo,
|
||||||
|
],
|
||||||
|
status: "clean",
|
||||||
|
}),
|
||||||
|
).toBe("safe")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groups nested non-archived workspace sessions by latest activity", () => {
|
||||||
|
const session = (id: string, directory: string, updated: number, archived?: number) =>
|
||||||
|
({ id, location: { directory }, time: { created: 1, updated, archived } }) as SessionInfo
|
||||||
|
const sessions = sessionsForWorkspace(
|
||||||
|
[
|
||||||
|
session("old", "/workspace", 2),
|
||||||
|
session("nested", "/workspace/packages/app", 3),
|
||||||
|
session("archived", "/workspace", 4, 5),
|
||||||
|
session("other", "/other", 6),
|
||||||
|
],
|
||||||
|
"/workspace",
|
||||||
|
)
|
||||||
|
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges workspace placement by freshness with authoritative server ties", () => {
|
||||||
|
const session = (directory: string, updated: number) =>
|
||||||
|
({ id: "session", location: { directory }, time: { created: 1, updated } }) as SessionInfo
|
||||||
|
|
||||||
|
expect(
|
||||||
|
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.location.directory,
|
||||||
|
).toBe("/destination")
|
||||||
|
expect(
|
||||||
|
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.location.directory,
|
||||||
|
).toBe("/destination")
|
||||||
|
expect(
|
||||||
|
mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.location.directory,
|
||||||
|
).toBe("/source")
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
|
||||||
|
|
||||||
|
export function workspaceDirectories(project: WorkspaceProject) {
|
||||||
|
return (project.sandboxes ?? []).filter(
|
||||||
|
(directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceInventory<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
|
||||||
|
return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project })))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
|
||||||
|
workspaces: readonly T[],
|
||||||
|
project: string,
|
||||||
|
) {
|
||||||
|
if (project === "all") return [...workspaces]
|
||||||
|
return workspaces.filter((workspace) => workspace.project.id === project)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionsForWorkspace(sessions: readonly SessionInfo[], workspace: string) {
|
||||||
|
return sessions
|
||||||
|
.filter((session) => session.time.archived === undefined)
|
||||||
|
.filter((session) => containsDirectory(workspace, session.location.directory))
|
||||||
|
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeWorkspaceSessionInventory(server: readonly SessionInfo[], cached: readonly SessionInfo[]) {
|
||||||
|
const sessions = new Map(server.map((session) => [session.id, session]))
|
||||||
|
cached.forEach((session) => {
|
||||||
|
const current = sessions.get(session.id)
|
||||||
|
if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session)
|
||||||
|
})
|
||||||
|
return [...sessions.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeWorkspacesSequentially<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
|
||||||
|
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty"
|
||||||
|
|
||||||
|
export function inspectWorkspaceDeletion(input: {
|
||||||
|
workspace: string
|
||||||
|
activeDirectory?: string
|
||||||
|
sessions: readonly SessionInfo[]
|
||||||
|
status: "clean" | "dirty"
|
||||||
|
}): WorkspaceDeleteInspection {
|
||||||
|
if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active"
|
||||||
|
if (
|
||||||
|
input.sessions.some(
|
||||||
|
(session) =>
|
||||||
|
session.time.archived === undefined && containsDirectory(input.workspace, session.location.directory),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "linked"
|
||||||
|
if (input.status === "dirty") return "dirty"
|
||||||
|
return "safe"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||||
|
if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree)))
|
||||||
|
return false
|
||||||
|
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) {
|
||||||
|
if (!project) return false
|
||||||
|
return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsDirectory(parent: string, child: string) {
|
||||||
|
const normalize = (value: string) => {
|
||||||
|
const key = pathKey(value)
|
||||||
|
return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
|
||||||
|
}
|
||||||
|
const root = normalize(parent)
|
||||||
|
const target = normalize(child)
|
||||||
|
return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
|
||||||
|
if (selection === "main" || selection === "create") return true
|
||||||
|
if (!project) return false
|
||||||
|
if (containsDirectory(project.worktree, selection) && containsDirectory(selection, project.worktree)) return true
|
||||||
|
return isWorkspaceDirectory(project, selection)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceDefaultSelection(
|
||||||
|
setting: WorkspaceDefaultDestination,
|
||||||
|
lastUsed: WorkspaceLastUsed | undefined,
|
||||||
|
) {
|
||||||
|
if (setting === "local") return "main"
|
||||||
|
if (setting === "new") return "create"
|
||||||
|
return lastUsed === "workspace" ? "create" : "main"
|
||||||
|
}
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
|
"web-tree-sitter": "0.25.10",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
|
|||||||
readonly type: "session.agent.selected"
|
readonly type: "session.agent.selected"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly agent: Agent.ID
|
|
||||||
readonly previous?: Agent.ID | undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
|
|||||||
readonly type: "session.model.selected"
|
readonly type: "session.model.selected"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly model: Model.Ref
|
|
||||||
readonly previous?: Model.Ref | undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
|
|||||||
type: "session.agent.selected"
|
type: "session.agent.selected"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; agent: string; previous?: string }
|
data: { sessionID: string; agent: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionModelSelected = {
|
export type SessionModelSelected = {
|
||||||
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
|
|||||||
type: "session.model.selected"
|
type: "session.model.selected"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
data: { sessionID: string; model: ModelRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMoved = {
|
export type SessionMoved = {
|
||||||
|
|||||||
@@ -716,11 +716,10 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
}),
|
}),
|
||||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
previous: session.agent,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
||||||
@@ -734,7 +733,6 @@ const layer = Layer.effect(
|
|||||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
previous: session.model,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
rename: Effect.fn("Session.rename")(function* (input) {
|
rename: Effect.fn("Session.rename")(function* (input) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
"session.usage.recorded": () => Effect.void,
|
"session.usage.recorded": () => Effect.void,
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const previous = event.data.previous ?? (yield* adapter.getAgent())
|
const previous = yield* adapter.getAgent()
|
||||||
yield* adapter.appendMessage(
|
yield* adapter.appendMessage(
|
||||||
SessionMessage.AgentSelected.make({
|
SessionMessage.AgentSelected.make({
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
},
|
},
|
||||||
"session.model.selected": (event) => {
|
"session.model.selected": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const previous = event.data.previous ?? (yield* adapter.getModel())
|
const previous = yield* adapter.getModel()
|
||||||
yield* adapter.appendMessage(
|
yield* adapter.appendMessage(
|
||||||
SessionMessage.ModelSelected.make({
|
SessionMessage.ModelSelected.make({
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ describe("Session.create", () => {
|
|||||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
|
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||||
])
|
])
|
||||||
@@ -678,12 +678,7 @@ describe("Session.create", () => {
|
|||||||
it.effect("switches the selected model through the durable Session event", () =>
|
it.effect("switches the selected model through the durable Session event", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const previous = Model.Ref.make({
|
const created = yield* session.create({ location })
|
||||||
id: Model.ID.make("haiku"),
|
|
||||||
providerID: Provider.ID.anthropic,
|
|
||||||
variant: Model.VariantID.make("default"),
|
|
||||||
})
|
|
||||||
const created = yield* session.create({ location, model: previous })
|
|
||||||
const model = Model.Ref.make({
|
const model = Model.Ref.make({
|
||||||
id: Model.ID.make("sonnet"),
|
id: Model.ID.make("sonnet"),
|
||||||
providerID: Provider.ID.anthropic,
|
providerID: Provider.ID.anthropic,
|
||||||
@@ -697,10 +692,7 @@ describe("Session.create", () => {
|
|||||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||||
)
|
)
|
||||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
|
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
|
||||||
{ type: "model-switched", model, previous },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type Locale,
|
type Locale,
|
||||||
type Platform,
|
type Platform,
|
||||||
PlatformProvider,
|
PlatformProvider,
|
||||||
|
preloadSessionRoute,
|
||||||
createDraftStore,
|
createDraftStore,
|
||||||
ServerConnection,
|
ServerConnection,
|
||||||
useCommand,
|
useCommand,
|
||||||
@@ -441,7 +442,9 @@ render(() => {
|
|||||||
const api = window.api as typeof window.api & {
|
const api = window.api as typeof window.api & {
|
||||||
getWindowID?: () => Promise<string>
|
getWindowID?: () => Promise<string>
|
||||||
}
|
}
|
||||||
return { id: await api.getWindowID?.() }
|
const id = await api.getWindowID?.()
|
||||||
|
if (/^\/server\/[^/]+\/session\/[^/]+/.test(getLastActiveUrl(id ?? "browser"))) await preloadSessionRoute()
|
||||||
|
return { id }
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta"
|
|||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
import {
|
||||||
|
pluralCategory,
|
||||||
|
pluralKey,
|
||||||
|
type UiI18nParams,
|
||||||
|
type UiI18nPluralKey,
|
||||||
|
type UiTranslate,
|
||||||
|
type UiPluralCategory,
|
||||||
|
} from "@opencode-ai/ui/context/i18n"
|
||||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||||
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
|
||||||
@@ -58,20 +65,30 @@ function detectLocale() {
|
|||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const locale = createMemo(() => detectLocale())
|
const locale = createMemo(() => detectLocale())
|
||||||
const zh = uiZh as Partial<Record<string, string>>
|
const zh = uiZh as Partial<Record<string, string>>
|
||||||
const t = (key: keyof typeof uiEn, params?: UiI18nParams) => {
|
const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => {
|
||||||
const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
|
const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
|
||||||
const text = value ?? String(key)
|
const text = value ?? String(key)
|
||||||
return resolveTemplate(text, params)
|
return resolveTemplate(text, params)
|
||||||
}
|
}
|
||||||
|
const t = translate as UiTranslate
|
||||||
|
const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => {
|
||||||
|
const candidate = pluralKey(key, category)
|
||||||
|
const fallback = pluralKey(key, "other")
|
||||||
|
const value =
|
||||||
|
locale() === "zh"
|
||||||
|
? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback])
|
||||||
|
: (uiEn[candidate] ?? uiEn[fallback])
|
||||||
|
return resolveTemplate(value ?? fallback, params)
|
||||||
|
}
|
||||||
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
|
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
|
||||||
t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count })
|
pluralForm(key, pluralCategory(locale(), count), { ...params, count })
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof document !== "object") return
|
if (typeof document !== "object") return
|
||||||
document.documentElement.lang = locale()
|
document.documentElement.lang = locale()
|
||||||
})
|
})
|
||||||
|
|
||||||
return <I18nProvider value={{ locale, t, plural }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale, t, plural, pluralForm }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|||||||
@@ -14373,9 +14373,6 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "agent"],
|
"required": ["sessionID", "agent"],
|
||||||
@@ -14444,9 +14441,6 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
"$ref": "#/components/schemas/Model.Ref"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "model"],
|
"required": ["sessionID", "model"],
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
agent: Agent.ID,
|
agent: Agent.ID,
|
||||||
previous: Agent.ID.pipe(optional),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type AgentSelected = typeof AgentSelected.Type
|
export type AgentSelected = typeof AgentSelected.Type
|
||||||
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
model: Model.Ref,
|
model: Model.Ref,
|
||||||
previous: Model.Ref.pipe(optional),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type ModelSelected = typeof ModelSelected.Type
|
export type ModelSelected = typeof ModelSelected.Type
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
|
||||||
|
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||||
|
- Render count-sensitive copy through `i18n.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `i18n.t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms.
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ describe("markdown stream", () => {
|
|||||||
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
|
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("splits completed markdown into bounded top-level blocks", () => {
|
||||||
|
const result = project(undefined, "# Plan\n\nFirst paragraph.\n\nSecond paragraph.", false)
|
||||||
|
|
||||||
|
expect(result.blocks.map((block) => block.raw)).toEqual(["# Plan", "First paragraph.", "Second paragraph."])
|
||||||
|
})
|
||||||
|
|
||||||
test("catches up paced text before finalizing", () => {
|
test("catches up paced text before finalizing", () => {
|
||||||
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
|
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
|
||||||
const final = project(live, `${live.text} item`, false)
|
const final = project(live, `${live.text} item`, false)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function heal(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stream(text: string, live: boolean): Block[] {
|
export function stream(text: string, live: boolean): Block[] {
|
||||||
if (!live) return completedProjection(text).blocks
|
if (!live) return completedBlocks(text)
|
||||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||||
const tokens = marked.lexer(text)
|
const tokens = marked.lexer(text)
|
||||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||||
@@ -85,6 +85,17 @@ export function stream(text: string, live: boolean): Block[] {
|
|||||||
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completedBlocks(text: string) {
|
||||||
|
if (refs(text)) return completedProjection(text).blocks
|
||||||
|
const tokens = marked.lexer(text)
|
||||||
|
return tokens.flatMap((token): Block[] => {
|
||||||
|
if (token.type === "space") return []
|
||||||
|
if (token.type !== "code") return [{ raw: token.raw, src: token.raw, mode: "full" }]
|
||||||
|
const code = token as Tokens.Code
|
||||||
|
return [{ raw: code.raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
||||||
if (!live) {
|
if (!live) {
|
||||||
const current =
|
const current =
|
||||||
@@ -93,7 +104,7 @@ export function project(previous: Projection | undefined, text: string, live: bo
|
|||||||
: previous && text.startsWith(previous.text)
|
: previous && text.startsWith(previous.text)
|
||||||
? project(previous, text, true)
|
? project(previous, text, true)
|
||||||
: undefined
|
: undefined
|
||||||
if (!current) return completedProjection(text)
|
if (!current) return { text, blocks: completedBlocks(text) }
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
blocks: current.blocks.map((block) => {
|
blocks: current.blocks.map((block) => {
|
||||||
|
|||||||
@@ -491,6 +491,8 @@ export function Markdown(
|
|||||||
)
|
)
|
||||||
|
|
||||||
let copyCleanup: (() => void) | undefined
|
let copyCleanup: (() => void) | undefined
|
||||||
|
let renderFrame: number | undefined
|
||||||
|
let renderGeneration = 0
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const container = root()
|
const container = root()
|
||||||
@@ -499,6 +501,9 @@ export function Markdown(
|
|||||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
||||||
if (!container) return
|
if (!container) return
|
||||||
if (isServer) return
|
if (isServer) return
|
||||||
|
const generation = ++renderGeneration
|
||||||
|
if (renderFrame !== undefined) cancelAnimationFrame(renderFrame)
|
||||||
|
renderFrame = undefined
|
||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
disposeCopyButtons(container)
|
disposeCopyButtons(container)
|
||||||
container.innerHTML = ""
|
container.innerHTML = ""
|
||||||
@@ -515,24 +520,40 @@ export function Markdown(
|
|||||||
})
|
})
|
||||||
activeCodeKeys.clear()
|
activeCodeKeys.clear()
|
||||||
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
||||||
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
let index = 0
|
||||||
while (container.children.length > content.length) {
|
const update = () => {
|
||||||
const child = container.lastElementChild
|
renderFrame = undefined
|
||||||
if (!child) break
|
if (generation !== renderGeneration) return
|
||||||
disposeCopyButtons(child)
|
const deadline = performance.now() + 8
|
||||||
child.remove()
|
while (index < content.length && performance.now() < deadline) {
|
||||||
|
updateBlock(container, index, content[index]!, labels)
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
if (index < content.length) {
|
||||||
|
renderFrame = requestAnimationFrame(update)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
while (container.children.length > content.length) {
|
||||||
|
const child = container.lastElementChild
|
||||||
|
if (!child) break
|
||||||
|
disposeCopyButtons(child)
|
||||||
|
child.remove()
|
||||||
|
}
|
||||||
|
container
|
||||||
|
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||||
|
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||||
|
if (!copyCleanup)
|
||||||
|
copyCleanup = setupCodeCopy(container, () => ({
|
||||||
|
copy: i18n.t("ui.message.copy"),
|
||||||
|
copied: i18n.t("ui.message.copied"),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
container
|
update()
|
||||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
|
||||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
|
||||||
if (!copyCleanup)
|
|
||||||
copyCleanup = setupCodeCopy(container, () => ({
|
|
||||||
copy: i18n.t("ui.message.copy"),
|
|
||||||
copied: i18n.t("ui.message.copied"),
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
renderGeneration += 1
|
||||||
|
if (renderFrame !== undefined) cancelAnimationFrame(renderFrame)
|
||||||
if (copyCleanup) copyCleanup()
|
if (copyCleanup) copyCleanup()
|
||||||
disposeMarkdownProjection(owner)
|
disposeMarkdownProjection(owner)
|
||||||
activeCodeKeys.forEach(disposeCode)
|
activeCodeKeys.forEach(disposeCode)
|
||||||
|
|||||||
@@ -1382,6 +1382,11 @@ body[data-new-layout] [data-component="user-message"] {
|
|||||||
background: var(--v2-background-bg-layer-02);
|
background: var(--v2-background-bg-layer-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-new-layout] [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] {
|
||||||
|
background: var(--v2-background-bg-accent);
|
||||||
|
color: var(--v2-text-text-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
body:not([data-new-layout]) {
|
body:not([data-new-layout]) {
|
||||||
[data-component="user-message"] {
|
[data-component="user-message"] {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ export function getToolInfo(
|
|||||||
icon: "code-lines",
|
icon: "code-lines",
|
||||||
title: i18n.t("ui.tool.patch"),
|
title: i18n.t("ui.tool.patch"),
|
||||||
subtitle: input.files?.length
|
subtitle: input.files?.length
|
||||||
? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
|
? `${input.files.length} ${i18n.plural("ui.common.file", input.files.length)}`
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
case "todowrite":
|
case "todowrite":
|
||||||
@@ -2349,7 +2349,7 @@ ToolRegistry.register({
|
|||||||
const subtitle = createMemo(() => {
|
const subtitle = createMemo(() => {
|
||||||
const count = files().length
|
const count = files().length
|
||||||
if (count === 0) return ""
|
if (count === 0) return ""
|
||||||
return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
|
return `${count} ${i18n.plural("ui.common.file", count)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2590,7 +2590,7 @@ ToolRegistry.register({
|
|||||||
const count = questions().length
|
const count = questions().length
|
||||||
if (count === 0) return ""
|
if (count === 0) return ""
|
||||||
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
|
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
|
||||||
return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
|
return `${count} ${i18n.plural("ui.common.question", count)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ function common(one: string, other: string) {
|
|||||||
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
|
export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
|
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
|
||||||
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
|
const form = (category: ReturnType<typeof pluralCategory>) =>
|
||||||
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
|
i18n.pluralForm?.(props.plural, category) ?? (i18n.t as (key: string) => string)(pluralKey(props.plural, category))
|
||||||
const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category()))))
|
const one = createMemo(() => split(form("one")))
|
||||||
|
const other = createMemo(() => split(form("other")))
|
||||||
|
const active = createMemo(() => split(form(category())))
|
||||||
const suffix = createMemo(() => common(one().after, other().after))
|
const suffix = createMemo(() => common(one().after, other().after))
|
||||||
const splitSuffix = createMemo(
|
const splitSuffix = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function createPool(lineDiffType: "none" | "word-alt") {
|
|||||||
{
|
{
|
||||||
theme: "OpenCode",
|
theme: "OpenCode",
|
||||||
lineDiffType,
|
lineDiffType,
|
||||||
preferredHighlighter: "shiki-wasm",
|
preferredHighlighter: "shiki-js",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type PromptInputV2Mode = "normal" | "shell"
|
|||||||
|
|
||||||
export type PromptInputV2Props = {
|
export type PromptInputV2Props = {
|
||||||
controller: PromptInputV2Interaction
|
controller: PromptInputV2Interaction
|
||||||
|
accentSubmit?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
borderUnderlay?: boolean
|
borderUnderlay?: boolean
|
||||||
@@ -52,9 +53,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
|||||||
const view = props.controller.view
|
const view = props.controller.view
|
||||||
let editor: HTMLDivElement | undefined
|
let editor: HTMLDivElement | undefined
|
||||||
let localInput = false
|
let localInput = false
|
||||||
const updateCursor = () => {
|
const updateCursor = (event: KeyboardEvent | PointerEvent) => {
|
||||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||||
props.controller.onCursor(promptInputV2Cursor(editor))
|
if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key))
|
||||||
|
return
|
||||||
|
props.controller.onCursor(parsePromptInputV2Editor(editor).cursor)
|
||||||
}
|
}
|
||||||
const mode = createMemo(() => state.mode)
|
const mode = createMemo(() => state.mode)
|
||||||
const buttons = createMemo(() => ({
|
const buttons = createMemo(() => ({
|
||||||
@@ -163,8 +166,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
|||||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
const { prompt, cursor } = parsePromptInputV2Editor(event.currentTarget)
|
||||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
|
||||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||||
localInput = true
|
localInput = true
|
||||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||||
@@ -258,6 +260,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
|||||||
mode={state.mode}
|
mode={state.mode}
|
||||||
stopping={view.submit.stopping()}
|
stopping={view.submit.stopping()}
|
||||||
disabled={!props.controller.canSubmit()}
|
disabled={!props.controller.canSubmit()}
|
||||||
|
accent={props.accentSubmit}
|
||||||
sendLabel={i18n.t("ui.promptInput.send")}
|
sendLabel={i18n.t("ui.promptInput.send")}
|
||||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||||
onSubmit={props.controller.submit}
|
onSubmit={props.controller.submit}
|
||||||
@@ -300,8 +303,13 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2
|
|||||||
|
|
||||||
function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||||
const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
|
const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const anchorNode = selection && editor.contains(selection.anchorNode) ? selection.anchorNode : undefined
|
||||||
|
const anchorOffset = anchorNode ? selection!.anchorOffset : 0
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
let position = 0
|
let position = 0
|
||||||
|
let cursor: number | undefined
|
||||||
|
const offset = () => position + buffer.length
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
if (!buffer) return
|
if (!buffer) return
|
||||||
@@ -336,43 +344,42 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
|||||||
}
|
}
|
||||||
const visit = (node: Node) => {
|
const visit = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (node === anchorNode) cursor = offset() + Math.min(anchorOffset, node.textContent?.length ?? 0)
|
||||||
buffer += node.textContent ?? ""
|
buffer += node.textContent ?? ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!(node instanceof HTMLElement)) return
|
if (!(node instanceof HTMLElement)) return
|
||||||
if (node.dataset.mention) {
|
if (node.dataset.mention) {
|
||||||
|
if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? (node.textContent?.length ?? 0) : 0)
|
||||||
mention(node)
|
mention(node)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (node.tagName === "BR") {
|
if (node.tagName === "BR") {
|
||||||
|
if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? 1 : 0)
|
||||||
buffer += "\n"
|
buffer += "\n"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Array.from(node.childNodes).forEach(visit)
|
Array.from(node.childNodes).forEach((child, index) => {
|
||||||
|
if (node === anchorNode && anchorOffset === index) cursor = offset()
|
||||||
|
visit(child)
|
||||||
|
})
|
||||||
|
if (node === anchorNode && anchorOffset >= node.childNodes.length) cursor = offset()
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.from(editor.childNodes).forEach((node, index, nodes) => {
|
Array.from(editor.childNodes).forEach((node, index, nodes) => {
|
||||||
|
if (editor === anchorNode && anchorOffset === index) cursor = offset()
|
||||||
visit(node)
|
visit(node)
|
||||||
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
|
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
|
||||||
})
|
})
|
||||||
|
if (editor === anchorNode && anchorOffset >= editor.childNodes.length) cursor = offset()
|
||||||
flush()
|
flush()
|
||||||
if (
|
const result =
|
||||||
parts.every((part) => part.type === "text") &&
|
parts.length === 0 ||
|
||||||
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
|
(parts.every((part) => part.type === "text") &&
|
||||||
) {
|
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === ""))
|
||||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
? [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||||
}
|
: parts
|
||||||
if (parts.length > 0) return parts
|
return { prompt: result, cursor: cursor ?? offset() }
|
||||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptInputV2Cursor(editor: HTMLDivElement) {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
|
||||||
const range = selection.getRangeAt(0).cloneRange()
|
|
||||||
range.selectNodeContents(editor)
|
|
||||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
|
||||||
return range.toString().length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptInputV2Attachments(props: {
|
export function PromptInputV2Attachments(props: {
|
||||||
@@ -673,6 +680,7 @@ export function PromptInputV2SubmitButton(props: {
|
|||||||
mode: PromptInputV2Mode
|
mode: PromptInputV2Mode
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
accent?: boolean
|
||||||
sendLabel: string
|
sendLabel: string
|
||||||
stopLabel: string
|
stopLabel: string
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
@@ -691,10 +699,16 @@ export function PromptInputV2SubmitButton(props: {
|
|||||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||||
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||||
|
classList={{
|
||||||
|
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
|
||||||
|
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
"background-image":
|
"background-image":
|
||||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
props.accent && !props.stopping && !props.disabled
|
||||||
|
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
|
||||||
|
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||||
}}
|
}}
|
||||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
## Localization
|
## Localization
|
||||||
|
|
||||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors.
|
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors.
|
||||||
|
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
|
||||||
|
- Render count-sensitive copy through `plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms.
|
||||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const icons = {
|
|||||||
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
|
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
|
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
|
||||||
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
|
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
"workspace-isolated": `<g transform="translate(2 2)"><path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/></g>`,
|
||||||
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
|
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
|
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
@@ -103,6 +104,11 @@ const icons = {
|
|||||||
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
|
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
|
||||||
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
|
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
|
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
|
||||||
|
appearance: `<path d="M2.707 14.707L14.707 2.707M2.707 9.06L9.06 2.707M2.707 3.413L3.413 2.707M8.354 14.707L14.707 8.354M14.000 14.706L14.706 14.000" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
notifications: `<path d="M15.389 7.278V9.611C15.389 10.593 15.389 11.389 15.389 11.389H11.833M6.056 11.389H2.5C2.5 11.389 2.5 10.593 2.5 9.611V4.278C2.5 3.296 2.5 2.5 2.5 2.5H9.945M6.056 11.389V13.611H8.944H11.833V11.389M6.056 11.389H11.833" stroke="currentColor"/><circle cx="14.5" cy="4.5" r="2" stroke="currentColor" fill="currentColor"/>`,
|
||||||
|
extensions: `<path d="M9.166 6.805V9.305M11.834 6.805V9.305M6.5 6.805V9.305M2.5 2.5V13.61H15.833V2.5H2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||||
|
cube: `<path d="M10 2.5L16.5 6.25V13.75L10 17.5L3.5 13.75V6.25L10 2.5Z" stroke="currentColor"/><path d="M10 10L16.5 6.25M10 10V17.5M10 10L3.5 6.25" stroke="currentColor"/>`,
|
||||||
|
"post-skill": `<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" stroke="currentColor"/><path d="M5.5 7.5H10.5M5.5 10.5H14.5" stroke="currentColor"/>`,
|
||||||
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
|
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||||
import { I18nProvider } from "@kobalte/core/i18n"
|
import { I18nProvider } from "@kobalte/core/i18n"
|
||||||
import { dict as en } from "../i18n/en"
|
import { dict as en } from "../i18n/en"
|
||||||
|
import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en"
|
||||||
|
|
||||||
export type UiI18nKey = keyof typeof en
|
export type UiI18nKey = Key
|
||||||
|
export type UiI18nPluralKey = PluralKey
|
||||||
export const UI_PLURAL_KEYS = [
|
export type UiPluralCategory = PluralCategory
|
||||||
"ui.sessionTurn.diffs.changed",
|
export type UiI18nPluralLookupKey = PluralLookupKey
|
||||||
"ui.messagePart.context.read",
|
export type UiI18nLocaleKey = LocaleKey
|
||||||
"ui.messagePart.context.search",
|
type UiTranslationKey<Key extends string> = Key extends UiI18nPluralLookupKey ? never : Key
|
||||||
"ui.messagePart.context.list",
|
|
||||||
] as const
|
|
||||||
export type UiI18nPluralKey = (typeof UI_PLURAL_KEYS)[number]
|
|
||||||
export type UiPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
|
|
||||||
export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}`
|
|
||||||
|
|
||||||
export type UiI18nParams = Record<string, string | number | boolean>
|
export type UiI18nParams = Record<string, string | number | boolean>
|
||||||
|
export type UiTranslate = <Key extends string>(key: UiTranslationKey<Key>, params?: UiI18nParams) => string
|
||||||
|
|
||||||
export type UiI18n = {
|
export type UiI18n = {
|
||||||
locale: Accessor<string>
|
locale: Accessor<string>
|
||||||
layoutLocale?: Accessor<string>
|
layoutLocale?: Accessor<string>
|
||||||
t: (key: UiI18nKey, params?: UiI18nParams) => string
|
t: UiTranslate
|
||||||
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
|
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
|
||||||
|
pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = new Map<string, Intl.PluralRules>()
|
const rules = new Map<string, Intl.PluralRules>()
|
||||||
@@ -50,11 +48,16 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
|
|||||||
const fallback: UiI18n = {
|
const fallback: UiI18n = {
|
||||||
locale: () => "en",
|
locale: () => "en",
|
||||||
t: (key, params) => {
|
t: (key, params) => {
|
||||||
const value = en[key] ?? String(key)
|
const value = en[key as UiI18nKey] ?? String(key)
|
||||||
return resolveTemplate(value, params)
|
return resolveTemplate(value, params)
|
||||||
},
|
},
|
||||||
plural: (key, count, params) =>
|
plural: (key, count, params) =>
|
||||||
fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }),
|
fallback.pluralForm!(key, pluralCategory(fallback.locale(), count), { ...params, count }),
|
||||||
|
pluralForm: (key, category, params) => {
|
||||||
|
const values = en as Partial<Record<UiI18nLocaleKey, string>>
|
||||||
|
const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other`
|
||||||
|
return resolveTemplate(value, params)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = createContext<UiI18n>(fallback)
|
const Context = createContext<UiI18n>(fallback)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const dict: Record<string, string> = {
|
const source = {
|
||||||
"ui.sessionReview.title": "Session changes",
|
"ui.sessionReview.title": "Session changes",
|
||||||
"ui.sessionReview.title.git": "Git changes",
|
"ui.sessionReview.title.git": "Git changes",
|
||||||
"ui.sessionReview.title.branch": "Branch changes",
|
"ui.sessionReview.title.branch": "Branch changes",
|
||||||
@@ -215,4 +215,13 @@ export const dict: Record<string, string> = {
|
|||||||
"ui.question.multiHint": "Select all answers that apply",
|
"ui.question.multiHint": "Select all answers that apply",
|
||||||
"ui.question.singleHint": "Select one answer",
|
"ui.question.singleHint": "Select one answer",
|
||||||
"ui.question.custom.placeholder": "Type your answer...",
|
"ui.question.custom.placeholder": "Type your answer...",
|
||||||
}
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
|
export type Key = keyof typeof source
|
||||||
|
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
|
||||||
|
export type PluralKey = {
|
||||||
|
[Entry in Key]: Entry extends `${infer Base}.other` ? (`${Base}.one` extends Key ? Base : never) : never
|
||||||
|
}[Key]
|
||||||
|
export type PluralLookupKey = `${PluralKey}.${PluralCategory}`
|
||||||
|
export type LocaleKey = Key | PluralLookupKey
|
||||||
|
export const dict: typeof source & Record<string, string> = source
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user