mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5ed2866a | |||
| 4de91c9755 | |||
| 89e3329bd5 | |||
| 17a5b32270 | |||
| 6721ff5328 | |||
| 026e0c634f | |||
| c581b59b7f | |||
| 5cc7811ec2 | |||
| 5d32845b02 | |||
| e9f5842f29 | |||
| 7b9734593a | |||
| 1dbfc7cfab | |||
| 3441b95afc | |||
| 306204bad8 | |||
| fca453a2a5 | |||
| 2a51cf5f88 | |||
| b7bd2b0ae0 | |||
| 45b2759a1e | |||
| 65b6abc4bb | |||
| 8c8492ef97 | |||
| 115e689723 | |||
| 7c30a4230e | |||
| bd4557291b | |||
| 9ecf46bfea | |||
| 47c8d85904 | |||
| c401076b6f | |||
| 9ded81b6c2 | |||
| 87142c1b4c | |||
| d8320209c9 | |||
| 31c72b9a17 | |||
| 1aef4de853 | |||
| 9c94634515 | |||
| c2b754bb03 | |||
| 2372edd5eb | |||
| a5f7f8d3b5 | |||
| 4df276b9e8 |
@@ -75,7 +75,7 @@ jobs:
|
|||||||
build-cli:
|
build-cli:
|
||||||
needs: version
|
needs: version
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
with:
|
with:
|
||||||
@@ -91,7 +91,7 @@ jobs:
|
|||||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
- name: Build legacy CLI
|
- name: Build legacy CLI
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: |
|
path: |
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
packages/opencode/dist/opencode-linux*
|
packages/opencode/dist/opencode-linux*
|
||||||
|
|
||||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist/opencode-windows*
|
path: packages/opencode/dist/opencode-windows*
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
|
|
||||||
build-node-cli:
|
build-node-cli:
|
||||||
needs: version
|
needs: version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -184,7 +184,7 @@ jobs:
|
|||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
@@ -377,7 +377,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
@@ -393,6 +393,7 @@ jobs:
|
|||||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
@@ -496,29 +497,31 @@ jobs:
|
|||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-windows
|
name: opencode-cli-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: github.ref_name != 'v2'
|
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-cli-signed-windows
|
name: opencode-cli-signed-windows
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
name: opencode-preview-cli
|
name: opencode-preview-cli
|
||||||
path: packages/cli/dist
|
path: packages/cli/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
if: github.ref_name != 'beta'
|
||||||
with:
|
with:
|
||||||
pattern: opencode-node-cli-*
|
pattern: opencode-node-cli-*
|
||||||
path: packages/cli/dist/node
|
path: packages/cli/dist/node
|
||||||
|
|||||||
@@ -439,6 +439,7 @@
|
|||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@sentry/vite-plugin": "catalog:",
|
"@sentry/vite-plugin": "catalog:",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -351,8 +356,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
const next = { title: session.title, directory: session.location.directory }
|
const next = { title: session.title, directory: session.location.directory }
|
||||||
const current = info[key]
|
const current = info[key]
|
||||||
console.log({ tab, session, current })
|
if (current && current.title === next.title && current.directory === next.directory) return
|
||||||
if (current?.title === next.title && current.directory === next.directory) return
|
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||||
setInfo(key, next)
|
setInfo(key, next)
|
||||||
},
|
},
|
||||||
select: navigateTab,
|
select: navigateTab,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
|
|||||||
time: { created: number }
|
time: { created: number }
|
||||||
type: "agent-switched"
|
type: "agent-switched"
|
||||||
agent: string
|
agent: string
|
||||||
|
previous?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptBase64 = string
|
export type PromptBase64 = string
|
||||||
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
readonly type: "agent-switched"
|
readonly type: "agent-switched"
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
|
readonly previous?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
readonly type: "agent-switched"
|
readonly type: "agent-switched"
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
|
readonly previous?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
|
|||||||
readonly time: { readonly created: number }
|
readonly time: { readonly created: number }
|
||||||
readonly type: "agent-switched"
|
readonly type: "agent-switched"
|
||||||
readonly agent: string
|
readonly agent: string
|
||||||
|
readonly previous?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|||||||
@@ -194,11 +194,10 @@ async function formatTypescript(input: string) {
|
|||||||
|
|
||||||
function renderRegistry(names: string[]) {
|
function renderRegistry(names: string[]) {
|
||||||
return `import type { DatabaseMigration } from "./migration"
|
return `import type { DatabaseMigration } from "./migration"
|
||||||
|
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||||
|
|
||||||
export const migrations: DatabaseMigration.Migration[] = (
|
export const migrations = [
|
||||||
await Promise.all([
|
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
] satisfies DatabaseMigration.Migration[]
|
||||||
])
|
|
||||||
).map((module) => module.default)
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,8 +508,23 @@ function toolOutput(result: ToolResultValue) {
|
|||||||
case "text":
|
case "text":
|
||||||
case "error":
|
case "error":
|
||||||
return { type: "text" as const, value: messageValue(result.value) }
|
return { type: "text" as const, value: messageValue(result.value) }
|
||||||
|
case "content":
|
||||||
|
return {
|
||||||
|
type: "content" as const,
|
||||||
|
value: result.value.map((item) => {
|
||||||
|
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||||
|
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||||
|
const image = item.mime.toLowerCase().startsWith("image/")
|
||||||
|
if (data !== undefined)
|
||||||
|
return image
|
||||||
|
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||||
|
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||||
|
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case "json":
|
||||||
|
return { type: "json" as const, value: jsonValue(result.value) }
|
||||||
}
|
}
|
||||||
return { type: "json" as const, value: jsonValue(result.value) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||||
|
|||||||
@@ -153,10 +153,12 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sourceDirectories = ["plugin", "plugins"] as const
|
||||||
|
|
||||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const files = yield* fs
|
const files = yield* fs
|
||||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||||
cwd: directory,
|
cwd: directory,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
include: "file",
|
include: "file",
|
||||||
@@ -168,8 +170,6 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceDirectories = ["plugin", "plugins"] as const
|
|
||||||
|
|
||||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||||
return entries.some(
|
return entries.some(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
|
|||||||
export function layer(options: Options = { path: ":memory:" }) {
|
export function layer(options: Options = { path: ":memory:" }) {
|
||||||
return Layer.unwrap(
|
return Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const global = yield* Global.Service
|
|
||||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||||
const filename = options.path ?? ":memory:"
|
const filename = options.path ?? ":memory:"
|
||||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||||
|
const global = yield* Global.Service
|
||||||
return provide(join(global.data, filename))
|
return provide(join(global.data, filename))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-45
@@ -1,47 +1,86 @@
|
|||||||
import type { DatabaseMigration } from "./migration"
|
import type { DatabaseMigration } from "./migration"
|
||||||
|
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||||
|
import m01 from "./migration/20260211171708_add_project_commands"
|
||||||
|
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||||
|
import m03 from "./migration/20260225215848_workspace"
|
||||||
|
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||||
|
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||||
|
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||||
|
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||||
|
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||||
|
import m09 from "./migration/20260323234822_events"
|
||||||
|
import m10 from "./migration/20260410174513_workspace-name"
|
||||||
|
import m11 from "./migration/20260413175956_chief_energizer"
|
||||||
|
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||||
|
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||||
|
import m14 from "./migration/20260428004200_add_session_path"
|
||||||
|
import m15 from "./migration/20260501142318_next_venus"
|
||||||
|
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||||
|
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||||
|
import m18 from "./migration/20260510033149_session_usage"
|
||||||
|
import m19 from "./migration/20260511000411_data_migration_state"
|
||||||
|
import m20 from "./migration/20260511173437_session-metadata"
|
||||||
|
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||||
|
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||||
|
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||||
|
import m24 from "./migration/20260602182828_add_project_directories"
|
||||||
|
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||||
|
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||||
|
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||||
|
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||||
|
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||||
|
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||||
|
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||||
|
import m32 from "./migration/20260611035744_credential"
|
||||||
|
import m33 from "./migration/20260611192811_lush_chimera"
|
||||||
|
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||||
|
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||||
|
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||||
|
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||||
|
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||||
|
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||||
|
import m40 from "./migration/20260808023530_workspace_domain"
|
||||||
|
|
||||||
export const migrations: DatabaseMigration.Migration[] = (
|
export const migrations = [
|
||||||
await Promise.all([
|
m00,
|
||||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
m01,
|
||||||
import("./migration/20260211171708_add_project_commands"),
|
m02,
|
||||||
import("./migration/20260213144116_wakeful_the_professor"),
|
m03,
|
||||||
import("./migration/20260225215848_workspace"),
|
m04,
|
||||||
import("./migration/20260227213759_add_session_workspace_id"),
|
m05,
|
||||||
import("./migration/20260228203230_blue_harpoon"),
|
m06,
|
||||||
import("./migration/20260303231226_add_workspace_fields"),
|
m07,
|
||||||
import("./migration/20260309230000_move_org_to_state"),
|
m08,
|
||||||
import("./migration/20260312043431_session_message_cursor"),
|
m09,
|
||||||
import("./migration/20260323234822_events"),
|
m10,
|
||||||
import("./migration/20260410174513_workspace-name"),
|
m11,
|
||||||
import("./migration/20260413175956_chief_energizer"),
|
m12,
|
||||||
import("./migration/20260423070820_add_icon_url_override"),
|
m13,
|
||||||
import("./migration/20260427172553_slow_nightmare"),
|
m14,
|
||||||
import("./migration/20260428004200_add_session_path"),
|
m15,
|
||||||
import("./migration/20260501142318_next_venus"),
|
m16,
|
||||||
import("./migration/20260504145000_add_sync_owner"),
|
m17,
|
||||||
import("./migration/20260507164347_add_workspace_time"),
|
m18,
|
||||||
import("./migration/20260510033149_session_usage"),
|
m19,
|
||||||
import("./migration/20260511000411_data_migration_state"),
|
m20,
|
||||||
import("./migration/20260511173437_session-metadata"),
|
m21,
|
||||||
import("./migration/20260601010001_normalize_storage_paths"),
|
m22,
|
||||||
import("./migration/20260601202201_amazing_prowler"),
|
m23,
|
||||||
import("./migration/20260602002951_lowly_union_jack"),
|
m24,
|
||||||
import("./migration/20260602182828_add_project_directories"),
|
m25,
|
||||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
m26,
|
||||||
import("./migration/20260603040000_session_message_projection_order"),
|
m27,
|
||||||
import("./migration/20260603141458_session_input_inbox"),
|
m28,
|
||||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
m29,
|
||||||
import("./migration/20260604172448_event_sourced_session_input"),
|
m30,
|
||||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
m31,
|
||||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
m32,
|
||||||
import("./migration/20260611035744_credential"),
|
m33,
|
||||||
import("./migration/20260611192811_lush_chimera"),
|
m34,
|
||||||
import("./migration/20260612174303_project_dir_strategy"),
|
m35,
|
||||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
m36,
|
||||||
import("./migration/20260622170816_reset_v2_session_state"),
|
m37,
|
||||||
import("./migration/20260622202450_simplify_session_input"),
|
m38,
|
||||||
import("./migration/20260804233008_loose_psylocke"),
|
m39,
|
||||||
import("./migration/20260805200742_import_legacy_credentials"),
|
m40,
|
||||||
import("./migration/20260808023530_workspace_domain"),
|
] satisfies DatabaseMigration.Migration[]
|
||||||
])
|
|
||||||
).map((module) => module.default)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
@@ -612,7 +612,16 @@ export const layer = (options?: Options) =>
|
|||||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||||
const text = yield* fetchApi()
|
const text = yield* fetchApi()
|
||||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||||
yield* kv.set(key, { updatedAt: Date.now(), body: text })
|
// Best-effort: a cache-write failure must never kill catalog
|
||||||
|
// population. The payload has outgrown some KV backends' per-value
|
||||||
|
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||||
|
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||||
|
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
|
||||||
|
Effect.catchCauseIf(
|
||||||
|
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||||
|
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||||
|
),
|
||||||
|
)
|
||||||
return catalog
|
return catalog
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
|
|||||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { State } from "../state"
|
import { State } from "../state"
|
||||||
@@ -15,19 +15,29 @@ export interface Domains {
|
|||||||
readonly tool: ToolHooks
|
readonly tool: ToolHooks
|
||||||
}
|
}
|
||||||
|
|
||||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
|
||||||
|
|
||||||
|
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
|
||||||
|
interface Failures extends Record<keyof Domains, unknown> {
|
||||||
|
readonly aisdk: NoFailures<AISDKHooks>
|
||||||
|
readonly session: NoFailures<SessionHooks>
|
||||||
|
readonly shell: NoFailures<ShellHooks>
|
||||||
|
readonly tool: ToolFailures
|
||||||
|
}
|
||||||
|
|
||||||
|
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: Name,
|
name: Name,
|
||||||
callback: Callback<Domains[Domain][Name]>,
|
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: Name,
|
name: Name,
|
||||||
event: Domains[Domain][Name],
|
event: Domains[Domain][Name],
|
||||||
) => Effect.Effect<Domains[Domain][Name]>
|
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||||
@@ -56,7 +66,9 @@ const layer = Layer.effect(
|
|||||||
|
|
||||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||||
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
|
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
||||||
|
event,
|
||||||
|
])
|
||||||
yield* result
|
yield* result
|
||||||
}
|
}
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const ModelsDevPlugin = define({
|
|||||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||||
})
|
})
|
||||||
for (const model of provider.models) {
|
for (const model of provider.models) {
|
||||||
|
if (model.status === "deprecated") continue
|
||||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
|
|||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
|
|
||||||
export interface Adapter {
|
export interface Adapter {
|
||||||
|
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||||
readonly getAssistant: (
|
readonly getAssistant: (
|
||||||
@@ -59,15 +60,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
"session.created": () => Effect.void,
|
"session.created": () => Effect.void,
|
||||||
"session.usage.recorded": () => Effect.void,
|
"session.usage.recorded": () => Effect.void,
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return adapter.appendMessage(
|
return Effect.gen(function* () {
|
||||||
SessionMessage.AgentSelected.make({
|
const previous = yield* adapter.getAgent()
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
yield* adapter.appendMessage(
|
||||||
type: "agent-switched",
|
SessionMessage.AgentSelected.make({
|
||||||
metadata: event.metadata,
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
agent: event.data.agent,
|
type: "agent-switched",
|
||||||
time: { created: event.created },
|
metadata: event.metadata,
|
||||||
}),
|
agent: event.data.agent,
|
||||||
)
|
previous,
|
||||||
|
time: { created: event.created },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
"session.model.selected": (event) => {
|
"session.model.selected": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
|||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { Agent } from "../agent"
|
||||||
import { Model } from "../model"
|
import { Model } from "../model"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
|
|||||||
}
|
}
|
||||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||||
const adapter: SessionMessageUpdater.Adapter = {
|
const adapter: SessionMessageUpdater.Adapter = {
|
||||||
|
getAgent() {
|
||||||
|
return db
|
||||||
|
.select({ agent: SessionTable.agent })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||||
|
)
|
||||||
|
},
|
||||||
getModel() {
|
getModel() {
|
||||||
return db
|
return db
|
||||||
.select({ model: SessionTable.model })
|
.select({ model: SessionTable.model })
|
||||||
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
|
|||||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||||
db
|
Effect.gen(function* () {
|
||||||
.update(SessionTable)
|
yield* run(db, event)
|
||||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
yield* db
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.update(SessionTable)
|
||||||
.run()
|
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||||
|
const eligible = files.filter(
|
||||||
|
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
|
||||||
|
)
|
||||||
|
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||||
|
|
||||||
|
const seen = new Map<string, Set<string>>()
|
||||||
|
return files.flatMap((file) => {
|
||||||
|
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
|
||||||
|
return attachmentContent(file)
|
||||||
|
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
|
||||||
|
const payloads = seen.get(metadata) ?? new Set<string>()
|
||||||
|
if (payloads.has(file.data)) return []
|
||||||
|
payloads.add(file.data)
|
||||||
|
seen.set(metadata, payloads)
|
||||||
|
return attachmentContent(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||||
|
|
||||||
const providerMetadata = (
|
const providerMetadata = (
|
||||||
@@ -186,7 +205,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
|||||||
const content = [
|
const content = [
|
||||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||||
...(message.files ?? []).flatMap(attachmentContent),
|
...userAttachmentContent(message.files ?? []),
|
||||||
]
|
]
|
||||||
if (content.length === 0) return []
|
if (content.length === 0) return []
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -275,6 +275,73 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
|
yield* aisdk.hook.sdk((event) => {
|
||||||
|
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: resolved,
|
||||||
|
messages: [
|
||||||
|
Message.tool({
|
||||||
|
id: "call_1",
|
||||||
|
name: "read",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "attachments" },
|
||||||
|
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||||
|
mime: "application/pdf",
|
||||||
|
name: "document.pdf",
|
||||||
|
},
|
||||||
|
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
|
||||||
|
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
|
||||||
|
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.prompt).toEqual([
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call_1",
|
||||||
|
toolName: "read",
|
||||||
|
output: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "attachments" },
|
||||||
|
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||||
|
{
|
||||||
|
type: "file-data",
|
||||||
|
data: "JVBERg==",
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
filename: "document.pdf",
|
||||||
|
},
|
||||||
|
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||||
|
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||||
|
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||||
|
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||||
|
const makeFailingWriteKV = (cache: MockCache) =>
|
||||||
|
Layer.mock(KV.Service, {
|
||||||
|
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||||
|
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||||
|
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||||
|
})
|
||||||
|
|
||||||
const makeCache = (): MockCache => ({ values: new Map() })
|
const makeCache = (): MockCache => ({ values: new Map() })
|
||||||
|
|
||||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||||
@@ -248,6 +257,25 @@ describe("ModelsDev Service", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cache = makeCache()
|
||||||
|
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||||
|
const layer = Layer.fresh(
|
||||||
|
AppNodeBuilder.build(ModelsDev.node, [
|
||||||
|
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||||
|
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||||
|
[KV.node, makeFailingWriteKV(cache)],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||||
|
expect(result).toEqual(fixture2Snapshot)
|
||||||
|
expect(cache.values.has(cacheKey)).toBe(false)
|
||||||
|
const final = yield* Ref.get(state)
|
||||||
|
expect(final.calls.length).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = makeCache()
|
const cache = makeCache()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
@@ -395,4 +396,51 @@ describe("Plugin", () => {
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("rejects tool execution when an execute.before hook fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* Plugin.Service
|
||||||
|
const registry = yield* Tool.Service
|
||||||
|
const executed: unknown[] = []
|
||||||
|
|
||||||
|
const plugin = EffectPlugin.define({
|
||||||
|
id: "tool-hook-reject",
|
||||||
|
effect: (ctx) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ctx.tool
|
||||||
|
.transform((draft) =>
|
||||||
|
draft.add({
|
||||||
|
name: "echo",
|
||||||
|
options: { codemode: false },
|
||||||
|
description: "Echo",
|
||||||
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
|
execute: ({ text }) =>
|
||||||
|
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
yield* ctx.tool
|
||||||
|
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* plugins.activate([versioned(plugin)])
|
||||||
|
|
||||||
|
const toolSet = yield* registry.snapshot()
|
||||||
|
const failure = yield* toolSet
|
||||||
|
.execute({
|
||||||
|
sessionID: Session.ID.make("ses_hook_reject"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
messageID: SessionMessage.ID.make("msg_hook_reject"),
|
||||||
|
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
|
||||||
|
expect(executed).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -215,6 +215,66 @@ describe("ModelsDevPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("omits deprecated models from the catalog", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const providerID = Provider.ID.make("acme")
|
||||||
|
const activeID = Model.ID.make("current")
|
||||||
|
const deprecatedID = Model.ID.make("legacy")
|
||||||
|
const model = {
|
||||||
|
modelID: activeID,
|
||||||
|
providerID,
|
||||||
|
name: "Current",
|
||||||
|
capabilities: { tools: true, input: [], output: [] },
|
||||||
|
variants: [],
|
||||||
|
time: { released: Date.parse("2026-01-01") },
|
||||||
|
cost: [],
|
||||||
|
status: "active",
|
||||||
|
enabled: true,
|
||||||
|
limit: { context: 128_000, output: 32_000 },
|
||||||
|
} satisfies Omit<Model.Info, "id">
|
||||||
|
const snapshots = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: providerID,
|
||||||
|
name: "Acme",
|
||||||
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
|
},
|
||||||
|
environment: [],
|
||||||
|
models: [
|
||||||
|
{ id: activeID, ...model },
|
||||||
|
{
|
||||||
|
id: deprecatedID,
|
||||||
|
...model,
|
||||||
|
modelID: deprecatedID,
|
||||||
|
name: "Legacy",
|
||||||
|
status: "deprecated" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies readonly ModelsDev.Snapshot[]
|
||||||
|
|
||||||
|
yield* ModelsDevPlugin.effect(
|
||||||
|
host({
|
||||||
|
catalog: catalogHost(catalog),
|
||||||
|
integration: integrationHost(integrations),
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provideService(
|
||||||
|
ModelsDev.Service,
|
||||||
|
ModelsDev.Service.of({
|
||||||
|
get: () => Effect.succeed(snapshots),
|
||||||
|
refresh: () => Effect.void,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
|
||||||
|
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("registers key methods for providers with environment variables", () =>
|
it.effect("registers key methods for providers with environment variables", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
|||||||
@@ -647,7 +647,7 @@ describe("Session.create", () => {
|
|||||||
it.effect("switches the selected agent through the durable Session event", () =>
|
it.effect("switches the selected agent through the durable Session event", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const created = yield* session.create({ location })
|
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
|
||||||
|
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
||||||
|
|
||||||
@@ -655,6 +655,9 @@ describe("Session.create", () => {
|
|||||||
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" } }])
|
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||||
|
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||||
|
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
|
|||||||
directory: "/project",
|
directory: "/project",
|
||||||
title: "test",
|
title: "test",
|
||||||
version: "test",
|
version: "test",
|
||||||
|
agent: "plan",
|
||||||
model: previousModel,
|
model: previousModel,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
|
|||||||
text: "synthetic context",
|
text: "synthetic context",
|
||||||
metadata: { source: "projector-test" },
|
metadata: { source: "projector-test" },
|
||||||
})
|
})
|
||||||
|
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
|
||||||
|
agent: build,
|
||||||
|
previous: "plan",
|
||||||
|
})
|
||||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||||
command: "pwd",
|
command: "pwd",
|
||||||
|
|||||||
@@ -373,6 +373,103 @@ Recent work
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||||
|
const data = Base64.make("AAECAw==")
|
||||||
|
const messages = toLLMMessages(
|
||||||
|
[
|
||||||
|
SessionMessage.User.make({
|
||||||
|
id: id("user-duplicate-image"),
|
||||||
|
type: "user",
|
||||||
|
text: "[Image 1] [Image 1] [Image 2]",
|
||||||
|
files: [
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||||
|
}),
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||||
|
}),
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
description: "alternate use",
|
||||||
|
mention: { start: 20, end: 29, text: "[Image 2]" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
|
||||||
|
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||||
|
{
|
||||||
|
type: "media",
|
||||||
|
mediaType: "image/png",
|
||||||
|
data,
|
||||||
|
filename: "image.png",
|
||||||
|
metadata: { description: "alternate use" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves provider media with distinct labels or URI sources", () => {
|
||||||
|
const data = Base64.make("AAECAw==")
|
||||||
|
const messages = toLLMMessages(
|
||||||
|
[
|
||||||
|
SessionMessage.User.make({
|
||||||
|
id: id("user-distinct-images"),
|
||||||
|
type: "user",
|
||||||
|
text: "[Image 1] [Image 2]",
|
||||||
|
files: [
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||||
|
}),
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||||
|
}),
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "uri", uri: "file:///project/image.png" },
|
||||||
|
name: "image.png",
|
||||||
|
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||||
|
}),
|
||||||
|
FileAttachment.make({
|
||||||
|
data,
|
||||||
|
mime: "image/png",
|
||||||
|
source: { type: "inline" },
|
||||||
|
name: "image.png",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
|
|||||||
expect(desktop).toContain("NoDisplay=true")
|
expect(desktop).toContain("NoDisplay=true")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("bundles the CLI outside the dev app archive", async () => {
|
for (const channel of ["dev", "beta"] as const) {
|
||||||
const previous = process.env.OPENCODE_CHANNEL
|
test(`bundles the CLI outside the ${channel} app archive`, async () => {
|
||||||
process.env.OPENCODE_CHANNEL = "dev"
|
|
||||||
const module = await import("./electron-builder.config.ts?cli-resource")
|
|
||||||
const config = module.default as Configuration
|
|
||||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
|
||||||
else process.env.OPENCODE_CHANNEL = previous
|
|
||||||
|
|
||||||
expect(config.files).toContain("!resources/opencode-cli*")
|
|
||||||
expect(config.extraResources).toContainEqual({
|
|
||||||
from: "resources/",
|
|
||||||
to: "",
|
|
||||||
filter: ["opencode-cli*"],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const channel of ["beta", "prod"] as const) {
|
|
||||||
test(`does not bundle the CLI in ${channel} builds`, async () => {
|
|
||||||
const previous = process.env.OPENCODE_CHANNEL
|
const previous = process.env.OPENCODE_CHANNEL
|
||||||
process.env.OPENCODE_CHANNEL = channel
|
process.env.OPENCODE_CHANNEL = channel
|
||||||
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
|
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
|
||||||
const config = module.default as Configuration
|
const config = module.default as Configuration
|
||||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||||
else process.env.OPENCODE_CHANNEL = previous
|
else process.env.OPENCODE_CHANNEL = previous
|
||||||
|
|
||||||
expect(config.extraResources).not.toContainEqual({
|
expect(config.files).toContain("!resources/opencode-cli*")
|
||||||
|
expect(config.extraResources).toContainEqual({
|
||||||
from: "resources/",
|
from: "resources/",
|
||||||
to: "",
|
to: "",
|
||||||
filter: ["opencode-cli*"],
|
filter: ["opencode-cli*"],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("does not bundle the CLI in prod builds", async () => {
|
||||||
|
const previous = process.env.OPENCODE_CHANNEL
|
||||||
|
process.env.OPENCODE_CHANNEL = "prod"
|
||||||
|
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
|
||||||
|
const config = module.default as Configuration
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||||
|
else process.env.OPENCODE_CHANNEL = previous
|
||||||
|
|
||||||
|
expect(config.extraResources).not.toContainEqual({
|
||||||
|
from: "resources/",
|
||||||
|
to: "",
|
||||||
|
filter: ["opencode-cli*"],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
|
|||||||
},
|
},
|
||||||
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
||||||
extraResources: [
|
extraResources: [
|
||||||
...(channel === "dev"
|
...(channel !== "prod"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
from: "resources/",
|
from: "resources/",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@sentry/solid": "catalog:",
|
"@sentry/solid": "catalog:",
|
||||||
"@sentry/vite-plugin": "catalog:",
|
"@sentry/vite-plugin": "catalog:",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
|
|
||||||
import { RUST_TARGET } from "./utils"
|
import { CLI_TARGET } from "./utils"
|
||||||
|
|
||||||
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
|
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
|
||||||
|
|
||||||
const BUNDLE_DIR = "dist"
|
const BUNDLE_DIR = "dist"
|
||||||
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
|
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
|
|||||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||||
|
|
||||||
if (channel === "dev") await downloadCliToResources()
|
if (channel === "dev") await downloadCliToResources()
|
||||||
|
if (channel === "beta") await downloadCliToResources("next")
|
||||||
|
|||||||
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
|
|||||||
return "dev"
|
return "dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
|
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-apple-darwin",
|
target: "aarch64-apple-darwin",
|
||||||
package: "@opencode-ai/cli-darwin-arm64",
|
package: "@opencode-ai/cli-darwin-arm64",
|
||||||
os: "darwin",
|
os: "darwin",
|
||||||
cpu: "arm64",
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-apple-darwin",
|
target: "x86_64-apple-darwin",
|
||||||
package: "@opencode-ai/cli-darwin-x64-baseline",
|
package: "@opencode-ai/cli-darwin-x64-baseline",
|
||||||
os: "darwin",
|
os: "darwin",
|
||||||
cpu: "x64",
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-pc-windows-msvc",
|
target: "aarch64-pc-windows-msvc",
|
||||||
package: "@opencode-ai/cli-windows-arm64",
|
package: "@opencode-ai/cli-windows-arm64",
|
||||||
os: "win32",
|
os: "win32",
|
||||||
cpu: "arm64",
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-pc-windows-msvc",
|
target: "x86_64-pc-windows-msvc",
|
||||||
package: "@opencode-ai/cli-windows-x64-baseline",
|
package: "@opencode-ai/cli-windows-x64-baseline",
|
||||||
os: "win32",
|
os: "win32",
|
||||||
cpu: "x64",
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "x86_64-unknown-linux-gnu",
|
target: "x86_64-unknown-linux-gnu",
|
||||||
package: "@opencode-ai/cli-linux-x64-baseline",
|
package: "@opencode-ai/cli-linux-x64-baseline",
|
||||||
os: "linux",
|
os: "linux",
|
||||||
cpu: "x64",
|
cpu: "x64",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rustTarget: "aarch64-unknown-linux-gnu",
|
target: "aarch64-unknown-linux-gnu",
|
||||||
package: "@opencode-ai/cli-linux-arm64",
|
package: "@opencode-ai/cli-linux-arm64",
|
||||||
os: "linux",
|
os: "linux",
|
||||||
cpu: "arm64",
|
cpu: "arm64",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const RUST_TARGET = Bun.env.RUST_TARGET
|
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
|
||||||
|
|
||||||
function nativeTarget() {
|
function nativeTarget() {
|
||||||
const { platform, arch } = process
|
const { platform, arch } = process
|
||||||
@@ -62,19 +62,19 @@ function nativeTarget() {
|
|||||||
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
|
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
|
||||||
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
|
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
|
||||||
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
|
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
|
||||||
|
|
||||||
return binaryConfig
|
return binaryConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadCliToResources() {
|
export async function downloadCliToResources(version = CLI_VERSION) {
|
||||||
const cli = getCurrentCli()
|
const cli = getCurrentCli()
|
||||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||||
const dest = windowsify("resources/opencode-cli")
|
const dest = windowsify("resources/opencode-cli")
|
||||||
try {
|
try {
|
||||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
|
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
|
||||||
dest,
|
dest,
|
||||||
@@ -88,7 +88,7 @@ export async function downloadCliToResources() {
|
|||||||
}
|
}
|
||||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||||
|
|
||||||
console.log(`Copied ${cli.package} to ${dest}`)
|
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function windowsify(path: string) {
|
export function windowsify(path: string) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Service } from "@opencode-ai/client/service"
|
||||||
import { execFile } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
||||||
@@ -8,52 +9,34 @@ import { app } from "electron"
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
const root = dirname(fileURLToPath(import.meta.url))
|
const root = dirname(fileURLToPath(import.meta.url))
|
||||||
const stateHome = process.env.XDG_STATE_HOME
|
|
||||||
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
|
|
||||||
|
|
||||||
type Logger = {
|
type Logger = {
|
||||||
log(message: string, meta?: Record<string, unknown>): void
|
log(message: string, meta?: Record<string, unknown>): void
|
||||||
error(message: string, meta?: Record<string, unknown>): void
|
error(message: string, meta?: Record<string, unknown>): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
|
export async function startBackgroundCli(logger: Logger) {
|
||||||
const bundled = app.isPackaged
|
const bundled = app.isPackaged
|
||||||
? join(process.resourcesPath, executableName())
|
? join(process.resourcesPath, executableName())
|
||||||
: join(root, "../../resources", executableName())
|
: join(root, "../../resources", executableName())
|
||||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||||
const version = await run(bundled, ["--version"], logger)
|
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||||
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
||||||
|
const service = await Service.ensure({
|
||||||
const candidates = [
|
version,
|
||||||
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
|
command: [binary, "serve", "--service"],
|
||||||
].filter((candidate) => candidate === undefined || existsSync(candidate))
|
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||||
const discovered = await Promise.all(
|
|
||||||
candidates.map(async (candidate) => ({
|
|
||||||
stateHome: candidate,
|
|
||||||
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const found = discovered.find((candidate) => candidate.url !== undefined)
|
|
||||||
logger.log("v2 CLI background instance checked", {
|
|
||||||
detected: Boolean(found),
|
|
||||||
...endpoint(found?.url),
|
|
||||||
})
|
|
||||||
|
|
||||||
const daemonStateHome = found?.stateHome ?? stateHome
|
|
||||||
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
|
|
||||||
const password = await run(binary, ["service", "get", "password"], logger, {
|
|
||||||
redact: true,
|
|
||||||
stateHome: daemonStateHome,
|
|
||||||
})
|
})
|
||||||
|
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||||
logger.log("v2 CLI background service ready", {
|
logger.log("v2 CLI background service ready", {
|
||||||
existing: Boolean(found),
|
username: service.auth.username,
|
||||||
username: "opencode",
|
version,
|
||||||
...endpoint(url),
|
...endpoint(service.url),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url,
|
url: service.url,
|
||||||
username: "opencode",
|
username: service.auth.username,
|
||||||
password,
|
password: service.auth.password,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) {
|
|||||||
return destination
|
return destination
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(
|
async function run(binary: string, args: string[], logger: Logger) {
|
||||||
binary: string,
|
|
||||||
args: string[],
|
|
||||||
logger: Logger,
|
|
||||||
options: { redact?: boolean; stateHome?: string } = {},
|
|
||||||
) {
|
|
||||||
logger.log("v2 CLI command started", { binary, args })
|
logger.log("v2 CLI command started", { binary, args })
|
||||||
const env = { ...process.env }
|
return execFileAsync(binary, args, { windowsHide: true }).then(
|
||||||
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
|
|
||||||
else env.XDG_STATE_HOME = options.stateHome
|
|
||||||
return execFileAsync(binary, args, { env, windowsHide: true }).then(
|
|
||||||
(result) => {
|
(result) => {
|
||||||
const stdout = result.stdout.trim()
|
const stdout = result.stdout.trim()
|
||||||
const stderr = result.stderr.trim()
|
const stderr = result.stderr.trim()
|
||||||
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
|
logger.log("v2 CLI command completed", { args, stdout, stderr })
|
||||||
return stdout
|
return stdout
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
@@ -99,7 +74,7 @@ async function run(
|
|||||||
logger.error("v2 CLI command failed", {
|
logger.error("v2 CLI command failed", {
|
||||||
args,
|
args,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
|
stdout: output.stdout?.trim() ?? "",
|
||||||
stderr: output.stderr?.trim() ?? "",
|
stderr: output.stderr?.trim() ?? "",
|
||||||
})
|
})
|
||||||
throw error
|
throw error
|
||||||
@@ -107,11 +82,11 @@ async function run(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceUrl(status: string) {
|
function parseVersion(output: string) {
|
||||||
if (URL.canParse(status)) return status
|
const marker = output.lastIndexOf(" v")
|
||||||
if (!status.startsWith("running ")) return
|
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||||
const url = status.slice("running ".length).trim()
|
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||||
return URL.canParse(url) ? url : undefined
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpoint(url: string | undefined) {
|
function endpoint(url: string | undefined) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user