feat(app): support current pty transport (#38463)

This commit is contained in:
Brendan Allan
2026-07-24 18:20:54 +08:00
committed by GitHub
parent 27bde01298
commit e64089b084
26 changed files with 641 additions and 157 deletions
+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.
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page, type Route } from "@playwright/test" import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible() await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") await page.keyboard.press("Control+,")
const dialog = page.locator(".settings-v2-dialog") const dialog = page.locator(".settings-v2-dialog")
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
await expect(page.getByText(sessionA.title).first()).toBeVisible() await expect(page.getByText(sessionA.title).first()).toBeVisible()
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") await page.keyboard.press("Control+,")
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
await autoAccept.locator('[data-slot="switch-control"]').click() await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(autoAccept.getByRole("switch")).toBeChecked() await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) 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 === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session/status") return json(route, {}) if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
if (url.pathname === "/session") return json(route, sessions) 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}`) const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current) if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -216,7 +242,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
directory, directory,
home: 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 === "/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, {}) return json(route, {})
}) })
} }
@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) { async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 }) await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: "proj_review_line_comment_regression", id: "proj_review_line_comment_regression",
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) 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() 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"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -92,18 +93,20 @@ async function setup(page: Page) {
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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) => await page.route("**/vcs/diff**", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
location: { directory },
data:
new URL(route.request().url()).searchParams.get("mode") === "branch" new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")] ? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")], : [diff("src/alpha.ts"), diff("src/gamma.ts")],
), }),
}), }),
) )
await page.addInitScript( await page.addInitScript(
@@ -25,6 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -65,18 +66,23 @@ test("keeps the review tree and terminal sized when both panels are open", async
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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 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") const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
location: { directory: scope ?? directory, project: { id: projectID, directory } },
data:
url.searchParams.get("mode") === "branch" url.searchParams.get("mode") === "branch"
? detail ? detail
? branchDiffs ? branchDiffs
@@ -84,20 +90,56 @@ test("keeps the review tree and terminal sized when both panels are open", async
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs : branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
), }),
}) })
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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) => await page.route("**/api/pty/pty_review_terminal*", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), 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(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem( localStorage.setItem(
@@ -135,8 +177,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const lazyDiff = page.waitForRequest((request) => { const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
await lastFile.click() await lastFile.click()
@@ -150,8 +192,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" } sessionStatus[sessionID] = { type: "idle" }
@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), 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({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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) => await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, 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(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
}) })
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
const ghostty = Promise.withResolvers<void>() const ghostty = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>() const release = Promise.withResolvers<void>()
const created = { count: 0 } const created = { count: 0 }
await page.route("**/pty", (route) => { await page.route("**/api/pty*", (route) => {
created.count += 1 created.count += 1
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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) => { await page.route(/ghostty-web/, async (route) => {
@@ -155,27 +161,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 }) => { test("focuses a terminal created from the new-terminal button", async ({ page }) => {
const created = { count: 0 } const created = { count: 0 }
await page.route("**/pty", (route) => { await page.route("**/api/pty*", (route) => {
created.count += 1 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({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(next), body: JSON.stringify({ location: ptyLocation(), data: next }),
}) })
}) })
await page.route(`**/pty/${newPtyID}`, (route) => await page.route(`**/api/pty/${newPtyID}*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), 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({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, 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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus") await expectSessionTitle(page, "Terminal composer focus")
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, { 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 }
}
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => { test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
], ],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", 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) => await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), 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 page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) 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"]') const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toBeVisible() await expect(terminal).toBeVisible()
await expect.poll(() => connections.length).toBe(1) 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 writeProbe(page)
await switchTab(page, titleB) await switchTab(page, titleB)
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -85,26 +90,33 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route("**/pty", (route) => await page.route("**/api/pty*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
}), }),
) )
await page.route(`**/pty/${ptyID}`, (route) => await page.route(`**/api/pty/${ptyID}*`, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
)
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
route.fulfill({ 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, status: 200,
contentType: "application/json", contentType: "application/json",
headers: { "access-control-allow-origin": "*" }, 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[] = [] 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()) connections.push(ws.url())
}) })
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
function sessionHref(sessionID: string) { function sessionHref(sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}` 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 }
}
@@ -127,11 +127,13 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const [shells] = createResource( const [shells] = createResource(
() => async () => {
serverSdk() const sdk = serverSdk()
.client.pty.shells() if ((await sdk.protocol) === "v1") {
.then((res) => res.data ?? []) return (await sdk.client.pty.shells()).data ?? []
.catch(() => [] as ShellOption[]), }
return (await sdk.api.pty.shells()).data
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
@@ -122,11 +122,13 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource( const [shells] = createResource(
() => async () => {
serverSdk() const sdk = serverSdk()
.client.pty.shells() if ((await sdk.protocol) === "v1") {
.then((res) => res.data ?? []) return (await sdk.client.pty.shells()).data ?? []
.catch(() => [] as ShellOption[]), }
return (await sdk.api.pty.shells()).data
},
{ initialValue: [] as ShellOption[] }, { initialValue: [] as ShellOption[] },
) )
+45 -6
View File
@@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server const connection = useServerSDK()().server
const directory = sdk().directory const directory = sdk().directory
const client = sdk().client
const url = sdk().url const url = sdk().url
const auth = connection.http const auth = connection.http
const username = auth?.username ?? "opencode" const username = auth?.username ?? "opencode"
@@ -241,8 +240,9 @@ export const Terminal = (props: TerminalProps) => {
} }
} }
const pushSize = (cols: number, rows: number) => { const pushSize = async (cols: number, rows: number) => {
return client.pty if ((await sdk().protocol) === "v1") {
return sdk().client.pty
.update({ .update({
ptyID: id, ptyID: id,
size: { cols, rows }, size: { cols, rows },
@@ -251,6 +251,16 @@ export const Terminal = (props: TerminalProps) => {
debugTerminal("failed to sync terminal size", err) debugTerminal("failed to sync terminal size", err)
}) })
} }
return sdk().api.pty
.update({
ptyID: id,
location: { directory },
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
const getTerminalColors = (): TerminalColors => { const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light" const mode = theme.mode() === "dark" ? "dark" : "light"
@@ -522,17 +532,29 @@ export const Terminal = (props: TerminalProps) => {
local.onConnectError?.(err) local.onConnectError?.(err)
} }
const gone = () => const gone = async () => {
client.pty if ((await sdk().protocol) === "v1") {
return sdk().client.pty
.get({ ptyID: id }, { throwOnError: false }) .get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404) .then((result) => result.response.status === 404)
.catch((err) => { .catch((err) => {
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
}
return sdk().api.pty
.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 connectToken = async () => {
const result = await client.pty if ((await sdk().protocol) === "v1") {
const result = await sdk().client.pty
.connectToken( .connectToken(
{ ptyID: id, directory }, { ptyID: id, directory },
{ {
@@ -551,6 +573,20 @@ export const Terminal = (props: TerminalProps) => {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") 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}`) throw new Error(`PTY connect ticket failed with ${result.response.status}`)
} }
return sdk().api.pty
.connectToken({
ptyID: id,
location: { directory },
"x-opencode-ticket": "1",
})
.then((result) => result.data.ticket)
.catch((err: unknown) => {
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
})
}
const retry = (err: unknown) => { const retry = (err: unknown) => {
if (disposed) return if (disposed) return
@@ -579,11 +615,14 @@ export const Terminal = (props: TerminalProps) => {
fail(err) fail(err)
return undefined return undefined
}) })
const protocol = await sdk().protocol
if (protocol === "v2" && !ticket) return
if (once.value) return if (once.value) return
if (disposed) return if (disposed) return
const socket = new WebSocket( const socket = new WebSocket(
terminalWebSocketURL({ terminalWebSocketURL({
protocol,
url, url,
id, id,
directory, directory,
+1
View File
@@ -424,6 +424,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
return { return {
scope: serverSDK.scope, scope: serverSDK.scope,
protocol: serverSDK.protocol,
directory, directory,
client, client,
api: createCompatibleApi({ api: createCompatibleApi({
+45 -24
View File
@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue, scope: ServerScopeValue,
legacySessionID?: string, legacySessionID?: string,
) { ) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
@@ -240,19 +241,29 @@ function createWorkspaceTerminalSession(
}) })
onCleanup(unsub) 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 index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) { if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty })) setStore("all", index, (item) => ({ ...item, ...pty }))
} }
client.pty const doUpdate = async () => {
.update({ if ((await sdk.protocol) === "v1") {
await sdk.client.pty.update({
ptyID: pty.id, ptyID: pty.id,
title: pty.title, title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
}) })
.catch((error: unknown) => { } else {
await sdk.api.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
}
doUpdate().catch((error: unknown) => {
if (previous) { if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id) const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous) if (currentIndex >= 0) setStore("all", currentIndex, previous)
@@ -261,26 +272,30 @@ 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 index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const next = await client.pty const data = await (async () => {
.create({ if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: pty.title })).data
}
return (await sdk.api.pty.create({
location,
title: pty.title, title: pty.title,
}) })).data
.catch((error: unknown) => { })().catch((error: unknown) => {
console.error("Failed to clone terminal", error) console.error("Failed to clone terminal", error)
return undefined return undefined
}) })
if (!next?.data) return if (!data?.id) return
const active = store.active === pty.id const active = store.active === pty.id
batch(() => { batch(() => {
setStore("all", index, { setStore("all", index, {
id: next.data.id, id: data.id,
title: next.data.title ?? pty.title, title: data.title ?? pty.title,
titleNumber: pty.titleNumber, titleNumber: pty.titleNumber,
buffer: undefined, buffer: undefined,
cursor: undefined, cursor: undefined,
@@ -289,7 +304,7 @@ function createWorkspaceTerminalSession(
cols: undefined, cols: undefined,
}) })
if (active) { if (active) {
setStore("active", next.data.id) setStore("active", data.id)
} }
}) })
} }
@@ -308,17 +323,22 @@ function createWorkspaceTerminalSession(
const nextNumber = pickNextTerminalNumber() const nextNumber = pickNextTerminalNumber()
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
sdk.client.pty const doCreate = async () => {
.create({ title: defaultTitle(nextNumber) }) if ((await sdk.protocol) === "v1") {
.then((pty: { data?: { id?: string; title?: string } }) => { return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
const id = pty.data?.id }
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
}
doCreate()
.then((data) => {
const id = data?.id
if (!id) { if (!id) {
if (focusRequest !== undefined) cancelFocus(focusRequest) if (focusRequest !== undefined) cancelFocus(focusRequest)
return return
} }
const newTerminal = { const newTerminal = {
id, id,
title: pty.data?.title ?? defaultTitle(nextNumber), title: data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber, titleNumber: nextNumber,
} }
batch(() => { batch(() => {
@@ -335,7 +355,7 @@ function createWorkspaceTerminalSession(
}) })
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
update(sdk.client, pty) update(pty)
}, },
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@@ -350,10 +370,9 @@ function createWorkspaceTerminalSession(
}) })
}, },
async clone(id: string) { async clone(id: string) {
await clone(sdk.client, id) await clone(id)
}, },
bind() { bind() {
const client = sdk.client
return { return {
trim(id: string) { trim(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
@@ -361,10 +380,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty)) setStore("all", index, (pty) => trimTerminal(pty))
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
update(client, pty) update(pty)
}, },
async clone(id: string) { async clone(id: string) {
await clone(client, id) await clone(id)
}, },
} }
}, },
@@ -412,7 +431,9 @@ function createWorkspaceTerminalSession(
}) })
} }
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { const removePromise =
(await sdk.protocol) === "v1" ? sdk.client.pty.remove({ ptyID: id }) : sdk.api.pty.remove({ ptyID: id, location })
await removePromise.catch((error: unknown) => {
console.error("Failed to close terminal", error) console.error("Failed to close terminal", error)
}) })
}, },
@@ -1,6 +1,7 @@
import { createEffect, onCleanup, type JSX } from "solid-js" import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" 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 { SessionReview } from "@opencode-ai/session-ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element 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 { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" 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 { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" 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" return typeof value.file === "string"
} }
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean canReview: () => boolean
diffs: () => (SnapshotFileDiff | VcsFileDiff)[] diffs: () => ReviewDiff[]
diffsReady: () => boolean diffsReady: () => boolean
empty: () => string empty: () => string
hasReview: () => boolean hasReview: () => boolean
@@ -1,14 +1,15 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" 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 type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" 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) { export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p) 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" return typeof value.file === "string"
} }
@@ -1,5 +1,6 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -30,7 +31,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = { export type ReviewPanelV2Props = {
title?: JSX.Element title?: JSX.Element
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" 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 type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs" import { diffs, message } from "./diffs"
@@ -9,7 +10,7 @@ const item = {
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified", status: "modified",
} satisfies SnapshotFileDiff } satisfies FileDiffInfo & SnapshotFileDiff
describe("diffs", () => { describe("diffs", () => {
test("keeps valid arrays", () => { test("keeps valid arrays", () => {
+2 -1
View File
@@ -1,7 +1,8 @@
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" 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" 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 { function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!value || typeof value !== "object" || Array.isArray(value)) return false
@@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url" import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => { describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => { test("uses the current ticketed PTY route", () => {
const url = terminalWebSocketURL({ const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
ticket: "connect-ticket",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
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 without embedding credentials in websocket URL for v1", () => {
const url = terminalWebSocketURL({
protocol: "v1",
url: "http://127.0.0.1:49365", url: "http://127.0.0.1:49365",
id: "pty_test", id: "pty_test",
directory: "/tmp/project", directory: "/tmp/project",
@@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => {
expect(url.protocol).toBe("ws:") expect(url.protocol).toBe("ws:")
expect(url.username).toBe("") expect(url.username).toBe("")
expect(url.password).toBe("") expect(url.password).toBe("")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
}) })
test("omits query auth for same-origin saved credentials", () => { test("omits query auth for same-origin saved credentials for v1", () => {
const url = terminalWebSocketURL({ const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test", url: "https://app.example.test",
id: "pty_test", id: "pty_test",
directory: "/tmp/project", directory: "/tmp/project",
@@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => {
}) })
expect(url.protocol).toBe("wss:") expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.has("auth_token")).toBe(false) expect(url.searchParams.has("auth_token")).toBe(false)
}) })
test("uses query auth for same-origin credentials from auth_token", () => { test("uses query auth for same-origin credentials from auth_token for v1", () => {
const url = terminalWebSocketURL({ const url = terminalWebSocketURL({
protocol: "v1",
url: "https://app.example.test", url: "https://app.example.test",
id: "pty_test", id: "pty_test",
directory: "/tmp/project", directory: "/tmp/project",
@@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => {
}) })
expect(url.protocol).toBe("wss:") expect(url.protocol).toBe("wss:")
expect(url.pathname).toBe("/pty/pty_test/connect")
expect(url.searchParams.get("directory")).toBe("/tmp/project")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
}) })
}) })
@@ -1,6 +1,7 @@
import { authTokenFromCredentials } from "@/utils/server" import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: { export function terminalWebSocketURL(input: {
protocol?: "v1" | "v2"
url: string url: string
id: string id: string
directory: string directory: string
@@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: {
password?: string password?: string
authToken?: boolean authToken?: boolean
}) { }) {
const next = new URL(`${input.url}/pty/${input.id}/connect`) const isV1 = input.protocol === "v1"
const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
if (isV1) {
next.searchParams.set("directory", input.directory) next.searchParams.set("directory", input.directory)
} else {
next.searchParams.set("location[directory]", input.directory)
}
next.searchParams.set("cursor", String(input.cursor)) next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:" next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) { if (input.ticket) {
next.searchParams.set("ticket", input.ticket) next.searchParams.set("ticket", input.ticket)
return next return next
} }
if (input.password && (!input.sameOrigin || input.authToken)) if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
next.searchParams.set( next.searchParams.set(
"auth_token", "auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }), authTokenFromCredentials({ username: input.username, password: input.password }),
) )
}
return next return next
} }
@@ -1,6 +1,7 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { parsePatch } from "diff" import { parsePatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
type LegacyDiff = { type LegacyDiff = {
file: string file: string
@@ -13,7 +14,7 @@ type LegacyDiff = {
} }
type SnapshotDiff = SnapshotFileDiff & { file: string } 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 DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
export type ViewDiff = { 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 { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" 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 { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs" import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -62,10 +63,10 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string } export type SessionReviewFocus = { file: string; id: string }
type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any> preloaded?: PreloadMultiFileDiffResult<any>
} }
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any> preloaded?: PreloadMultiFileDiffResult<any>
} }
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
@@ -4,6 +4,7 @@ import {
Message as MessageType, Message as MessageType,
Part as PartType, Part as PartType,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionStatus } from "@opencode-ai/sdk/v2" import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context" import { useData } from "../context"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
@@ -90,7 +91,7 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback return fallback
} }
type SummaryDiff = SnapshotFileDiff & { file: string } type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string" return typeof value.file === "string"
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -21,7 +22,7 @@ type Data = {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: SnapshotFileDiff[] [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[]
} }
session_diff_preload?: { session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[] [sessionID: string]: PreloadMultiFileDiffResult<any>[]
@@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
import { mediaKindFromPath } from "../../pierre/media" import { mediaKindFromPath } from "../../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import "./session-review-v2.css" import "./session-review-v2.css"
type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff
export type SessionReviewFilePreviewV2Props = { export type SessionReviewFilePreviewV2Props = {
file: string file: string