Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 67e232840e feat(app): support v1 and v2 servers 2026-07-23 13:52:27 +08:00
122 changed files with 3555 additions and 4469 deletions
+7 -7
View File
@@ -303,7 +303,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -578,7 +578,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
@@ -1076,12 +1076,12 @@
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1202,7 +1202,7 @@
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
@@ -5702,9 +5702,9 @@
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
"x86_64-linux": "sha256-L741oedvozk0cIVnaZnujvwWrK+WXINv9KiKxYRfVwQ=",
"aarch64-linux": "sha256-ThzQ4nCLbaLiKA7cBHI7OMAlXb+8Hchm3HojGnIAEz0=",
"aarch64-darwin": "sha256-YdXOgFgYRu4tKw90+7F1reCihO+JC33dGk51J8NTRIk=",
"x86_64-darwin": "sha256-Ea5X2mYHGch3JyA6wC0uH3zBEzQcFT/adqJ1+7LtRdQ="
}
}
+1 -1
View File
@@ -149,7 +149,7 @@
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
+220
View File
@@ -0,0 +1,220 @@
# V1 API Migration Checklist
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
## Events
- [x] Replace `GET /global/event` with `GET /api/event`.
- `src/context/server-sdk.tsx`
- [x] Reduce current granular session and message events into the existing app projections.
- `src/context/server-session-v2-reducer.ts`
- `src/context/server-session.ts`
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- `src/context/notification.tsx`
- `src/pages/session/usage-exceeded-dialogs.tsx`
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
- `src/context/global-sync/event-reducer.ts`
- `src/context/server-session.ts`
- [x] Adapt current permission and question events to the existing request model.
- `src/context/global-sync/event-reducer.ts`
- `src/context/permission.tsx`
- [x] Consume current file watcher events.
- `src/context/file.tsx`
- [x] Consume current VCS events.
- `src/context/global-sync/event-reducer.ts`
- `src/pages/session.tsx`
- [x] Consume current `pty.exited` events.
- `src/context/terminal.tsx`
- [ ] Migrate LSP and reference events.
- `src/context/global-sync/event-reducer.ts`
## Sessions
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
- `src/context/server-sync.tsx`
- [x] Migrate session listing from `GET /session`.
- `src/context/server-sync.tsx`
- `src/context/directory-sync.ts`
- `src/pages/layout.tsx`
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
- `src/components/titlebar.tsx`
- [x] Migrate session updates from `PATCH /session/:sessionID`.
- `src/context/directory-sync.ts`
- `src/context/layout.tsx`
- `src/pages/home.tsx`
- `src/pages/layout.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- `src/components/titlebar-tab-nav.tsx`
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
- `src/pages/session/timeline/message-timeline.tsx`
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
- [x] Migrate abort from `POST /session/:sessionID/abort`.
- `src/components/prompt-input/submit.ts`
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session.tsx`
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
- `src/pages/session/use-session-commands.tsx`
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
- `src/components/prompt-input/submit.ts`
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
- `src/components/dialog-fork.tsx`
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
- `src/pages/session/use-session-commands.tsx`
- `src/pages/session/timeline/message-timeline.tsx`
- Blocked: the current API has no sharing contract or implementation.
## Session Compatibility Fallbacks
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
- `src/context/server-session.ts`
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
- `src/context/server-session.ts`
## Filesystem
- [ ] Migrate file listing from `GET /file`.
- `src/context/file.tsx`
- [ ] Migrate file reads from `GET /file/content`.
- `src/context/file.tsx`
- `src/pages/session/review-tab.tsx`
- `src/pages/session/v2/review-panel-v2.tsx`
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
- `src/context/global-sync/bootstrap.ts`
- `src/components/dialog-select-directory.tsx`
- `src/components/dialog-select-directory-v2.tsx`
## Projects And Worktrees
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate Git initialization from `POST /project/git/init`.
- `src/pages/session.tsx`
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
- `src/context/layout.tsx`
- `src/components/edit-project.ts`
- `src/pages/layout.tsx`
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
- `src/pages/layout.tsx`
- `src/components/prompt-input/submit.ts`
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
- [ ] Migrate instance disposal from `POST /instance/dispose`.
- `src/pages/layout.tsx`
## VCS
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
- `src/pages/session.tsx`
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
- `src/pages/layout.tsx`
## Configuration And Authentication
- [ ] Migrate global configuration reads from `GET /global/config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate directory configuration reads from `GET /config`.
- `src/context/global-sync/bootstrap.ts`
- [ ] Migrate global configuration updates from `PATCH /global/config`.
- `src/context/server-sync.tsx`
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
- `src/components/dialog-connect-provider.tsx`
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
- `src/components/dialog-connect-provider.tsx`
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/dialog-custom-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
- [ ] Migrate global disposal from `POST /global/dispose`.
- `src/components/dialog-connect-provider.tsx`
- `src/components/settings-providers.tsx`
- `src/components/settings-v2/providers.tsx`
## Permissions And Questions
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/permission.tsx`
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
- `src/context/permission.tsx`
- `src/pages/session/composer/session-composer-state.ts`
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
- `src/context/global-sync/bootstrap.ts`
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
- `src/pages/session/composer/session-question-dock.tsx`
## Commands, MCP, LSP, And References
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
- `src/context/global-sync/bootstrap.ts`
- `src/context/server-sync.tsx`
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
- `src/context/server-sync.tsx`
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
- `src/context/server-sync.tsx`
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
- `src/context/server-sync.tsx`
- [ ] Migrate LSP status from `GET /lsp`.
- `src/context/server-sync.tsx`
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
- `src/context/global-sync/bootstrap.ts`
## Search
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
- `src/components/command-palette.ts`
- `src/components/dialog-command-palette-v2.tsx`
## PTY And Terminal
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
- `src/context/terminal.tsx`
- `src/components/terminal.tsx`
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
- `src/components/settings-general.tsx`
- `src/components/settings-v2/general.tsx`
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
- `src/components/terminal.tsx`
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
- `src/components/terminal.tsx`
## Legacy Types And Adapters
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
- [ ] Replace the current-session-to-legacy-session adapter.
- `src/utils/session.ts`
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
- `src/utils/session-message.ts`
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
- `src/context/global-sync/utils.ts`
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
- `package.json`
## Test Infrastructure
- [ ] Replace V1 endpoint mocks with current API mocks.
- `e2e/utils/mock-server.ts`
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
- `e2e/utils/sse-transport.ts`
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
- `e2e/performance/timeline-stability/fixture.ts`
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
@@ -97,7 +97,6 @@ export async function setupTimeline(
locale?: string
deviceScaleFactor?: number
seedHistory?: boolean
protocol?: "v1" | "v2"
} = {},
) {
const sessions = input.sessions ?? [session()]
@@ -115,7 +114,6 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: input.protocol,
directory,
project: project(),
provider: provider(),
@@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
)
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
parent: requests.filter((request) => request.type === "parent").length,
}
if (mode === "candidate") {
expect(requestCounts.parent).toBe(1)
expect(historyGates).toBe(1)
expect(requestCounts.parent).toBe(0)
expect(historyGates).toBe(0)
}
return { metrics, requestCounts, historyGateCount: historyGates }
}
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await tabA.locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
@@ -84,16 +85,19 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
@@ -116,7 +120,17 @@ async function mockServers(page: Page, requests: string[]) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } })
return json(route, {})
})
}
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {})
if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
return json(route, { data: [] })
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project") {
return json(route, [
{
id: remote ? sessionB.projectID : "project-server-a",
worktree: directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -193,7 +219,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
permissionRequests.push(url.toString())
return json(route, [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
@@ -216,7 +242,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory,
home: directory,
})
if (url.pathname === "/api/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
return json(route, {})
})
}
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
@@ -57,11 +58,14 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status")
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
if (url.pathname === "/session") return json(route, [current])
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
@@ -90,7 +94,17 @@ async function mockServers(page: Page) {
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } })
return json(route, {})
})
}
@@ -104,10 +118,10 @@ function json(route: Route, body: unknown, status = 200) {
})
}
function sse(route: Route) {
function sse(route: Route, current: boolean) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
})
}
@@ -143,9 +143,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
await page.getByRole("tab", { name: "Changes" }).click()
expect(await (await diffResponse).json()).toHaveLength(1)
expect((await (await diffResponse).json()).data).toHaveLength(1)
const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review)
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeDisabled()
await panel.locator("#session-side-panel-review-tab").click()
await panel.getByRole("tab", { name: /Review/ }).click()
await expect(sidebarToggle).toBeEnabled()
await panel.getByRole("tab", { name: "Open file" }).click()
await page.keyboard.press("Control+w")
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
await page.getByRole("option", { name: next }).click()
}
async function selectFile(page: Page, file: string) {
@@ -92,18 +92,20 @@ async function setup(page: Page) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
body: JSON.stringify({
location: { directory },
data:
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
}),
}),
)
await page.addInitScript(
@@ -64,39 +64,80 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
body: JSON.stringify({
location: { directory },
data: { branch: "review-pane-performance", defaultBranch: "dev" },
}),
}),
)
await page.route("**/vcs/diff**", (route) => {
await page.route("**/api/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
body: JSON.stringify({
location: { directory: scope ?? directory, project: { id: projectID, directory } },
data:
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
}),
})
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty?*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_review_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route("**/api/pty/pty_review_terminal?*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_review_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
@@ -134,8 +175,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
@@ -148,8 +189,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
events.push(statusEvent("idle"))
@@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await pathBlocked
return route.fallback()
})
@@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
)
await question.getByRole("button", { name: "Submit" }).click()
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
@@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => {
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
})
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@@ -1,6 +1,6 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/SubagentNavigation"
@@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
events,
eventRetry: events ? 16 : undefined,
})
// The child session resolves via /session/:id but is absent from the /session list,
// The child session resolves by ID but is absent from the session list,
// matching a subagent session that has not been loaded into the list cache yet.
await page.route(
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
body: JSON.stringify({
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
cursor: {},
}),
}),
)
await configurePage(page)
@@ -1,5 +1,6 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
@@ -56,9 +57,14 @@ async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, sessions)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -66,7 +72,7 @@ async function mockServer(page: Page) {
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
@@ -89,7 +95,20 @@ async function mockServer(page: Page) {
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}
@@ -46,25 +46,30 @@ test.beforeEach(async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty?*", (route) => {
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(`**/api/pty/${ptyID}?*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
})
@@ -95,12 +100,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const created = { count: 0 }
await page.route("**/pty", (route) => {
await page.route("**/api/pty?*", (route) => {
created.count += 1
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
})
})
await page.route(/ghostty-web/, async (route) => {
@@ -155,27 +160,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 }
await page.route("**/pty", (route) => {
await page.route("**/api/pty?*", (route) => {
created.count += 1
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
body: JSON.stringify({ location: ptyLocation(), data: next }),
})
})
await page.route(`**/pty/${newPtyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route(`**/api/pty/${newPtyID}?*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
}),
)
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
}),
)
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@@ -207,3 +216,11 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
)
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo(id: string, title: string) {
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
@@ -43,17 +43,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty?*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.route("**/pty/pty_hidden_terminal", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
await page.route("**/api/pty/pty_hidden_terminal?*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: {
id: "pty_hidden_terminal",
title: "Terminal 1",
command: "cmd.exe",
args: [],
cwd: directory,
status: "running",
pid: 1,
},
}),
}),
)
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory } },
data: { ticket: "e2e-ticket", expires_in: 60 },
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
await writeProbe(page)
await switchTab(page, titleB)
@@ -85,26 +89,33 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route("**/pty", (route) =>
await page.route("**/api/pty?*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/pty/${ptyID}`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
await page.route(`**/api/pty/${ptyID}?*`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}),
)
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
const url = new URL(route.request().url())
expect(url.searchParams.get("location[directory]")).toBe(directory)
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ ticket: "e2e-ticket" }),
}),
)
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
})
})
const connections: string[] = []
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
connections.push(ws.url())
})
@@ -143,3 +154,11 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
}
function ptyInfo() {
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
}
+5 -22
View File
@@ -4,7 +4,6 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown
directory: string
project: unknown
@@ -61,19 +60,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: [
...(path === "/global/event"
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
: events,
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/global/health") return json(route, { healthy: true })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -171,12 +162,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
!search ||
String(session.title ?? "")
.toLowerCase()
.includes(search)
)
return !search || String(session.title ?? "").toLowerCase().includes(search)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const data = ordered.slice(offset, offset + limit)
@@ -190,9 +176,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
status.type === "idle" ? [] : [[id, { type: "running" }]],
),
Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])),
),
})
}
@@ -305,8 +289,7 @@ function currentPermission(value: unknown) {
resources: permission.patterns ?? [],
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
}
}
+5 -10
View File
@@ -104,8 +104,7 @@ export async function installSseTransport<T>(
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = (
@@ -161,7 +160,9 @@ export async function installSseTransport<T>(
})
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
const bytes = encoder.encode(
encoded.map((item) => frame(item.payload, item.delivery.options)).join(""),
)
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
}
@@ -194,15 +195,9 @@ export async function installSseTransport<T>(
connections.push(record)
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
if (url.pathname === "/api/event")
controller.enqueue(
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
)
if (url.pathname === "/global/event")
controller.enqueue(
encoder.encode(
frame({
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
}),
frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }),
),
)
request.signal.addEventListener(
+11 -52
View File
@@ -47,7 +47,7 @@ import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { currentRoute, LayoutProvider } from "@/context/layout"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
@@ -67,8 +67,7 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref }
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { NewHome } from "@/pages/home"
import { LegacyHome } from "@/pages/home/legacy-home"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -237,44 +236,6 @@ function UiI18nBridge(props: ParentProps) {
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
}
function LayoutCompatibility(props: ParentProps) {
const global = useGlobal()
const location = useLocation()
const server = useServer()
const settings = useSettings()
const tabs = useTabs()
const connection = createMemo(() => {
const route = currentRoute(location.pathname, location.search)
if (route.type === "draft" && !tabs.ready()) return
const key =
route.type === "draft"
? tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === route.draftID)?.server
: route.type === "home"
? undefined
: route.server
if (!key) return server.current
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
const protocol = createMemo(() => {
const value = connection()
if (!value) return
return global.ensureServerCtx(value).sdk.protocolKind()
})
createEffect(() => {
const value = protocol()
if (!value) return
settings.general.setNewLayoutRequired(value === "v2")
})
const ready = createMemo(() => {
const value = protocol()
if (!value) return false
return settings.general.newLayoutRequired() === (value === "v2")
})
return <Show when={ready()}>{props.children}</Show>
}
declare global {
interface Window {
__OPENCODE__?: {
@@ -596,17 +557,15 @@ export function AppInterface(props: {
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<LayoutCompatibility>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</LayoutCompatibility>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
@@ -1,5 +1,6 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
@@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { normalizeSessionInfo } from "@/utils/session"
export type CommandPaletteEntry = {
id: string
@@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -219,7 +221,7 @@ export function createServerSessionEntries(props: {
server: ServerConnection.Key
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }>
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
untitled: () => string
category: () => string
}) {
@@ -255,7 +257,8 @@ export function createServerSessionEntries(props: {
return props
.load(search, current.signal)
.then((result) =>
(result.data ?? [])
result.data
.map(normalizeSessionInfo)
.filter((session) => !session.time.archived)
.map((session) => {
const project =
@@ -264,7 +267,7 @@ export function createServerSessionEntries(props: {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : session.project?.name || getFilename(session.directory),
description: project ? displayName(project) : getFilename(session.directory),
category: props.category(),
directory: session.directory,
sessionID: session.id,
@@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: {
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) =>
serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }),
serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -1,4 +1,7 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import type {
IntegrationMethod,
IntegrationOauthConnectOutput,
} from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -28,6 +31,8 @@ import {
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -228,8 +236,6 @@ function ProviderPickerV2(props: {
}) {
const providers = useProviders(props.directory)
const language = useLanguage()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const [store, setStore] = createStore({
filter: "",
active: undefined as string | undefined,
@@ -266,19 +272,7 @@ function ProviderPickerV2(props: {
const connect = (provider: string) => {
props.onPrepare?.()
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
props.onSelect(provider)
return
}
if (store.connecting) return
setStore("connecting", provider)
void serverSDK()
.client.provider.auth()
.then((response) => {
serverSync().set("provider_auth", response.data ?? {})
props.onSelect(provider)
})
.catch(() => props.onSelect(provider))
props.onSelect(provider)
}
const move = (event: KeyboardEvent, direction: number) => {
@@ -395,10 +389,17 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(props.directory)
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -413,38 +414,34 @@ function ProviderConnection(props: {
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
const fallback = createMemo<ConnectMethod[]>(() => [
{
type: "api" as const,
type: "key" as const,
label: language.t("provider.connect.method.apiKey"),
},
])
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const cachedMethods = serverSync().data.provider_auth[props.provider]
const directMethod =
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({
methodIndex: directMethod as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>,
state: (directMethod === undefined ? "pending" : undefined) as
| undefined
| "pending"
| "complete"
| "error"
| "prompt",
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
@@ -454,7 +451,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
@@ -508,7 +505,7 @@ function ProviderConnection(props: {
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey")
if (value.type === "key") return language.t("provider.connect.method.apiKey")
return value.label ?? ""
}
@@ -518,7 +515,7 @@ function ProviderConnection(props: {
const hint = suffix?.[1]
return {
label: suffix ? label.slice(0, -suffix[0].length) : label,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined,
}
}
@@ -549,46 +546,22 @@ function ProviderConnection(props: {
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "api" && method.prompts?.length) {
if (!inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.inputs", inputs })
return
}
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
if (!alive.value) return
const elapsed = Date.now() - start
const delay = 1000 - elapsed
if (delay > 0) {
if (timer.current !== undefined) clearTimeout(timer.current)
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
dispatch({ type: "auth.complete", authorization: x.data! })
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
@@ -603,9 +576,9 @@ function ProviderConnection(props: {
index: 0,
})
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const prompts = createMemo(() => {
const value = method()
return value?.prompts ?? []
return value?.type === "oauth" ? (value.prompts ?? []) : []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true
@@ -636,10 +609,6 @@ function ProviderConnection(props: {
setFormStore("index", next)
return
}
if (method()?.type === "api") {
dispatch({ type: "auth.inputs", inputs: value })
return
}
await selectMethod(store.methodIndex, value)
}
@@ -741,7 +710,10 @@ function ProviderConnection(props: {
})
async function complete() {
await serverSDK().client.global.dispose()
const value = directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
@@ -805,7 +777,7 @@ function ProviderConnection(props: {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
key={(m) => m?.label ?? m?.type}
onSelect={async (selected, index) => {
if (!selected) return
void selectMethod(index)
@@ -851,13 +823,10 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
})
await complete()
}
@@ -984,12 +953,13 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
@@ -1076,25 +1046,37 @@ function ProviderConnection(props: {
})
onMount(() => {
void (async () => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
await complete()
})()
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return (
@@ -1178,15 +1160,15 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<Match when={method()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<Match when={store.authorization?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<Match when={store.authorization?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
@@ -131,7 +131,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
+3 -7
View File
@@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.client.session.fork({ sessionID, messageID: item.id })
.api.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -28,6 +28,7 @@ import {
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { getFilename } from "@opencode-ai/core/util/path"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.client.path
sdk.api.path
.get()
.then((result) => result.data)
.catch(() => undefined),
{ initialValue: undefined },
)
@@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
)
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const cleaned = cleanPickerInput(value)
const typed = cleaned.replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
type: "file",
limit: 20,
})
.then((result) => result.data)
.catch(() => [])
const results = [
...directories,
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
]
return {
query: value,
@@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
type: entry.type,
})),
)
.catch(() => undefined)
})
listings.set(key, request)
@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
return sdk.api.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => {
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (!x || toggle.isPending) return
if (!x || x.status === "pending" || toggle.isPending) return
toggle.mutate(x.name)
}}
>
@@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={enabled()}
disabled={toggle.isPending && toggle.variables === i.name}
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
onChange={() => {
if (toggle.isPending) return
toggle.mutate(i.name)
@@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
},
@@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve({
data: [
{ path: "Users/", type: "directory" },
{ path: "tmp/", type: "directory" },
],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
expect(await search("/")).toEqual(["/Users", "/tmp"])
expect(directories).toEqual(["/"])
})
test("identifies the next directory level to preload", () => {
expect(
preloadTreeDirectories("src/", [
@@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
let current = 0
const scoped = (value: string) => {
const raw = normalizePickerDrive(value)
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
const base = args.base()
if (!base) return
const raw = normalizePickerDrive(value)
if (!raw) return { directory: trimPickerPath(base), path: "" }
const home = args.home()
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
const root = pickerRoot(raw)
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
return { directory: trimPickerPath(base), path: raw }
}
@@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
.map((node) => {
const relative = trimPickerPath(normalizePickerDrive(node.path))
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
}),
)
cache.set(key, request)
return request
@@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+5 -2
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
@@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverCtx().sdk.client.project.update({
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
@@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
@@ -19,11 +24,15 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sessionDirectories: Record<string, string> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -32,7 +41,7 @@ let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
const 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>({
prompt: promptValue,
cursor: 0,
@@ -64,23 +73,39 @@ const prompt = {
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
session: {
create: async () => {
await createSessionGate
createdSessions.push(directory)
return {
data: {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: `New session ${createdSessions.length}`,
},
}
location: { directory: location },
}
},
prompt: async (input: unknown) => {
sentPrompts.push(directory)
promptInputs.push(input)
return { data: undefined }
},
command: async (input: unknown) => {
sentCommands.push(input)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
promptAsync: async () => ({ data: undefined }),
},
session: {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
@@ -90,27 +115,6 @@ const clientFor = (directory: string) => {
}
}
const api = {
session: {
async create(input: { location: { directory: string } }) {
await createSessionGate
createdSessions.push(input.location.directory)
const session = {
id: `session-${createdSessions.length}`,
title: `New session ${createdSessions.length}`,
}
sessionDirectories[session.id] = input.location.directory
return session
},
async shell(input: { sessionID: string }) {
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
},
async prompt() {},
async command() {},
async interrupt() {},
},
}
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
@@ -193,8 +197,8 @@ beforeAll(async () => {
const sdk = {
scope: "local",
directory: "/repo/main",
api,
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -206,7 +210,7 @@ beforeAll(async () => {
mock.module("@/context/sync", () => ({
useSync: () => () => ({
data: { command: [] },
data: { command: commands },
session: {
optimistic: {
add: (value: {
@@ -233,6 +237,9 @@ beforeAll(async () => {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
@@ -274,11 +281,17 @@ beforeAll(async () => {
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
@@ -287,8 +300,8 @@ beforeEach(() => {
variant = undefined
permissionServer = "server-a"
createSessionGate = undefined
serverSessionSyncs = 0
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
})
describe("prompt submit worktree selection", () => {
@@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => {
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
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" },
@@ -443,6 +472,7 @@ describe("prompt submit worktree selection", () => {
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
@@ -451,6 +481,53 @@ describe("prompt submit worktree selection", () => {
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect(sentPrompts).toEqual(["/repo/main"])
expect(promptInputs[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
files: [],
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
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,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([
{
sessionID: "session-1",
id: expect.stringMatching(/^msg_/),
command: "review",
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
files: [],
},
])
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
@@ -511,7 +588,8 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
})
})
@@ -15,47 +15,9 @@ export const ServerRowMenu: Component<{
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
return (
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
)
}
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
export function serverMenuLabels(language: ReturnType<typeof useLanguage>) {
return {
more: language.t("common.moreOptions"),
server: language.t("settings.section.server"),
edit: language.t("dialog.server.menu.edit"),
default: language.t("dialog.server.menu.default"),
defaultRemove: language.t("dialog.server.menu.defaultRemove"),
delete: language.t("dialog.server.menu.delete"),
}
}
export const ServerRowMenuView: Component<{
server: ServerConnection.Any
labels: ReturnType<typeof serverMenuLabels>
canDefault: boolean
isDefault: boolean
onEdit: (server: ServerConnection.Http) => void
onSetDefault: () => void
onRemoveDefault: () => void
onRemove: () => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const builtin = () => ServerConnection.builtin(props.server)
const httpServer = () => (props.server.type === "http" ? props.server : undefined)
return (
<MenuV2 gutter={6} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
@@ -63,30 +25,31 @@ export const ServerRowMenuView: Component<{
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.labels.more}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{props.labels.server}</MenuV2.GroupLabel>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin() || !httpServer()}
onSelect={() => {
const server = httpServer()
if (server) props.onEdit(server)
}}
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
>
{props.labels.edit}
{language.t("dialog.server.menu.edit")}
</MenuV2.Item>
<Show when={props.canDefault && !props.isDefault}>
<MenuV2.Item onSelect={props.onSetDefault}>{props.labels.default}</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.canDefault && props.isDefault}>
<MenuV2.Item onSelect={props.onRemoveDefault}>{props.labels.defaultRemove}</MenuV2.Item>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin()} onSelect={props.onRemove}>
{props.labels.delete}
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "./updater-action"
import {
monoDefault,
@@ -125,14 +125,12 @@ export const SettingsGeneral: Component = () => {
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const [shells] = createResource(
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
() =>
serverSdk()
.api.pty.shells()
.then((res) => res.data)
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -324,29 +322,27 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<Show when={protocol() === "v1"}>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -39,7 +39,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -84,8 +83,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -98,7 +96,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -221,33 +218,31 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
)}
</For>
<Show when={protocol() === "v1"}>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
<span class="text-12-regular text-text-weak pl-8">
{language.t("settings.providers.custom.description")}
</span>
</div>
</Show>
<Button
size="large"
variant="secondary"
icon="plus-small"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</Button>
</div>
</SettingsList>
<Button
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
@@ -92,7 +92,6 @@ export const SettingsGeneralV2: Component<{
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
@@ -123,11 +122,10 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
() => (protocol() === "v1" ? serverSdk() : undefined),
(sdk) =>
sdk
.client.pty.shells()
.then((res) => res.data ?? [])
() =>
serverSdk()
.api.pty.shells()
.then((res) => res.data)
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -283,28 +281,26 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
<Show when={protocol() === "v1"}>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
</Show>
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -696,9 +692,7 @@ export const SettingsGeneralV2: Component<{
<div class="settings-v2-tab-body">
<Show when={settings.general.layoutTransitionAvailable()}>
<Show when={!settings.general.newLayoutRequired()}>
<InterfaceSection />
</Show>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
@@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
@@ -33,7 +33,6 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -78,8 +77,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -92,7 +90,6 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
}
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
@@ -221,35 +218,33 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
)}
</For>
<Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
/>
<div class="settings-v2-provider-copy">
<div class="settings-v2-provider-main">
<span class="settings-v2-provider-name">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
<p class="settings-v2-provider-description">{language.t("settings.providers.custom.description")}</p>
</div>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</Show>
<ButtonV2
size="normal"
variant="neutral"
icon="plus"
onClick={() => {
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
}}
>
{language.t("common.connect")}
</ButtonV2>
</div>
</SettingsListV2>
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
@@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useSettings } from "@/context/settings"
import { useMcpToggle } from "@/context/mcp"
import { useServerProtocol } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -258,7 +257,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const language = useLanguage()
const navigate = useNavigate()
const settings = useSettings()
const protocol = useServerProtocol()
const fail = (err: unknown) => {
showToast({
@@ -317,12 +315,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
<Show when={protocol() === "v1"}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
{!settings.general.newLayoutDesigns() && (
@@ -482,27 +478,25 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
</div>
</Tabs.Content>
<Show when={protocol() === "v1"}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</Tabs.Content>
</Show>
</div>
</Tabs.Content>
</Tabs>
</div>
)
+18 -32
View File
@@ -11,7 +11,6 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -176,15 +175,9 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory
const client = sdk().client
const client = sdk().api.pty
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -242,9 +235,10 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = (cols: number, rows: number) => {
return client.pty
return client
.update({
ptyID: id,
location: { directory },
size: { cols, rows },
})
.catch((err) => {
@@ -523,33 +517,29 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = () =>
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
client
.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
return client
.connectToken({
ptyID: id,
location: { directory },
"x-opencode-ticket": "1",
})
.then((result) => result.data.ticket)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
}
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
@@ -579,7 +569,7 @@ export const Terminal = (props: TerminalProps) => {
fail(err)
return undefined
})
if (once.value) return
if (!ticket || once.value) return
if (disposed) return
const socket = new WebSocket(
@@ -589,10 +579,6 @@ export const Terminal = (props: TerminalProps) => {
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
)
socket.binaryType = "arraybuffer"
@@ -120,8 +120,7 @@ export function TabNavItem(props: {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
+3 -2
View File
@@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.client.session
sdk.api.session
.get({ sessionID: route.sessionId })
.then((x) => x.data)
.then(normalizeSessionInfo)
.catch(() => {}),
)
+11 -3
View File
@@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
serverSDK()
.api.file.find(
{
location: { directory: sdk().directory },
query,
type: dirs === "true" ? "directory" : "file",
limit: options?.limit,
},
{ signal: options?.signal },
)
.then(
(x) => (x.data ?? []).map(path.normalize),
(x) => x.data.map((entry) => path.normalize(entry.path)),
(error) => {
if (options?.signal?.aborted) throw error
return []
@@ -124,6 +124,7 @@ describe("bootstrapDirectory", () => {
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
})
})
describe("query keys", () => {
@@ -1,10 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AgentListOutput,
ModelDefaultOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
+8 -2
View File
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
@@ -126,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
@@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
void serverSdk()
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
.api.project.update({ projectID: project.id, icon: { color } })
.then((result) =>
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
),
)
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
+23 -16
View File
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
acceptKey,
directoryAcceptKey,
@@ -211,7 +212,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
)
function enableConfiguredDirectory(directory: string) {
if (input.sdk.protocolKind() !== "v1") return
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
if (childStore.config.permission !== "allow") return
@@ -244,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
const respond: PermissionRespondFn = (request) => {
if (meta.disposed) return
input.sdk.client.permission.respond(request).catch(() => {
responded.delete(request.permissionID)
})
input.sdk.api.permission
.reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response })
.catch(() => {
responded.delete(request.permissionID)
})
}
const list = async (directory: string) => {
if ((await input.sdk.protocol) === "v1") {
return (await input.sdk.client.permission.list({ directory })).data ?? []
}
return input.sdk.api.permission.request
.list({ location: { directory } })
.then((result) => result.data.map(normalizePermissionRequest))
}
function respondOnce(permission: PermissionRequest, directory?: string) {
@@ -344,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
for (const permission of permissions) {
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
}
})
.catch(() => undefined)
@@ -378,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
input.sdk.client.permission
.list({ directory })
.then((x) => {
list(directory)
.then((permissions) => {
if (meta.disposed) return
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
for (const permission of permissions) {
void respondPending(
perm,
permission,
directory,
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
)
+7 -8
View File
@@ -53,14 +53,13 @@ describe("coalesceServerEvents", () => {
})
test("merges adjacent current text deltas", () => {
const current = (id: string, value: string) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const current = (id: string, value: string) => adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "hello ") },
{ directory: "/repo", payload: current("evt_2", "world") },
+7 -36
View File
@@ -3,16 +3,16 @@ import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js"
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
import { detectServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi } from "@/utils/server-compat"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -164,27 +164,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
protocol: Promise<ServerProtocol>
protocolKind: Accessor<ServerProtocol | undefined>
url: string
client: ReturnType<typeof createSdkForServer>
api: CompatibleApi
currentApi: ServerApi
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
start: () => Promise<void> | undefined
}
createClient: (
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
) => ReturnType<typeof createSdkForServer>
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@@ -206,10 +186,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
(value) => value,
)
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
@@ -338,7 +314,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
fetch: platform.fetch,
throwOnError: true,
})
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const currentApi = createApiForServer({ server: server.http, fetch: platform.fetch })
const legacy = (directory?: string) =>
createSdkForServer({
server: server.http,
@@ -352,7 +328,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
server,
scope,
protocol,
protocolKind,
url: server.http.url,
client: sdk,
api,
@@ -372,6 +347,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
@@ -400,11 +376,6 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
},
})
export function useServerProtocol() {
const serverSDK = useServerSDK()
return createMemo(() => serverSDK().protocolKind())
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
@@ -25,12 +25,7 @@ describe("v2 session reducer", () => {
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
})
apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } })
apply({
...base,
id: "evt_step",
@@ -141,15 +136,12 @@ describe("v2 session reducer", () => {
})
test("requests hydration when promotion admission was missed", () => {
const result = createV2SessionReducer().reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = createV2SessionReducer().reduce([], event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}))
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
@@ -103,63 +103,40 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
case "session.shell.ended":
return updateMessage<Shell>(
source,
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
(item) => ({
...item,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...item.time, completed: event.created },
}),
sessionID,
)
return updateMessage<Shell>(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({
...item,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...item.time, completed: event.created },
}), sessionID)
case "session.step.started": {
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
const completed =
current && current.id !== event.data.assistantMessageID
? update(source, current.id, (item) =>
item.type === "assistant"
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
: item,
)
: [...source]
const completed = current && current.id !== event.data.assistantMessageID
? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item)
: [...source]
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
if (existing?.type === "assistant")
return result(
update(completed, existing.id, (item) =>
item.type === "assistant"
? {
...item,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
time: { ...item.time, completed: undefined },
}
: item,
),
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
)
return result(
[
...completed,
{
id: event.data.assistantMessageID,
type: "assistant",
metadata: event.metadata,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
},
],
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
)
return result(update(completed, existing.id, (item) => item.type === "assistant" ? {
...item,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
time: { ...item.time, completed: undefined },
} : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id])
return result([...completed, {
id: event.data.assistantMessageID,
type: "assistant",
metadata: event.metadata,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
}], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID])
}
case "session.step.ended":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
@@ -167,10 +144,9 @@ export function createV2SessionReducer() {
finish: event.data.finish,
cost: event.data.cost,
tokens: event.data.tokens,
snapshot:
event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.step.failed":
@@ -181,10 +157,9 @@ export function createV2SessionReducer() {
retry: undefined,
cost: event.data.cost ?? item.cost,
tokens: event.data.tokens ?? item.tokens,
snapshot:
event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
snapshot: event.data.snapshot || event.data.files
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
: item.snapshot,
time: { ...item.time, completed: event.created },
}))
case "session.text.started":
@@ -213,46 +188,29 @@ export function createV2SessionReducer() {
}),
}))
case "session.reasoning.delta":
return updateContent(
source,
event.data.assistantMessageID,
sessionID,
"reasoning",
event.data.ordinal,
(item) => ({
...item,
text: item.text + event.data.delta,
}),
)
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: item.text + event.data.delta,
}))
case "session.reasoning.ended":
return updateContent(
source,
event.data.assistantMessageID,
sessionID,
"reasoning",
event.data.ordinal,
(item) => ({
...item,
text: event.data.text,
state: event.data.state ?? item.state,
time: { created: item.time?.created ?? event.created, completed: event.created },
}),
)
return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({
...item,
text: event.data.text,
state: event.data.state ?? item.state,
time: { created: item.time?.created ?? event.created, completed: event.created },
}))
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
? item.content
: [
...item.content,
{
type: "tool",
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
},
],
: [...item.content, {
type: "tool",
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
}],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
@@ -337,21 +295,12 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
case "session.compaction.delta":
return updateMessage<Extract<Compaction, { status: "running" }>>(
source,
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
(item) => ({
...item,
summary: item.summary + event.data.text,
}),
sessionID,
)
return updateMessage<Extract<Compaction, { status: "running" }>>(source, (item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running", (item) => ({
...item,
summary: item.summary + event.data.text,
}), sessionID)
case "session.compaction.ended": {
const current = source.findLast(
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
)
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
if (!current)
return append({
id: messageID(event.id),
@@ -363,22 +312,16 @@ export function createV2SessionReducer() {
recent: event.data.recent,
time: { created: event.created },
})
return result(
update(source, current.id, () => ({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})),
[current.id],
)
return result(update(source, current.id, () => ({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})), [current.id])
}
case "session.compaction.failed": {
const current = source.findLast(
(item): item is Extract<Compaction, { status: "running" }> =>
item.type === "compaction" && item.status === "running",
)
const current = source.findLast((item): item is Extract<Compaction, { status: "running" }> => item.type === "compaction" && item.status === "running")
const failed: Extract<Compaction, { status: "failed" }> = {
id: current?.id ?? event.data.inputID ?? messageID(event.id),
type: "compaction",
@@ -389,10 +332,7 @@ export function createV2SessionReducer() {
time: current?.time ?? { created: event.created },
}
if (!current) return append(failed)
return result(
update(source, current.id, () => failed),
[failed.id],
)
return result(update(source, current.id, () => failed), [failed.id])
}
default:
return
@@ -422,7 +362,7 @@ function update(
id: string,
apply: (item: SessionMessageInfo) => SessionMessageInfo,
) {
return source.map((item) => (item.id === id ? apply(item) : item))
return source.map((item) => item.id === id ? apply(item) : item)
}
function updateMessage<T extends SessionMessageInfo>(
@@ -433,11 +373,7 @@ function updateMessage<T extends SessionMessageInfo>(
): V2SessionReduction {
const current = source.findLast(matches)
if (!current) return { sessionID, messages: [...source], touched: [] }
return {
sessionID,
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
touched: [current.id],
}
return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] }
}
function updateAssistant(
@@ -448,7 +384,7 @@ function updateAssistant(
): V2SessionReduction {
return {
sessionID,
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item),
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
}
}
@@ -459,9 +395,7 @@ function updateContent<T extends "text" | "reasoning">(
sessionID: string,
type: T,
ordinal: number,
apply: (
item: Extract<Assistant["content"][number], { type: T }>,
) => Extract<Assistant["content"][number], { type: T }>,
apply: (item: Extract<Assistant["content"][number], { type: T }>) => Extract<Assistant["content"][number], { type: T }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => {
let index = -1
@@ -480,13 +414,11 @@ function updateTool(
messageID: string,
callID: string,
sessionID: string,
apply: (
item: Extract<Assistant["content"][number], { type: "tool" }>,
) => Extract<Assistant["content"][number], { type: "tool" }>,
apply: (item: Extract<Assistant["content"][number], { type: "tool" }>) => Extract<Assistant["content"][number], { type: "tool" }>,
) {
return updateAssistant(source, messageID, sessionID, (assistant) => ({
...assistant,
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item),
}))
}
+6 -1
View File
@@ -11,7 +11,12 @@ import type {
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import {
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
+2 -7
View File
@@ -230,7 +230,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
migrationApplied: false,
previous: undefined as string | undefined,
})
const [newLayoutRequired, setNewLayoutRequired] = createSignal(false)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -251,7 +250,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
const layoutTransition = createMemo(() =>
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
)
const preferredNewLayout = createMemo(() => {
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
if (!layoutTransitionClassified()) {
@@ -267,7 +266,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
)
})
const newLayoutDesigns = createMemo(() => newLayoutRequired() || preferredNewLayout())
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
if (sunset && !oldInterfaceRetired()) {
@@ -406,12 +404,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "mobileTitlebarPosition", value)
},
newLayoutDesigns,
newLayoutRequired,
setNewLayoutRequired,
setNewLayoutDesigns(value: boolean) {
if (newLayoutRequired() && !value) return
const next = oldInterfaceRetired() ? true : value
if (preferredNewLayout() === next) return
if (newLayoutDesigns() === next) return
setStore("general", "newLayoutDesigns", next)
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
},
+17 -15
View File
@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue,
legacySessionID?: string,
) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
@@ -240,15 +241,16 @@ function createWorkspaceTerminalSession(
})
onCleanup(unsub)
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const update = (pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
client.pty
sdk.api.pty
.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
@@ -261,12 +263,13 @@ function createWorkspaceTerminalSession(
})
}
const clone = async (client: DirectorySDK["client"], id: string) => {
const clone = async (id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await client.pty
const next = await sdk.api.pty
.create({
location,
title: pty.title,
})
.catch((error: unknown) => {
@@ -308,17 +311,17 @@ function createWorkspaceTerminalSession(
const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
sdk.api.pty
.create({ location, title: defaultTitle(nextNumber) })
.then((pty) => {
const id = pty.data.id
if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest)
return
}
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
title: pty.data.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
batch(() => {
@@ -335,7 +338,7 @@ function createWorkspaceTerminalSession(
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty)
update(pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -350,10 +353,9 @@ function createWorkspaceTerminalSession(
})
},
async clone(id: string) {
await clone(sdk.client, id)
await clone(id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -361,10 +363,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty)
update(pty)
},
async clone(id: string) {
await clone(client, id)
await clone(id)
},
}
},
@@ -412,7 +414,7 @@ function createWorkspaceTerminalSession(
})
}
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
@@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => undefined,
archive: async () => undefined,
remove: () => {
removed = true
},
@@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", directory: "/workspace" },
update: async () => Promise.reject(failure),
archive: async () => Promise.reject(failure),
remove: () => {
removed = true
},
+2 -12
View File
@@ -6,25 +6,15 @@ type HomeSession = {
directory: string
}
type SessionUpdate = {
directory: string
sessionID: string
time: { archived: number }
}
export async function archiveHomeSession(input: {
server: ServerConnection.Key
session: HomeSession
update: (value: SessionUpdate) => Promise<unknown>
archive: (sessionID: string) => Promise<unknown>
remove: () => void
onError?: (error: unknown) => void
}) {
await input
.update({
directory: input.session.directory,
sessionID: input.session.id,
time: { archived: Date.now() },
})
.archive(input.session.id)
.then(() => {
input.remove()
notifySessionTabsRemoved({
File diff suppressed because it is too large Load Diff
@@ -1,265 +0,0 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { useGlobal } from "@/context/global"
import { type HomeProjectSelection, type LocalProject, useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { useTabs } from "@/context/tabs"
import {
closeHomeProject,
errorMessage,
homeProjectDirectories,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
export function createHomeProjectsController() {
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const server = useServer()
const language = useLanguage()
const global = useGlobal()
const tabs = useTabs()
const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false })
const [menu, setMenu] = createStore({ open: undefined as string | undefined })
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const [state] = createResource(
() => ready.promise ?? Promise.resolve(),
(promise) => promise.then(() => _state),
{ initialValue: _state },
)
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return undefined
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
createEffect(() => {
const id = menu.open
if (!id) return
const connections = global.servers.list()
const valid = connections.some((conn) => {
if (serverMenuID(conn) === id) return true
if (
connections.length > 1 &&
(global.servers.health[ServerConnection.key(conn)]?.healthy !== true || collapsed(conn))
)
return false
const list = connections.length === 1 ? projects() : projectsForServer(conn)
return list.some((project) => projectMenuID(conn, project.worktree) === id)
})
if (!valid) setMenu("open", undefined)
})
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
}
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (global.servers.health[key]?.healthy === false) return
if (
!global
.ensureServerCtx(conn)
.projects.list()
.some((project) => project.worktree === directory)
)
return
setSelection(toggleHomeProjectSelection(selection(), key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.ensureServerCtx(conn)
directories.forEach((item) => ctx.projects.open(item))
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.ensureServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => {
void dialog.show(() => <DialogEditProjectV2 server={conn} project={project} />)
})
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
}
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn))
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
}
function chooseProject(conn: ServerConnection.Any) {
if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: (result) => addProjects(conn, homeProjectDirectories(result)),
})
}
function closeProject(conn: ServerConnection.Any, directory: string) {
const next = closeHomeProject(
selection(),
ServerConnection.key(conn),
global.ensureServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}
function moveProject(conn: ServerConnection.Any, worktree: string, index: number) {
global.ensureServerCtx(conn).projects.move(worktree, index)
}
function revealProject(conn: ServerConnection.Any, project: LocalProject) {
if (!platform.openPath || !canRevealProject(conn)) return
platform.openPath(project.worktree).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
)
}
function canRevealProject(conn: ServerConnection.Any) {
return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn)
}
function projectsForServer(conn: ServerConnection.Any) {
return global.ensureServerCtx(conn).projects.list()
}
function collapsed(conn: ServerConnection.Any) {
return state().collapsed[ServerConnection.key(conn)] ?? false
}
function serverMenuID(conn: ServerConnection.Any) {
return `server:${ServerConnection.key(conn)}`
}
function projectMenuID(conn: ServerConnection.Any, directory: string) {
return `project:${ServerConnection.key(conn)}:${directory}`
}
return {
language,
selection,
focusedServer,
focusedServerCtx,
focusedSync,
projects,
recentlyClosed,
homedir,
selectedProject,
newSessionProject,
servers: global.servers.list,
serverHealth: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)],
projectsForServer,
collapsed,
toggleCollapsed: (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key])
},
menuOpen: (id: string) => menu.open === id,
setMenuOpen: (id: string, open: boolean) => setMenu("open", open ? id : undefined),
serverMenuID,
projectMenuID,
canDefaultServer: serverManagement.canDefault,
isDefaultServer: (conn: ServerConnection.Any) => serverManagement.defaultKey() === ServerConnection.key(conn),
setDefaultServer: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
removeServer: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
openEditServer: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focusServer,
selectProject,
addProjects,
openNewSession,
openProjectNewSession,
editProject,
unseenCount,
clearNotifications,
chooseProject,
closeProject,
moveProject,
canRevealProject,
revealProject,
fileManagerActionLabel: () =>
language.t(fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel),
openSettings,
openHelp: () => platform.openLink("https://opencode.ai/desktop-feedback"),
}
}
export type HomeProjectsController = ReturnType<typeof createHomeProjectsController>
@@ -1,548 +0,0 @@
import { type Accessor, createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Icon as IconV2 } 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 { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]`
export type HomeProjectsViewProps = {
language: ReturnType<typeof useLanguage>
servers: Accessor<ServerConnection.Any[]>
projects: Accessor<LocalProject[]>
recentlyClosed: Accessor<LocalProject[]>
selection: Accessor<HomeProjectSelection>
homedir: Accessor<string>
serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
menuOpen: (id: string) => boolean
canDefaultServer: Accessor<boolean>
isDefaultServer: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
fileManagerActionLabel: Accessor<string>
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
serverMenuID: (server: ServerConnection.Any) => string
projectMenuID: (server: ServerConnection.Any, directory: string) => string
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
onToggleCollapsed: (server: ServerConnection.Any) => void
onEditServer: (server: ServerConnection.Http) => void
onSetDefaultServer: (server: ServerConnection.Any | undefined) => void
onRemoveServer: (server: ServerConnection.Any) => void
onSetMenuOpen: (id: string, open: boolean) => void
onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
onCloseProject: (server: ServerConnection.Any, directory: string) => void
onOpenSettings: () => void
onOpenHelp: () => void
}
export function HomeProjectsView(props: HomeProjectsViewProps) {
return (
<aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
<Show
when={props.servers().length === 1 && !(props.projects().length === 0 && props.recentlyClosed().length > 0)}
>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
disabled={props.serverHealth(props.servers()[0])?.healthy === false}
onClick={() => props.onChooseProject(props.servers()[0])}
aria-label={props.language.t("home.project.add")}
/>
</TooltipV2>
</Show>
</div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
<Show
when={props.servers().length > 1}
fallback={
<div class="pr-3">
<Show
when={props.projects().length > 0}
fallback={<HomeProjectEmpty {...props} server={props.servers()[0]} items={props.recentlyClosed()} />}
>
<HomeProjectList {...props} server={props.servers()[0]} items={props.projects()} />
</Show>
</div>
}
>
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={props.servers()}>
{(item) => {
const projects = () => props.projectsForServer(item)
const healthy = () => !!props.serverHealth(item)?.healthy
const hasProjects = () => projects().length > 0
const collapsed = () => props.collapsed(item)
return (
<div class="flex min-w-0 flex-col gap-1">
<HomeServerRow
server={item}
{...props}
selected={props.selection().server === ServerConnection.key(item) && !props.selection().directory}
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={healthy() && hasProjects() && !collapsed()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} items={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex"
onOpenSettings={props.onOpenSettings}
onOpenHelp={props.onOpenHelp}
language={props.language}
/>
</aside>
)
}
export function HomeUtilityNav(props: {
class?: string
onOpenSettings: () => void
onOpenHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenSettings}
>
<IconV2 name="settings-gear" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.onOpenHelp}
>
<IconV2 name="help" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("sidebar.help")}</span>
</button>
</div>
)
}
function HomeServerRow(props: {
language: HomeProjectsViewProps["language"]
projectsForServer: HomeProjectsViewProps["projectsForServer"]
menuOpen: HomeProjectsViewProps["menuOpen"]
canDefaultServer: HomeProjectsViewProps["canDefaultServer"]
isDefaultServer: HomeProjectsViewProps["isDefaultServer"]
serverMenuID: HomeProjectsViewProps["serverMenuID"]
onFocusServer: HomeProjectsViewProps["onFocusServer"]
onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"]
onEditServer: HomeProjectsViewProps["onEditServer"]
onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"]
onRemoveServer: HomeProjectsViewProps["onRemoveServer"]
onSetMenuOpen: HomeProjectsViewProps["onSetMenuOpen"]
onChooseProject: HomeProjectsViewProps["onChooseProject"]
server: ServerConnection.Any
selected: boolean
collapsed: boolean
health: ServerHealth | undefined
}) {
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0
const menuID = () => props.serverMenuID(props.server)
return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined}
disabled={!healthy()}
onClick={() => props.onFocusServer(props.server)}
>
<span
data-action="home-server-collapse"
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
"cursor-default opacity-40": !canToggle(),
}}
aria-label={
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}
aria-disabled={!canToggle()}
aria-expanded={canToggle() ? !props.collapsed : undefined}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!canToggle()) return
props.onToggleCollapsed(props.server)
}}
onPointerDown={(event) => event.preventDefault()}
>
<IconV2
name="chevron-down"
size="small"
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} />
</div>
<span class="flex min-w-0 items-center gap-1">
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
<Show when={props.server.label}>
{(label) => (
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{label()}
</span>
)}
</Show>
</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<ServerRowMenuView
server={props.server}
labels={serverMenuLabels(props.language)}
canDefault={props.canDefaultServer()}
isDefault={props.isDefaultServer(props.server)}
onEdit={props.onEditServer}
onSetDefault={() => props.onSetDefaultServer(props.server)}
onRemoveDefault={() => props.onSetDefaultServer(undefined)}
onRemove={() => props.onRemoveServer(props.server)}
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
/>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("home.project.add")}>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
disabled={props.health?.healthy === false}
onClick={() => props.onChooseProject(props.server)}
/>
</TooltipV2>
</div>
</div>
)
}
type HomeProjectListProps = HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
}
function HomeProjectList(props: HomeProjectListProps) {
let listRef!: HTMLDivElement
return (
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: (event) =>
event.pointerType === "touch"
? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"),
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index)
if (props.selection().server !== ServerConnection.key(props.server))
props.onSelectProject(props.server, source.id.toString())
}}
>
<div class="flex min-w-0 flex-col gap-1" ref={listRef}>
{/* Keyed on worktree strings: the enriched project objects are
recreated on every store or sync update, so iterating them directly
remounts all rows — killing any in-flight drag activation (the
row's sortable unregisters on unmount) and discarding animations.
String keys keep row elements alive and move them on reorder. */}
<For each={props.items.map((project) => project.worktree)}>
{(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
</For>
</div>
</DragDropProvider>
)
}
function HomeProjectSlot(
props: HomeProjectListProps & {
worktree: string
index: () => number
},
) {
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
{...props}
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseen={props.unseenCount(props.server, item())}
/>
)}
</Show>
)
}
function HomeProjectEmpty(
props: HomeProjectsViewProps & {
server: ServerConnection.Any
items: LocalProject[]
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
return (
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
data-action="home-add-project-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
disabled={unreachable()}
onClick={() => props.onChooseProject(props.server)}
>
<IconV2 name="folder-add-left" size="small" />
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
</button>
<Show when={props.items.length > 0}>
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
</div>
<For each={props.items}>
{(project) => <HomeRecentlyClosedRow {...props} project={project} server={props.server} />}
</For>
</Show>
</div>
)
}
function HomeRecentlyClosedRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
},
) {
const unreachable = () => props.serverHealth(props.server)?.healthy === false
const path = () => {
const home = props.homedir()
const worktree = props.project.worktree
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
return worktree
}
return (
<TooltipV2 placement="right" value={path()}>
<button
type="button"
data-component="home-recently-closed-row"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={unreachable()}
onClick={() => props.onAddProjects(props.server, [props.project.worktree])}
>
<HomeProjectAvatar project={props.project} outline />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
</TooltipV2>
)
}
function HomeProjectRow(
props: HomeProjectsViewProps & {
project: LocalProject
server: ServerConnection.Any
index: () => number
serverSelected: boolean
selected: boolean
unseen: number
},
) {
const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false
const sortable = useSortable({
get id() {
return props.project.worktree
},
get index() {
return props.index()
},
})
let pointerDownSelected: boolean | undefined
const menuID = () => props.projectMenuID(props.server, props.project.worktree)
return (
<div
ref={sortable.ref}
class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
classList={{ "z-10": sortable.isDragSource() }}
>
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
disabled={serverUnreachable()}
onPointerDown={(event) => {
// Same-server mouse selection happens on pointerdown (like tabs),
// but only ever selects; selectProject toggles, and deselecting here
// would fire on every drag before the threshold is met. Cross-server
// selection waits for click so reordering a remote server's projects
// does not focus that server and load its session index. Touch is
// excluded so flick-scrolling the list cannot select rows.
pointerDownSelected = undefined
if (event.button !== 0 || event.pointerType === "touch") return
if (!props.serverSelected) return
pointerDownSelected = props.selected
if (!props.selected) props.onSelectProject(props.server, props.project.worktree)
}}
onClick={(event) => {
// The drag sensor calls preventDefault on post-drag clicks; never
// toggle selection as part of a reorder.
if (event.defaultPrevented) return
// Keyboard activation and touch taps keep the original toggle.
if (event.detail === 0 || pointerDownSelected === undefined) {
props.onSelectProject(props.server, props.project.worktree)
return
}
// Mouse: pointerdown already selected unselected rows; a plain click
// on an already-selected row toggles it off.
if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree)
pointerDownSelected = undefined
}}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
</button>
<div
class="hover-reveal absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/project:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={props.menuOpen(menuID())}
>
<MenuV2
gutter={6}
modal={false}
placement="bottom-end"
open={props.menuOpen(menuID())}
onOpenChange={(open) => props.onSetMenuOpen(menuID(), open)}
>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</MenuV2.Item>
<Show when={props.canRevealProject(props.server)}>
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
{props.fileManagerActionLabel()}
</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={props.unseen === 0}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.onCloseProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}
/>
</div>
</div>
)
}
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
const name = createMemo(() => displayName(props.project))
return (
<ProjectAvatar
fallback={name()}
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
/>
)
}
@@ -1,578 +0,0 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import {
type Accessor,
createEffect,
createMemo,
createRoot,
createSignal,
type JSX,
on,
onCleanup,
startTransition,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useCommand } from "@/context/command"
import {
loadHomeSessionIndex,
retainHomeSessions,
type HomeSessionEvents,
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { archiveHomeSession } from "../home-session-archive"
import type { HomeProjectsController } from "./home-projects-controller"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
export type HomeSessionRecord = {
session: Session
project: LocalProject
projectName: string
}
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
export type OpenSessionOptions = { background?: boolean }
export function createHomeSessionsController(projects: HomeProjectsController) {
const tabs = useTabs()
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const marked = useMarked()
const [state, setState] = createStore({ search: "", searchFocused: false, active: "" })
const [thumbTrack, setThumbTrack] = createSignal<HTMLDivElement>()
const [hoverTarget, setHoverTarget] = createSignal<HTMLElement>()
let viewport: HTMLDivElement | undefined
let searchRoot: HTMLDivElement | undefined
let searchInput: HTMLInputElement | undefined
let searchList: HTMLDivElement | undefined
const search = createMemo(() => state.search.trim())
const projectDirectories = createMemo(() => {
const project = projects.selectedProject()
if (!project) return projects.projects().flatMap(directories)
return directories(project)
})
const projectByID = createMemo(
() => new Map(projects.projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const homeSessions = () => projects.focusedSync().homeSessions
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!projects.focusedServerCtx(),
queryFn: async ({ signal }) => {
const ctx = projects.focusedServerCtx()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
projectDirectories,
projects: projects.projects,
projectByID,
}),
)
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const searchResults = createMemo(() => {
const query = search().toLowerCase()
if (!query) return []
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
})
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const searchPlaceholder = createMemo(() => {
const project = projects.selectedProject()
if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) })
if (projects.servers().length > 1) {
const conn = projects.focusedServer()
if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) })
}
return language.t("home.sessions.search.placeholder")
})
const groups = createMemo(() => groupSessions(records(), language))
const header = createHomeSessionHeaderController(groups)
const prefetched = new Set<string>()
createEffect(() => {
const ctx = projects.focusedServerCtx()
const conn = projects.focusedServer()
if (!ctx || !conn) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
void ctx.sync.session
.sync(record.session.id)
.then(() =>
Promise.all(
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
),
)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
createEffect(() => syncActive(searchResults()))
createEffect(
on(
() => state.search,
() => syncActive(searchResults()),
),
)
onCleanup(
makeEventListener(document, "pointerdown", (event) => {
if (!searchOpen()) return
const target = event.target
if (!(target instanceof Node) || searchRoot?.contains(target)) return
closeSearch()
}),
)
command.register("home", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const conn = projects.focusedServer()
if (!conn) return
const ctx = projects.focusedServerCtx()
if (!ctx) return
const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2")
void dialog.show(() => (
<DialogHomeCommandPaletteV2
server={conn}
onSelectSession={(entry) => {
if (!entry.sessionID || !entry.directory || !entry.server) return
const sessionID = entry.sessionID
const server = entry.server
const directory = entry.project?.worktree ?? entry.directory
ctx.projects.open(directory)
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
tabs.select(tab)
})
}}
/>
))
},
},
{
id: "home.sessions.search.focus",
title: searchPlaceholder(),
keybind: "mod+f",
hidden: true,
onSelect: focusSearch,
},
])
function syncActive(results: HomeSessionRecord[]) {
if (results.length === 0) {
setState("active", "")
return
}
if (!results.some((record) => homeSessionSearchKey(record) === state.active)) {
setState("active", homeSessionSearchKey(results[0]))
}
}
function focusSearch() {
searchInput?.focus()
setState("searchFocused", true)
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
}
function moveActive(delta: number) {
const results = searchResults()
if (results.length === 0) return
const index = results.findIndex((record) => homeSessionSearchKey(record) === state.active)
const next = ((index === -1 ? 0 : index) + delta + results.length) % results.length
setState("active", homeSessionSearchKey(results[next]))
searchList?.querySelector<HTMLElement>(`[data-key="${state.active}"]`)?.scrollIntoView({ block: "nearest" })
}
function selectSearchSession(record: HomeSessionRecord, options?: OpenSessionOptions) {
openSession(record.session, options)
if (!options?.background) closeSearch()
}
function openSession(session: Session, options?: OpenSessionOptions) {
const directoryKey = pathKey(session.directory)
const project =
projects
.projects()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
) ?? projectForSession(session, projects.projects(), projectByID())
const conn = projects.focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = projects.focusedServerCtx()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
})
}
async function archiveSession(session: Session) {
const conn = projects.focusedServer()
const ctx = projects.focusedServerCtx()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
update: (value) => ctx.sdk.client.session.update(value),
remove: () =>
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (item) => item.id)
if (match.found) draft.session.splice(match.index, 1)
}),
),
onError: (cause) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
}),
})
}
function setViewport(element: HTMLDivElement) {
viewport = element
header.setViewport(element)
}
function containWheel(event: WheelEvent) {
if (!viewport) return
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
return {
language,
records,
groups,
loading: () => sessionLoad.isLoading,
showProjectName: () => !projects.selectedProject(),
server: () => projects.selection().server,
canCreateSession: () => !!projects.newSessionProject(),
createSession: projects.openNewSession,
openSession,
archiveSession,
hasOpenTab: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, projects.selection().server, record.session),
header,
scroll: {
thumbTrack,
hoverTarget,
setThumbTrack,
setHoverTarget,
setViewport,
update: (scrollTop: number) => header.update(scrollTop),
containWheel,
containOuterWheel: (event: WheelEvent) => {
if (!viewport) return
if (event.target instanceof Node && viewport.contains(event.target)) return
containWheel(event)
},
},
search: {
value: () => state.search,
query: search,
placeholder: searchPlaceholder,
open: searchOpen,
loading: () => sessionLoad.isLoading,
results: searchResults,
active: () => state.active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: search() }),
setRoot: (element: HTMLDivElement) => (searchRoot = element),
setInput: (element: HTMLInputElement) => (searchInput = element),
setList: (element: HTMLDivElement) => (searchList = element),
input: (value: string) => setState("search", value),
focus: focusSearch,
close: closeSearch,
highlight: (record: HomeSessionRecord) => setState("active", homeSessionSearchKey(record)),
move: moveActive,
select: selectSearchSession,
selectActive: () => {
const record = searchResults().find((item) => homeSessionSearchKey(item) === state.active)
if (record) selectSearchSession(record)
},
},
}
}
function directories(project: LocalProject) {
return [project.worktree, ...(project.sandboxes ?? [])]
}
function buildHomeSessionRecords(input: {
sessions: () => Session[]
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
}) {
const directories = new Set(input.projectDirectories().map(pathKey))
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const directory = pathKey(session.directory)
const project =
input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projectForSession(session, input.projects(), input.projectByID())
if (!project) return []
return { session, project, projectName: displayName(project) }
})
}
function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
export function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")
return [
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
].filter((group) => group.sessions.length > 0)
}
function createHomeSessionHeaderController(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(element: HTMLDivElement) {
viewport = element
bindResizeObserver()
queuePositionUpdate()
}
function setContent(element: HTMLDivElement) {
content = element
bindResizeObserver()
queuePositionUpdate()
}
function setHeader(id: HomeSessionGroup["id"], element: HTMLDivElement) {
headerRefs.set(id, element)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((element) => element !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => {
const element = headerRefs.get(group.id)
if (element) headerOffsets.set(group.id, element.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(queuePositionUpdate)
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return {
setViewport,
setContent,
setHeader,
update,
titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1,
}
}
export type HomeSessionsController = ReturnType<typeof createHomeSessionsController>
export function HomeSessionStatusController(props: {
server: Accessor<ServerConnection.Key>
record: HomeSessionRecord
isOpenTab: (record: HomeSessionRecord) => boolean
render: (state: { unread: Accessor<boolean>; loading: Accessor<boolean>; open: Accessor<boolean> }) => JSX.Element
}) {
const avatar = useSessionTabAvatarState(
props.server,
() => props.record.session.directory,
() => props.record.session.id,
)
return props.render({
unread: avatar.unread,
loading: avatar.loading,
open: () => props.isOpenTab(props.record),
})
}
@@ -1,491 +0,0 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
homeSessionSearchKey,
type HomeSessionGroup,
type HomeSessionRecord,
type OpenSessionOptions,
} from "./home-sessions-controller"
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SEARCH_RESULT_ROW =
"flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_SEARCH_RESULT_TITLE =
"min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]"
const HOME_SEARCH_RESULT_META =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
function isBackgroundOpen(event: MouseEvent) {
return shouldOpenSessionInBackground({
button: event.button,
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
meta: event.metaKey,
ctrl: event.ctrlKey,
shift: event.shiftKey,
alt: event.altKey,
})
}
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
searchValue: Accessor<string>
searchPlaceholder: Accessor<string>
searchOpen: Accessor<boolean>
searchLoading: Accessor<boolean>
searchResults: Accessor<HomeSessionRecord[]>
searchActive: Accessor<string>
searchNoResultsLabel: Accessor<string>
titleOpacity: (id: HomeSessionGroup["id"]) => number
isOpenTab: (record: HomeSessionRecord) => boolean
onCreateSession: () => void
onOpenSession: (session: Session, options?: OpenSessionOptions) => void
onArchiveSession: (session: Session) => Promise<void>
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void
onWheel: (event: WheelEvent) => void
onSetSearchRoot: (element: HTMLDivElement) => void
onSetSearchInput: (element: HTMLInputElement) => void
onSetSearchList: (element: HTMLDivElement) => void
onSearchFocus: () => void
onSearchInput: (value: string) => void
onSearchClose: () => void
onSearchMove: (delta: number) => void
onSearchSelectActive: () => void
onSearchHighlight: (record: HomeSessionRecord) => void
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
return (
<section
ref={props.onSetHoverTarget}
class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={props.language.t("sidebar.project.recentSessions")}
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
ref={props.onSetThumbTrack}
data-component="home-session-scroll-track"
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading()}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
</div>
}
>
<Show
when={props.groups().length > 0}
fallback={
<HomeSessionsEmpty
onNewSession={props.canCreateSession() ? props.onCreateSession : undefined}
language={props.language}
/>
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
)
}
function HomeSessionLeadingController(props: {
server: HomeSessionsViewProps["server"]
isOpenTab: HomeSessionsViewProps["isOpenTab"]
record: HomeSessionRecord
revealProjectOnHover: boolean
}) {
return (
<HomeSessionStatusController
server={props.server}
record={props.record}
isOpenTab={props.isOpenTab}
render={(state) => (
<HomeSessionLeading
record={props.record}
revealProjectOnHover={props.revealProjectOnHover}
open={state.open()}
unread={state.unread()}
loading={state.loading()}
/>
)}
/>
)
}
function HomeSessionLeading(props: {
record: HomeSessionRecord
revealProjectOnHover: boolean
open: boolean
unread: boolean
loading: boolean
}) {
return (
<div class="relative shrink-0">
<Show when={props.open}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 4px)" }}
/>
</Show>
<SessionTabAvatarView
project={props.record.project}
directory={props.record.session.directory}
revealProjectOnHover={props.revealProjectOnHover}
unread={props.unread}
loading={props.loading}
/>
</div>
)
}
function HomeSessionSearch(props: HomeSessionsViewProps) {
return (
<div class="w-full">
<div ref={props.onSetSearchRoot} data-component="home-session-search" class="relative z-30 w-full">
<Show when={props.searchOpen()}>
<div
data-component="home-session-search-panel"
class="absolute flex flex-col overflow-hidden rounded-[12px] bg-v2-background-bg-base shadow-[var(--v2-elevation-floating)]"
style={{ top: "-6px", left: "-6px", width: "calc(100% + 12px)" }}
>
<div class="flex flex-col pt-9">
<div id={HOME_SESSION_SEARCH_RESULTS_ID} role="listbox" class="flex flex-col gap-4 pt-4">
<Show
when={!props.searchLoading()}
fallback={
<div class="flex items-center justify-center px-4 py-3 text-v2-text-text-muted [font-weight:440]">
<Spinner class="size-4" />
</div>
}
>
<Show
when={props.searchResults().length > 0}
fallback={
<p class="my-1.5 px-4 pb-2 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.searchNoResultsLabel()}
</p>
}
>
<div class="flex flex-col">
<p class="my-1.5 pl-[18px] pr-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.search.sessions")}
</p>
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
<div class="flex flex-col gap-px pb-2">
<For each={props.searchResults()}>
{(record) => (
<HomeSessionSearchResultRow
{...props}
record={record}
selected={props.searchActive() === homeSessionSearchKey(record)}
/>
)}
</For>
</div>
</ScrollView>
</div>
</Show>
</Show>
</div>
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={props.onSetSearchInput}
class="relative z-20 min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.searchValue()}
placeholder={props.searchPlaceholder()}
aria-label={props.searchPlaceholder()}
aria-expanded={props.searchOpen()}
aria-controls={HOME_SESSION_SEARCH_RESULTS_ID}
aria-autocomplete="list"
aria-activedescendant={
props.searchActive() && props.searchOpen()
? `home-session-search-option-${props.searchActive()}`
: undefined
}
onFocus={props.onSearchFocus}
onInput={(event) => props.onSearchInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
props.onSearchClose()
event.currentTarget.blur()
return
}
if (!props.searchOpen() || props.searchResults().length === 0) return
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
props.onSearchMove(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
props.onSearchMove(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
props.onSearchSelectActive()
}
}}
/>
<Show when={props.searchValue()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="relative z-20 shrink-0"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
aria-label={props.searchPlaceholder()}
onClick={() => {
props.onSearchClose()
props.onSearchFocus()
}}
/>
</Show>
</label>
</div>
</div>
)
}
function HomeSessionSearchResultRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
selected: boolean
},
) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
return (
<button
type="button"
id={`home-session-search-option-${key()}`}
data-key={key()}
data-component="home-session-search-row"
role="option"
aria-selected={props.selected}
classList={{
[HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}}
onMouseEnter={() => props.onSearchHighlight(props.record)}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onSearchSelect(props.record, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onSearchSelect(props.record, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<span class={HOME_SEARCH_RESULT_META}>{props.record.projectName}</span>
</Show>
</div>
</button>
)
}
function HomeSessionGroupHeader(props: {
title: string
titleOpacity: number
onSetRef: (element: HTMLDivElement) => void
elevated?: boolean
}) {
return (
<div
ref={props.onSetRef}
class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
>
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title}
</div>
</div>
)
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
<div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<button
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={showProjectName()}>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
</span>
</Show>
</button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
<IconButtonV2
data-action="home-session-archive"
variant="ghost-muted"
size="large"
icon={<IconV2 name="archive" />}
aria-label={props.language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.onArchiveSession(props.record.session)
}}
/>
</TooltipV2>
</div>
</Show>
</div>
)
}
function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType<typeof useLanguage> }) {
return (
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div class="shrink-0 text-[13px] leading-[13px] tracking-[-0.04px] text-v2-text-text-base [font-weight:530]">
{props.language.t("home.sessions.empty")}
</div>
<p class="mb-1 text-center text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]">
{props.language.t("home.sessions.empty.description")}
</p>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2 data-action="home-new-session" variant="neutral" size="normal" icon="edit" onClick={onNewSession()}>
{props.language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div>
)
}
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
</div>
</div>
)
}
-142
View File
@@ -1,142 +0,0 @@
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { type ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Logo } from "@opencode-ai/ui/logo"
import { useNavigate } from "@solidjs/router"
import { DateTime } from "luxon"
import { createMemo, For, Match, Switch } from "solid-js"
export function LegacyHome() {
const sync = useServerSync()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const navigate = useNavigate()
const global = useGlobal()
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync().data.path.home)
const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false)
const recent = createMemo(() => {
return sync()
.data.project.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
const serverDotClass = createMemo(() => {
const healthy = global.servers.health[server.key]?.healthy
if (healthy === true) return "bg-icon-success-base"
if (healthy === false) return "bg-icon-critical-base"
return "bg-border-weak-base"
})
function openProject(conn: ServerConnection.Any, directory: string) {
const serverCtx = global.ensureServerCtx(conn)
serverCtx.projects.open(directory)
serverCtx.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
function chooseProject() {
if (serverUnreachable()) return
const conn = server.current
if (!conn) return
const resolve = (result: string | string[] | null) => {
if (Array.isArray(result)) {
result.forEach((directory) => openProject(conn, directory))
return
}
if (result) openProject(conn, result)
}
pickDirectory({
server: conn,
title: language.t("command.project.open"),
multiple: true,
onSelect: resolve,
})
}
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={sync().data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button
icon="folder-add-left"
size="normal"
class="pl-2 pr-3"
disabled={serverUnreachable()}
onClick={chooseProject}
>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(server.current!, project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync().ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" disabled={serverUnreachable()} onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
</div>
)
}
+30 -33
View File
@@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { useNotification } from "@/context/notification"
@@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { listAllSessions } from "@/utils/session"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) {
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
})
await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) {
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK()
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
const listed = await Promise.resolve(
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
)
.then((value) => (typeof value === "string" ? value : value.id))
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
@@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await serverSDK()
.client.session.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
session: await listAllSessions(serverSDK().api.session, {
directory: item,
parentID: null,
order: "desc",
}).catch(() => []),
})),
),
Date.now(),
@@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
return
}
@@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await serverSDK()
.client.session.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
clearWorkspaceTerminals(
directory,
@@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) {
return
}
const archivedAt = Date.now()
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.api.session.archive({ sessionID: session.id, directory: session.directory })
.catch(() => undefined),
),
)
@@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
.api.vcs.status({ location: { directory: props.directory } })
.then((result) => {
const files = result.data
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
@@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await serverSDK()
.client.session.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const sessions = await listAllSessions(serverSDK().api.session, {
directory: props.directory,
order: "desc",
}).catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
}
onMount(() => {
serverSDK()
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
.api.vcs.status({ location: { directory: props.directory } })
.then((result) => {
const files = result.data
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
@@ -19,34 +19,16 @@ export function SessionTabAvatar(props: {
() => props.directory,
() => props.sessionId,
)
return (
<SessionTabAvatarView
project={props.project}
directory={props.directory}
revealProjectOnHover={props.revealProjectOnHover}
unread={state.unread()}
loading={state.loading()}
/>
)
}
export function SessionTabAvatarView(props: {
project?: LocalProject
directory: string
revealProjectOnHover?: boolean
unread: boolean
loading: boolean
}) {
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={props.unread}
unread={state.unread()}
/>
)
return (
<Show when={props.loading} fallback={projectAvatar()}>
<Show when={state.loading()} fallback={projectAvatar()}>
<span class="relative block size-4 shrink-0">
<SessionProgressIndicatorV2
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
@@ -45,7 +45,8 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
loading:
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
@@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
setStore("responding", perm.id)
sdk()
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
mutationFn: (answers: QuestionAnswer[]) =>
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
+1 -1
View File
@@ -235,7 +235,7 @@ export function SessionFileView(props: SessionFileViewProps) {
const source = props.diff
if (!source) return
const loaded = loadedDiff()
return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source)
return normalize(loaded && loaded.source === source && loaded.version === props.diffVersion ? loaded.value : source)
})
return (
@@ -1,6 +1,7 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -56,15 +57,16 @@ import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
function renderDiff(value: ReviewDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffs: () => ReviewDiff[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -283,6 +283,14 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
const visible = new Set(props.userMessages.map((message) => message.id))
const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
const messages = sync().data.session_message[id] ?? []
return boundary ? messages.filter((message) => message.id < boundary) : messages
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@@ -291,8 +299,7 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const protocol = useServerProtocol()
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -325,7 +332,7 @@ export function MessageTimeline(props: {
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
sessionMessages: projectedMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
@@ -665,8 +672,7 @@ export function MessageTimeline(props: {
}))
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().client.session.update({ sessionID: input.id, title: input.title }),
mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -810,7 +816,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.client.session.update({ sessionID, time: { archived: Date.now() } })
.api.session.archive({ sessionID })
.then(() => {
sync().set(
produce((draft) => {
@@ -839,8 +845,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.client.session.delete({ sessionID })
.then((x) => x.data)
.api.session.remove({ sessionID })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1,16 +1,14 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
@@ -30,47 +28,19 @@ export function createTimelineProjection(input: {
})
return result
})
const activeMessageID = createMemo(() => {
const parentID = input
.messages()
.findLast(
(message): message is AssistantMessage =>
message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
input.inlineComments(),
),
),
),
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.inlineComments(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
@@ -0,0 +1,92 @@
import { describe, expect, mock, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_3", type: "user", text: "second", time: { created: 4 } },
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
)
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
])
})
test("renders a current shell message as a standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "pwd",
status: "exited",
exit: 0,
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
)
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
])
})
})
@@ -1,4 +1,5 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -31,6 +32,47 @@ export type TimelineRowMap = {
}
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
inlineComments: boolean,
) {
const turns = messages.reduce<{ user: UserMessage; assistants: AssistantMessage[] }[]>((result, message) => {
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
result.push({ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] })
return result
}
if (projected?.role === "user") {
result.push({ user: projected, assistants: [] })
return result
}
if (projected?.role !== "assistant") return result
result.at(-1)?.assistants.push(projected)
return result
}, [])
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
@@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
@@ -19,7 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useServerProtocol } from "@/context/server-sdk"
import { useLocal } from "@/context/local"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -41,15 +40,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const protocol = useServerProtocol()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
@@ -186,7 +184,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const share = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -213,7 +210,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const unshare = async () => {
if (protocol() !== "v1") return
const sessionID = params.id
if (!sessionID) return
@@ -310,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const client = sdk().client
const session = sdk().api.session
const directory = sdk().directory
const promptSession = prompt.capture()
const revert = info()?.revert?.messageID
@@ -320,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const parts = sync().data.part[message.id]
if (sync().data.session_working(sessionID)) {
await client.session.abort({ sessionID }).catch(() => {})
await session.interrupt({ sessionID }).catch(() => {})
}
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.revert({ sessionID, messageID: message.id }),
request: () => session.revert.stage({ sessionID, messageID: message.id }),
updatePrompt: (promptSession) => {
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
},
@@ -338,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionID = params.id
if (!sessionID) return
const owner = sessionOwnership.capture()
const client = sdk().client
const session = sdk().api.session
const messages = userMessages()
const promptSession = prompt.capture()
@@ -350,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.unrevert({ sessionID }),
request: () => session.revert.clear({ sessionID }),
updatePrompt: (promptSession) => promptSession.reset(),
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
})
@@ -360,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
await runCommand({
owner,
prompt: promptSession,
request: () => client.session.revert({ sessionID, messageID: next.id }),
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
})
@@ -379,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return
}
await sdk().client.session.summarize({
await sdk().api.session.compact({
sessionID,
modelID: model.id,
providerID: model.provider.id,
model: { providerID: model.provider.id, modelID: model.id },
})
}
@@ -394,7 +389,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const shareCmds = () => {
if (protocol() !== "v1") return []
if (sync().data.config.share === "disabled") return []
return [
sessionCommand({
@@ -1,14 +1,15 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
@@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -30,7 +31,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@@ -9,7 +10,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
} satisfies FileDiffInfo & SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
+2 -1
View File
@@ -1,7 +1,8 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = SnapshotFileDiff | VcsFileDiff
type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
+7 -38
View File
@@ -2,10 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createApiForServer, createSdkForServer } from "./server"
import { createCompatibleApi } from "./server-compat"
function setup(
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
responses?: { vcs?: { branch: string; default_branch: string } },
) {
function setup(protocol: "v1" | "v2") {
const requests: Request[] = []
const fetcher = Object.assign(
async (input: string | URL | Request, init?: RequestInit) => {
@@ -24,7 +21,7 @@ function setup(
}
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
return new Response(undefined, { status: 204 })
if (request.method === "POST" && request.url.endsWith("/prompt")) {
if (request.method === "POST" && request.url.endsWith("/prompt")) {
return Response.json({
admittedSeq: 1,
id: "msg_1",
@@ -33,18 +30,16 @@ function setup(
type: "user",
data: { text: "hello" },
delivery: "steer",
})
}
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
return Response.json(responses?.vcs ?? {})
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
})
}
if (request.method === "GET") return Response.json([])
return new Response(undefined, { status: 204 })
},
{ preconnect: globalThis.fetch.preconnect },
)
const server = { url: "http://localhost:4096" }
const api = createCompatibleApi({
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
protocol: Promise.resolve(protocol),
current: createApiForServer({ server, fetch: fetcher }),
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
directory: "/repo",
@@ -91,36 +86,10 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("POST")
})
test("resolves protocol detection once across implementation methods", async () => {
let detections = 0
const resolved = Promise.resolve<"v1" | "v2">("v2")
const protocol = new Proxy(resolved, {
get(target, property) {
if (property !== "then") return Reflect.get(target, property, target)
detections++
return target.then.bind(target)
},
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
expect(detections).toBe(1)
})
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
data: { branch: "feature", defaultBranch: "dev" },
})
})
})
+48 -47
View File
@@ -37,12 +37,6 @@ type LegacyPrompt = {
variant?: string
}
type LegacyLocation = { directory?: string }
type CompatibleInput = {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}
function mime(uri: string) {
const match = /^data:([^;,]+)/.exec(uri)
@@ -74,48 +68,15 @@ function sessionInfo(session: Session): SessionInfo {
}
}
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
const v1 = createV1Api(input)
return lazyApi(
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
input.current,
)
}
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(shape, {
get(target, property, receiver) {
const sample = Reflect.get(target, property, receiver)
if (typeof sample === "function") {
return (...args: unknown[]) =>
implementation.then((value) => {
const method = Reflect.get(value, property)
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
return Reflect.apply(method, value, args)
})
}
if (sample === null || typeof sample !== "object") return sample
if (cache.has(property)) return cache.get(property)
const nested = lazyApi(
implementation.then((value) => {
const result = Reflect.get(value, property)
if (result === null || typeof result !== "object") {
throw new Error(`API namespace unavailable: ${String(property)}`)
}
return result
}),
sample,
)
cache.set(property, nested)
return nested
},
})
}
function createV1Api(input: CompatibleInput): CompatibleApi {
export function createCompatibleApi(input: {
protocol: Promise<ServerProtocol>
current: ServerApi
legacy: LegacyFor
directory?: string
}): CompatibleApi {
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
const located = <T>(data: T, value?: { directory?: string }) => ({
location: {
directory: directory(value) ?? "",
@@ -132,6 +93,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
value?: Parameters<ServerApi["session"]["list"]>[0],
options?: Parameters<ServerApi["session"]["list"]>[1],
) {
if (!(await isV1())) return input.current.session.list(value, options)
if (!value?.directory && value?.search !== undefined) {
const result = await legacy().experimental.session.list(
{
@@ -152,6 +114,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
},
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
if (!(await isV1())) return input.current.session.create(value)
const result = await legacy(value?.location ?? undefined).session.create({
directory: directory(value?.location ?? undefined),
})
@@ -159,11 +122,13 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
return sessionInfo(result.data)
},
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
if (!(await isV1())) return input.current.session.get(value)
const result = await legacy().session.get(value)
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
return sessionInfo(result.data)
},
async active() {
if (!(await isV1())) return input.current.session.active()
const result = await legacy().session.status()
return Object.fromEntries(
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
@@ -172,23 +137,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
)
},
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.rename(value)
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.archive(value)
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
if (!(await isV1())) return input.current.session.remove(value)
await legacy(value).session.delete(value)
},
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
if (!(await isV1())) return input.current.session.fork(value)
const result = await legacy().session.fork(value)
if (!result.data) throw new Error("Failed to fork session")
return sessionInfo(result.data)
},
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
if (!(await isV1())) return input.current.session.interrupt(value)
await legacy().session.abort(value)
},
async prompt(value: SessionPromptInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.prompt(value)
await legacy().session.promptAsync({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
@@ -223,6 +194,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
}
},
async command(value: SessionCommandInput) {
if (!(await isV1())) return input.current.session.command(value)
await legacy().session.command({
sessionID: value.sessionID,
messageID: value.id ?? undefined,
@@ -249,6 +221,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
}
},
async shell(value: SessionShellInput & LegacyPrompt) {
if (!(await isV1())) return input.current.session.shell(value)
await legacy().session.shell({
sessionID: value.sessionID,
command: value.command,
@@ -257,6 +230,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
})
},
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
if (!(await isV1())) return input.current.session.compact(value)
if (!value.model) throw new Error("A model is required to compact a V1 session")
await legacy().session.summarize({
sessionID: value.sessionID,
@@ -273,10 +247,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
revert: {
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.stage(value)
await legacy().session.revert(value)
return { messageID: value.messageID }
},
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
if (!(await isV1())) return input.current.session.revert.clear(value)
await legacy().session.unrevert(value)
},
commit: input.current.session.revert.commit,
@@ -285,14 +261,17 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
project: {
...input.current.project,
async list() {
if (!(await isV1())) return input.current.project.list()
return ((await legacy().project.list()).data ?? []) as Project[]
},
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
if (!(await isV1())) return input.current.project.current(value)
const result = await legacy(value?.location).project.current()
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
if (!(await isV1())) return input.current.project.update(value)
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
@@ -302,6 +281,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
return result.data as Project
},
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
if (!(await isV1())) return input.current.project.directories(value)
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
@@ -309,6 +289,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
if (!(await isV1())) return input.current.path.get(value)
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
@@ -317,14 +298,17 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
if (!(await isV1())) return input.current.vcs.get(value)
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
},
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
if (!(await isV1())) return input.current.vcs.status(value)
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
},
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
if (!(await isV1())) return input.current.vcs.diff(value)
const result = await legacy(value.location).vcs.diff({
mode: value.mode === "working" ? "git" : value.mode,
context: value.context,
@@ -344,10 +328,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
file: {
...input.current.file,
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
if (!(await isV1())) return input.current.file.list(value)
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
return located(result.data ?? [], value?.location)
},
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
if (!(await isV1())) return input.current.file.find(value)
const result = await legacy(value.location).find.files({
query: value.query,
type: value.type,
@@ -362,6 +348,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
integration: {
...input.current.integration,
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
if (!(await isV1())) return input.current.integration.get(value)
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
(method, index) =>
method.type === "api"
@@ -381,6 +368,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
connect: {
...input.current.integration.connect,
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
if (!(await isV1())) return input.current.integration.connect.key(value)
await legacy(value.location).auth.set({
providerID: value.integrationID,
auth: { type: "api", key: value.key },
@@ -390,6 +378,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
oauth: {
...input.current.integration.oauth,
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.connect(value)
const method = Number(value.methodID)
const result = await legacy(value.location).provider.oauth.authorize(
{ providerID: value.integrationID, method, inputs: value.inputs },
@@ -408,6 +397,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
)
},
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.complete(value)
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method, code: value.code },
@@ -415,6 +405,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
)
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
if (!(await isV1())) return input.current.integration.oauth.status(value)
const method = Number(value.attemptID.split(":").at(-1))
await legacy(value.location).provider.oauth.callback(
{ providerID: value.integrationID, method },
@@ -430,12 +421,15 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
if (!(await isV1())) return input.current.pty.shells(value)
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
if (!(await isV1())) return input.current.pty.list(value)
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
if (!(await isV1())) return input.current.pty.create(value)
const result = await legacy(value?.location).pty.create({
command: value?.command,
args: value?.args ? [...value.args] : undefined,
@@ -447,11 +441,13 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
return located(result.data, value?.location)
},
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
if (!(await isV1())) return input.current.pty.get(value)
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
return located(result.data, value.location)
},
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
if (!(await isV1())) return input.current.pty.update(value)
const result = await legacy(value.location).pty.update({
ptyID: value.ptyID,
title: value.title,
@@ -461,9 +457,11 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
return located(result.data, value.location)
},
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
if (!(await isV1())) return input.current.pty.remove(value)
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
if (!(await isV1())) return input.current.pty.connectToken(value)
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
@@ -472,6 +470,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
permission: {
...input.current.permission,
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
if (!(await isV1())) return input.current.permission.reply(value)
await legacy().permission.respond({
sessionID: value.sessionID,
permissionID: value.requestID,
@@ -482,12 +481,14 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
question: {
...input.current.question,
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
if (!(await isV1())) return input.current.question.reply(value)
await legacy().question.reply({
requestID: value.requestID,
answers: value.answers.map((answer) => [...answer]),
})
},
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
if (!(await isV1())) return input.current.question.reject(value)
await legacy().question.reject({ requestID: value.requestID })
},
},
@@ -42,19 +42,6 @@ describe("checkServerHealth", () => {
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("falls back when the current health response is malformed", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
paths.push(url.pathname)
if (url.pathname === "/api/health") return Response.json({})
return Response.json({ healthy: true, version: "1.18.4" })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
let timeoutMs = 0
+2 -6
View File
@@ -95,13 +95,9 @@ export async function checkServerHealth(
: undefined,
})
.health.get({ signal })
.then((x) =>
typeof x.healthy === "boolean"
? { data: { healthy: x.healthy, version: x.version } }
: { error: new Error("Invalid health response") },
)
.then((x) => ({ data: { healthy: x.healthy === true, version: x.version } }))
.catch((error) => ({ error }))
if ("data" in current && current.data) return current.data
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
return createSdkForServer({ server, fetch, signal })
+38 -4
View File
@@ -1,5 +1,23 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { OpenCode } from "@opencode-ai/client/promise"
import type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
FileApi,
IntegrationApi,
McpApi,
MessageApi,
PathApi,
PermissionApi,
ProjectApi,
PtyApi,
QuestionApi,
ReferenceApi,
SessionApi,
VcsApi,
} from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
@@ -41,10 +59,28 @@ export function createSdkForServer({
})
}
export interface ServerApi extends CatalogApi {
readonly agent: AgentApi
readonly command: CommandApi
readonly event: EventApi
readonly file: FileApi
readonly integration: IntegrationApi
readonly mcp: McpApi
readonly message: MessageApi
readonly path: PathApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly pty: PtyApi
readonly question: QuestionApi
readonly reference: ReferenceApi
readonly session: SessionApi
readonly vcs: VcsApi
}
export function createApiForServer(input: {
server: ServerConnection.HttpBase
fetch?: typeof globalThis.fetch
}): OpenCodeClient {
}): ServerApi {
return OpenCode.make({
baseUrl: input.server.url,
fetch: input.fetch,
@@ -58,5 +94,3 @@ export function createApiForServer(input: {
: undefined,
})
}
export type ServerApi = OpenCodeClient
@@ -122,7 +122,9 @@ describe("normalizeSessionMessages", () => {
expect.objectContaining({ id: "msg_shell", role: "user" }),
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
])
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
expect(result.parts.get("msg_shell")).toEqual([
expect.objectContaining({ type: "text", text: "printf hello" }),
])
expect(result.parts.get("msg_shell:assistant")).toEqual([
expect.objectContaining({
type: "tool",
@@ -2,51 +2,22 @@ import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
test("uses the current ticketed PTY route", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
sameOrigin: false,
username: "opencode",
password: "secret",
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/api/pty/pty_test/connect")
expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
expect(url.searchParams.get("cursor")).toBe("0")
expect(url.searchParams.get("ticket")).toBe("connect-ticket")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
authToken: true,
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})
@@ -1,28 +1,14 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
sameOrigin?: boolean
username?: string
password?: string
authToken?: boolean
ticket: string
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
const next = new URL(`${input.url}/api/pty/${input.id}/connect`)
next.searchParams.set("location[directory]", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
next.searchParams.set("ticket", input.ticket)
return next
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client"
import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createServerSessionEntries } from "@/components/command-palette"
import type { LocalProject } from "@/context/layout"
@@ -14,15 +15,16 @@ const stored: Project = {
time: { created: 1, updated: 1 },
}
const session: GlobalSession = {
const session: SessionInfo = {
id: "session-1",
slug: "session-1",
projectID: stored.id,
directory: stored.worktree,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: stored.worktree },
title: "Palette session",
version: "1",
time: { created: 1, updated: 2 },
project: { id: stored.id, name: stored.name, worktree: stored.worktree },
}
describe("command palette sessions", () => {
+1 -1
View File
@@ -72,7 +72,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
-254
View File
@@ -26,257 +26,3 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
expect(body?.prompt_cache_key).toBe("session-123")
})
test("Mistral round-trips native reasoning in assistant history", async () => {
let body: { messages?: unknown[] } | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "mistral-small-latest",
object: "chat.completion",
choices: [
{
index: 0,
message: {
role: "assistant",
content: [
{
type: "thinking",
thinking: [
{ type: "text", text: "The user is greeting me." },
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
{ type: "reference", reference_ids: [1, "source-2"] },
],
closed: true,
signature: "sig-123",
},
{ type: "text", text: "Hi" },
],
},
finish_reason: "stop",
},
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
const first = await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
})
const reasoning = first.content.find((part) => part.type === "reasoning")
const text = first.content.find((part) => part.type === "text")
if (!reasoning || !text) throw new Error("expected reasoning and text")
await model.doGenerate({
prompt: [
{ role: "user", content: [{ type: "text", text: "Hello" }] },
{
role: "assistant",
content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text],
},
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
],
})
expect(body?.messages?.[1]).toEqual({
role: "assistant",
content: [
{
type: "thinking",
thinking: [
{ type: "text", text: "The user is greeting me." },
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
{ type: "reference", reference_ids: [1, "source-2"] },
],
closed: true,
signature: "sig-123",
},
{ type: "text", text: "Hi" },
],
})
await model.doGenerate({
prompt: [
{ role: "user", content: [{ type: "text", text: "Hello" }] },
{
role: "assistant",
content: [
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hi" },
],
},
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
],
})
expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" })
})
test("Mistral preserves native reasoning metadata while streaming", async () => {
const chunks = [
{
id: "response-1",
created: 0,
model: "mistral-small-latest",
choices: [
{
index: 0,
delta: {
role: "assistant",
content: [
{
type: "thinking",
thinking: [
{ type: "text", text: "thinking" },
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
],
},
],
},
},
],
},
{
id: "response-1",
created: 0,
model: "mistral-small-latest",
choices: [
{
index: 0,
delta: {
content: [
{
type: "thinking",
thinking: [{ type: "reference", reference_ids: [1, "source-2"] }],
closed: true,
signature: "sig-123",
},
],
},
},
],
},
{
id: "response-1",
created: 0,
model: "mistral-small-latest",
choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }],
},
{
id: "response-1",
created: 0,
model: "mistral-small-latest",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
]
const mockFetch = Object.assign(
async () =>
new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
headers: { "Content-Type": "text/event-stream" },
}),
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
const result = await model.doStream({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
})
const events = []
for await (const event of result.stream) events.push(event)
expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({
mistral: {
thinking: {
type: "thinking",
thinking: [
{ type: "text", text: "thinking" },
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
{ type: "reference", reference_ids: [1, "source-2"] },
],
closed: true,
signature: "sig-123",
},
},
})
expect(
events
.filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta")
.every((event) => event.providerMetadata === undefined),
).toBe(true)
})
test("Mistral preserves metadata-only thinking chunks", async () => {
const thinking = {
type: "thinking" as const,
thinking: [
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
{ type: "reference", reference_ids: [1, "source-2"] },
],
closed: true,
signature: "sig-123",
}
const mockFetch = Object.assign(
async () =>
Response.json({
id: "response-1",
created: 0,
model: "mistral-small-latest",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
const result = await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
})
expect(result.content).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: { mistral: { thinking } },
},
])
})
+1 -1
View File
@@ -66,7 +66,7 @@
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/mistral": "3.0.34",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
-111
View File
@@ -906,117 +906,6 @@ describe("session.llm.stream", () => {
},
)
const mistralFixture = { providerID: "mistral", modelID: "mistral-small-latest" }
it.instance(
"replays native Mistral reasoning from chat history",
() =>
Effect.gen(function* () {
const fixture = loadFixture(mistralFixture.providerID, mistralFixture.modelID)
const request = waitRequest(
"/chat/completions",
createEventResponse(
[
{
id: "chatcmpl-mistral",
object: "chat.completion.chunk",
created: 0,
model: fixture.model.id,
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }],
},
{
id: "chatcmpl-mistral",
object: "chat.completion.chunk",
created: 0,
model: fixture.model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
},
],
true,
),
)
const resolved = yield* Provider.use.getModel(
ProviderV2.ID.make(mistralFixture.providerID),
ModelV2.ID.make(fixture.model.id),
)
const sessionID = SessionID.make("session-test-mistral-reasoning")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("msg_user-mistral-reasoning"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderV2.ID.make(mistralFixture.providerID), modelID: resolved.id },
} satisfies SessionV1.User
const thinking = {
type: "thinking",
thinking: [
{ type: "text", text: "thinking" },
{
type: "tool_reference",
tool: "web_search",
title: "Example result",
url: "https://example.com/tool",
favicon: "https://example.com/favicon.ico",
description: "Example description",
},
{ type: "reference", reference_ids: [1, "source-2"] },
],
closed: true,
signature: "sig-123",
}
yield* drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking",
providerOptions: { mistral: { thinking } },
},
{ type: "text", text: "Previous answer" },
],
},
{ role: "user", content: "Continue" },
] satisfies ModelMessage[],
tools: {},
})
const capture = yield* Effect.promise(() => request)
const messages = capture.body.messages as Array<Record<string, unknown>>
expect(messages.find((message) => message.role === "assistant")).toEqual({
role: "assistant",
content: [thinking, { type: "text", text: "Previous answer" }],
})
}),
{
config: () => ({
enabled_providers: [mistralFixture.providerID],
provider: {
[mistralFixture.providerID]: {
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
},
},
}),
},
)
const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
it.instance(
"service stream cancellation cancels provider response body promptly",
@@ -521,6 +521,7 @@ export function getToolInfo(
}
}
case "bash":
case "shell":
return {
icon: "console",
title: i18n.t("ui.tool.shell"),
@@ -538,6 +539,7 @@ export function getToolInfo(
title: i18n.t("ui.messagePart.title.write"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
case "patch":
case "apply_patch":
return {
icon: "code-lines",
@@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
if (tool === "bash" || tool === "shell") return shell
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
@@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
}
export function getTool(name: string) {
return state[name]?.render
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
}
export const ToolRegistry = {
@@ -2101,7 +2103,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "bash",
name: "shell",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
@@ -2337,7 +2339,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "apply_patch",
name: "patch",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
@@ -1,6 +1,7 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { parsePatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
type LegacyDiff = {
file: string
@@ -13,7 +14,7 @@ type LegacyDiff = {
}
type SnapshotDiff = SnapshotFileDiff & { file: string }
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
export type ViewDiff = {
@@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
@@ -62,10 +63,10 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string }
type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any>
}
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & {
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any>
}
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
@@ -4,6 +4,7 @@ import {
Message as MessageType,
Part as PartType,
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "@opencode-ai/ui/context/file"
@@ -90,7 +91,7 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
type SummaryDiff = SnapshotFileDiff & { file: string }
type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
@@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
}

Some files were not shown because too many files have changed in this diff Show More