Compare commits

...
Author SHA1 Message Date
Brendonovich 4d4d47d670 feat(core): add project update API 2026-08-23 16:26:51 +00:00
Brendonovich b6974306c9 feat(core): run worktree setup scripts 2026-08-23 16:19:44 +00:00
Brendan Allan 89451c3e32 fix(app): navigate parent with escape (#44441) 2026-08-23 23:45:29 +08:00
Brendan Allan ad7d515d50 fix(session-ui): avoid replaying thinking summary animation (#44440) 2026-08-23 23:43:17 +08:00
Brendan Allan 8e71c8425f fix(app): hide empty terminal cursor (#44435) 2026-08-23 15:34:40 +00:00
Brendan Allan da4ab56801 fix(session-ui): hide empty file diffs (#44425) 2026-08-23 23:15:47 +08:00
Brendan Allan dd780ca882 fix(app): hand off optimistic attachments (#44411) 2026-08-23 22:59:51 +08:00
Brendan Allan fb9c9a2cbd fix(desktop): restore session completion alerts (#44412) 2026-08-23 22:35:06 +08:00
Brendan Allan 4c786f0ff8 fix(app): preserve subagent tab lineage (#44404) 2026-08-23 22:18:39 +08:00
Brendan Allan 1def4aa35a fix(app): avoid replaying streamed markdown fade (#44396) 2026-08-23 21:10:43 +08:00
opencode-agent[bot]andBrendonovich de11122c96 feat(app): search sessions by ID (#44387)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 20:17:57 +08:00
opencode-agent[bot] 62e3b6ad78 chore: update nix node_modules hashes 2026-08-23 11:49:19 +00:00
Brendan Allan 2c99b549db fix(app): redesign model selection dialog (#44383) 2026-08-23 19:34:14 +08:00
Luke Parker d7c3a95368 feat(app): refine expanded shell output (#44368) 2026-08-23 11:31:40 +00:00
opencode-agent[bot] 3b5b5aee0c chore: update nix node_modules hashes 2026-08-23 11:16:11 +00:00
Brendan Allan 8cbfac3184 fix(desktop): smooth startup hydration (#44377) 2026-08-23 19:07:10 +08:00
opencode-agent[bot]andBrendonovich e765349865 feat(session-ui): render streaming markdown with Solid (#44354)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-23 19:00:46 +08:00
opencode-agent[bot]andBrendonovich 750f3384ff fix(app): defer service worker takeover (#44374)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-23 18:52:45 +08:00
Simon Klee 1cf61593b5 ci: fix node build (#44349) 2026-08-23 11:26:26 +02:00
opencode-agent[bot] 708e4d8867 fix(app): refine mobile error page (#44353) 2026-08-23 09:23:10 +00:00
Luke Parker 0a5349c51c fix(session-ui): restore compaction summaries (#44311) 2026-08-23 18:57:06 +10:00
opencode-agent[bot] 3fd93b659b chore: update nix node_modules hashes 2026-08-23 08:28:38 +00:00
Simon Klee 17a445de3e tui: update OpenTUI to 0.5.7 (#44345) 2026-08-23 10:15:17 +02:00
97 changed files with 1984 additions and 396 deletions
+2 -1
View File
@@ -234,6 +234,7 @@ jobs:
host: macos-26
- target: windows-arm64
host: blacksmith-4vcpu-windows-2025
bun_install_flags: --cpu=*
- target: windows-x64
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
@@ -245,7 +246,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
install-flags: --os=* --cpu=*
install-flags: ${{ matrix.settings.bun_install_flags }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
+18 -17
View File
@@ -54,6 +54,7 @@
"name": "@opencode-ai/app",
"version": "1.18.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -593,8 +594,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"@opentui/core": ">=0.5.7",
"@opentui/solid": ">=0.5.7",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -717,7 +718,6 @@
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
@@ -727,6 +727,7 @@
"strip-ansi": "7.1.2",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -1088,9 +1089,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -2216,27 +2217,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="],
"@opentui/core": ["@opentui/core@0.5.7", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.7", "@opentui/core-darwin-x64": "0.5.7", "@opentui/core-linux-arm64": "0.5.7", "@opentui/core-linux-arm64-musl": "0.5.7", "@opentui/core-linux-x64": "0.5.7", "@opentui/core-linux-x64-musl": "0.5.7", "@opentui/core-win32-arm64": "0.5.7", "@opentui/core-win32-x64": "0.5.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-/XDabTkfBs2Wy2FhlC4jvzbpphYAs4SlnCRKYEvi+metlXNTSAshSs43wqZ9O4IPd0E4EcQqdfeQ0sdc3yPpYw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-75TDJgFD6hDoCElIX35Yg3TIRF/jhrtVQO/9snhjGuTsZ7bd0W88jUlvSXSNhqB3CX431rwgi47B+asWPDI1lQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-j+Dwu2yV8zahBFjnVIssYdPrHz/+pkd7xWxI+nHjV41V3c+scvH6vJY1U85VCVaoQs/zCH5xAJbmAAn7Sgeh8g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWO9NWaivXRPc0XhEbxehj3ApyC1SW8Bf1cnHnME1zhw/EZNCNE3TQHgKwOnnNKtvlbhzLQjOI4OqyPYV8UcEQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RumSHTasIAWU7jPBKiTuEZG/m+prfQKCHhL3JAQnBLgp7eAB20XZGfSLVWDBgxkyKq1rHCUgLtZktC5ZAyKfTA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-fXDmrIlfp9xaoVkk3BNn3yUO/b7plBSOfUh2oWOezRA6E/g5z+hTO1alGFQCy+VV+1kfA6iuYadAww0xNtfACg=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-1opDV+W7C1F4iWivCNKuYl5Hen+IeFXAuUPm3hVyN0UtdcP8LESpFJTr8QRwHpGSE9Jz1K2g2S2ipzI3u5mhgw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gbiZUyttjq8s+bsnsaopygWN7LTS/RyzD6GOmpgOB2TVitvo0P6mxM+AebBGuu/7xri17Rqaxf3uqrgdKiJAeg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-bDwon45lUxbV3rMcekTCg4mXcBu9uhdG6HLeLK5TVZf05/h+Ibkf2UeZ8A2jjQt+MK6jpYmxWwDCVCTzZ7EU7Q=="],
"@opentui/keymap": ["@opentui/keymap@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6" }, "peerDependencies": { "@opentui/react": "0.5.6", "@opentui/solid": "0.5.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1lR1aFRSgHCwN31YOHDQQVSqz1YNN7DsoIvWHjEiQJ12kTxHg9G7Q1HHFJttM838RWsNOvW6gBAhOr403VL8rg=="],
"@opentui/keymap": ["@opentui/keymap@0.5.7", "", { "dependencies": { "@opentui/core": "0.5.7" }, "peerDependencies": { "@opentui/react": "0.5.7", "@opentui/solid": "0.5.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BqfSbjlLuctnew2aoPPdK3wpzJfEPp7ttLBkTu1wJTkps9AcfUgXqwloqvY4DUKjUBNFYYEpU/0CPxI4Blj4MA=="],
"@opentui/solid": ["@opentui/solid@0.5.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kxmrQS31sgziW/qZDuFYuiT3CvcUQg9ZQA1qKCEb5mBoInur4fnECOdTaM2S8c3cidcV0z2yu41NP1Dypkl+BQ=="],
"@opentui/solid": ["@opentui/solid@0.5.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-qrKAZd9xt4D67LXZUARg1Aw0dwEWi29GyOxpM6abqmyExGPMS7jih5Z9oMQJ9EXsouDKpW7Mejjq7WbX2AaecQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2+fEzSA/1LPv/Hw7TP1Gu8boByTqoKyAvS1TKX2vLpo=",
"aarch64-linux": "sha256-+xOGlHZjkaddY91AOxq7xyUDFFKFWwaYGJOZQkV/Rtk=",
"aarch64-darwin": "sha256-HS9mG3OBsCBw2CjIQ4OE6fHOZ07gt4B1bS3YJrXG7Mw=",
"x86_64-darwin": "sha256-+gZT6aNbQRWPI7+hEhNOoE+/6uuIEUcEOVndtuFs7Fs="
"x86_64-linux": "sha256-P8AZ2THuXiET4hiSYTwGGdTgbm2l3XGZGJxQwg6lusE=",
"aarch64-linux": "sha256-73LxDeDPQspY4Bn/gbGU43j/k7gBZ5ywMMpUjDl3elg=",
"aarch64-darwin": "sha256-sc9YyTAFlbWxlFb9vwFYLs6IYd3SRoju9mQufzeQdd8=",
"x86_64-darwin": "sha256-nnk637DELjzGB3t9aLDYbHz5h3Dd3nvWcWGdaQk8yPM="
}
}
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.6",
"@opentui/keymap": "0.5.6",
"@opentui/solid": "0.5.6",
"@opentui/core": "0.5.7",
"@opentui/keymap": "0.5.7",
"@opentui/solid": "0.5.7",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -259,6 +259,22 @@ export function event(
return makeEvent(type, data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
return makeEvent("session.compaction.delta", data)
}
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
return makeEvent("session.compaction.ended", data)
}
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
return makeEvent("session.compaction.failed", data)
}
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
return makeEvent("session.tool.input.started", data)
}
@@ -45,21 +45,17 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a line number is hovered", async ({ page }) => {
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => {
await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -1,6 +1,15 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
import {
compactionDelta,
compactionEnded,
compactionFailed,
compactionStarted,
event,
session,
sessionID,
setupTimeline,
} from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
@@ -73,13 +82,93 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
expect(ownerWarnings).toEqual([])
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
sessionID,
reason: "manual",
recent: "",
}),
)
const compaction = page.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.send(
compactionDelta({
sessionID,
text: "## Checkpoint\n\nStreamed implementation details.",
}),
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.send(
compactionEnded({
sessionID,
reason: "manual",
text: "## Checkpoint\n\nFinal implementation details.",
recent: "",
}),
)
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await timeline.send(
compactionFailed({
sessionID,
reason: "auto",
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}),
)
const compactions = page.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await timeline.send(
compactionFailed({
sessionID,
reason: "manual",
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
}),
)
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [toolPart("call_subagent", "subagent", "streaming", {})],
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
@@ -87,10 +176,7 @@ test("shows a delegating row while subagent input streams", async ({ page }) =>
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
@@ -99,8 +99,8 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
@@ -112,8 +112,10 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'),
).toHaveText("a.ts")
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -26,6 +26,42 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("returns to the parent session with Escape", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.keyboard.press("Escape")
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
})
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -50,6 +86,44 @@ test("keeps the parent visible while the child session resolves", async ({ page
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.goBack()
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
const parentTab = page.locator("[data-titlebar-tab-slot]", {
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
})
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(parentTab).toHaveAttribute("data-active", "true"),
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
const home = page.getByRole("button", { name: "Home" })
await home.click()
await expect(page).toHaveURL("/")
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
await expect(childTab).toHaveCount(1)
await childTab.click()
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
@@ -10,11 +10,13 @@ const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const ptyInput: string[] = []
let sendPtyOutput: ((data: string) => void) | undefined
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
ptyInput.length = 0
sendPtyOutput = undefined
await mockOpenCodeServer(page, {
directory,
project: {
@@ -74,6 +76,7 @@ test.beforeEach(async ({ page }) => {
)
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
ws.onMessage((message) => ptyInput.push(message.toString()))
sendPtyOutput = (data) => ws.send(data)
})
})
@@ -90,6 +93,33 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
await expect.poll(() => ptyInput.join("")).toBe("\x15")
})
test("hides the native contenteditable caret", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
})
test("reveals the terminal after its first server output renders", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
await expect(terminal).toHaveAttribute("contenteditable", "true")
await expect(terminal).toHaveCSS("opacity", "0")
await expect.poll(() => sendPtyOutput).toBeDefined()
sendPtyOutput?.("\x1b[?25h")
await expect(terminal).toHaveCSS("opacity", "0")
sendPtyOutput?.("ready")
await expect(terminal).toHaveCSS("opacity", "1")
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
@@ -358,7 +358,8 @@ test.describe("smoke: session timeline", () => {
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shellSubtitle).toHaveCount(0)
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
await shellTrigger.click()
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
await expect(shellSubtitle).toHaveText("bun typecheck")
+1
View File
@@ -52,6 +52,7 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
+5
View File
@@ -1,4 +1,5 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
@@ -41,6 +42,10 @@ export type ComposerSelection = {
export type ComposerSession = {
id: string
directory: string
handoff?: {
set: (message: SessionMessageUser) => void
clear: (messageID: string) => void
}
api: {
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
+49
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Skill } from "@opencode-ai/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState } from "./state"
@@ -69,6 +70,7 @@ function submitInput(
function session(input: {
calls: string[]
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
handoff?: ComposerSession["handoff"]
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
@@ -78,6 +80,7 @@ function session(input: {
return {
id: "session-1",
directory: "C:/repo",
handoff: input.handoff,
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
@@ -180,6 +183,52 @@ describe("Composer submission", () => {
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("hands off image-only first prompts before admission", async () => {
const draft = createMemoryComposerState().capture()
draft.set([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "image.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
const handedOff = Promise.withResolvers<SessionMessageUser>()
const target = session({
calls: [],
handoff: { set: handedOff.resolve, clear() {} },
prompt: async () => undefined,
})
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start() {
return { session: target, cleanupReady: Promise.resolve() }
},
}
await submitInput(adapter).submit(new Event("submit"))
expect(await handedOff.promise).toMatchObject({
type: "user",
text: "",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
name: "image.png",
},
],
})
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
+26
View File
@@ -1,4 +1,5 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
@@ -78,6 +79,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
@@ -128,6 +130,29 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
}
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
return {
id: value.id,
type: "user",
text: value.text,
files: value.images.map((image) => ({
data: "",
mime: image.mime,
source: { type: "uri", uri: image.blob.url },
name: image.sourcePath ?? image.filename,
})),
metadata: {
displayText: value.text,
agent: value.selection.agent,
model: {
...value.selection.model,
...(value.selection.variant ? { variant: value.selection.variant } : {}),
},
},
time: { created: Date.now() },
}
}
function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
@@ -334,6 +359,7 @@ function failSubmission(
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
input.notify.failed(kind, error)
+1
View File
@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export { ServerConnection, useServers } from "./runtime/server/registry"
@@ -30,6 +30,7 @@ export function HomeCommandPalette(props: {
opened: server.projects.list,
stored: () => server.sync.data.project,
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -137,6 +137,17 @@ export function createHomeSessionsController(home: HomeController) {
showProjectName: () => !home.project.selected(),
server: () => home.selection.value().server,
canCreate: () => !!home.project.newSession(),
lookup: async (sessionID: string) => {
const ctx = home.server.focusedContext()
if (!ctx) return
const result = await ctx.sdk.api.session.get({ sessionID })
if (result.time.archived) return
return buildHomeSessionRecords({
sessions: () => [result],
projectDirectories,
projects: home.project.list,
})[0]
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
+35 -5
View File
@@ -8,13 +8,21 @@ import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "../model"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
import { looksLikeSessionID } from "@/session/search"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
const command = useCommand()
const language = useLanguage()
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
const [state, setState] = createStore({
value: "",
focused: false,
highlighted: "",
exact: undefined as HomeSessionRecord | undefined,
lookingUp: false,
})
let lookup = 0
let root: HTMLDivElement | undefined
let input: HTMLInputElement | undefined
let list: HTMLDivElement | undefined
@@ -22,9 +30,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
const results = createMemo(() => {
const value = query().toLowerCase()
if (!value) return []
return sessions.data
const records = sessions.data
.searchRecords()
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
return [state.exact, ...records]
})
const active = createMemo(() => {
const records = results()
@@ -50,6 +60,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
close()
}),
)
onCleanup(() => lookup++)
command.register("home.search", () => [
{
@@ -67,7 +78,26 @@ export function createHomeSessionSearchController(home: HomeController, sessions
}
function close() {
setState({ value: "", focused: false })
lookup++
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
}
function update(value: string) {
const current = ++lookup
const sessionID = value.trim()
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
if (!looksLikeSessionID(sessionID)) return
setState("lookingUp", true)
void sessions.session.lookup(sessionID).then(
(record) => {
if (current !== lookup) return
setState({ exact: record, lookingUp: false })
},
() => {
if (current !== lookup) return
setState("lookingUp", false)
},
)
}
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
@@ -81,11 +111,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
placeholder,
open,
focus,
input: (value: string) => setState({ value, highlighted: "" }),
input: update,
close,
},
result: {
loading: sessions.data.loading,
loading: () => sessions.data.loading() || state.lookingUp,
list: results,
active,
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
+8
View File
@@ -17,6 +17,14 @@
font-style: normal;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("@ibm/plex/IBM-Plex-Mono/fonts/complete/woff2/IBMPlexMono-Text.woff2") format("woff2");
font-weight: 440;
font-style: normal;
font-display: swap;
}
@media (display-mode: standalone) {
/* WebKit excludes safe-area insets from dvh in installed apps. */
#root {
@@ -1,5 +1,6 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
import { useComposerState } from "@/composer/persistence"
@@ -9,11 +10,13 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
import { usePermission } from "@/session/requests/permission"
import { useData, useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
export function createNewSessionComposerAdapter(props: {
draftID: string
@@ -77,7 +80,10 @@ export function createNewSessionComposerAdapter(props: {
if (!result.ok) throw result.error
return run()
}
const sessionKey = SessionStateKey.from(
serverSDK.scope,
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
@@ -102,6 +108,7 @@ export function createNewSessionComposerAdapter(props: {
session: {
id: created.id,
directory: sessionDirectory,
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
api: {
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
@@ -135,6 +142,27 @@ export function createNewSessionComposerAdapter(props: {
}
}
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
let unsubscribe: VoidFunction | undefined
return {
set(message: SessionMessageUser) {
unsubscribe?.()
setSessionMessageHandoff(key, message)
unsubscribe = event.on("session.inbox.enqueued", (item) => {
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, message.id)
})
},
clear(messageID: string) {
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, messageID)
},
}
}
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
+54 -28
View File
@@ -7,6 +7,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { TextInput } from "@opencode-ai/ui/text-input"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { popularProviders } from "@/providers/catalog/providers"
import { useLanguage } from "@/runtime/i18n/language"
@@ -23,6 +24,7 @@ export const DialogManageModels: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
@@ -120,16 +122,38 @@ export const DialogManageModels: Component = () => {
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-section" data-component="settings-models-provider">
<div class="settings-models-group-header justify-between">
<div class="flex min-w-0 items-center gap-2">
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
<h3 class="settings-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<div class="settings-models-group-header justify-between">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-models-group-chevron">
<Icon
name="chevron-down"
size="small"
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
/>
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
<Switch
class="mr-6"
class="me-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
@@ -137,26 +161,28 @@ export const DialogManageModels: Component = () => {
{group.items[0].provider.name}
</Switch>
</div>
<Show when={expanded()}>
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</Show>
</div>
<SettingsList>
<For each={group.items}>
{(item) => (
<SettingsRow title={item.name} description="">
<div>
<Switch
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRow>
)}
</For>
</SettingsList>
</div>
)}
)
}}
</For>
</Show>
</Show>
@@ -10,9 +10,10 @@ import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/d
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu"
import { TextInput } from "@opencode-ai/ui/text-input"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { decode64 } from "@/runtime/persistence/base64"
@@ -20,6 +21,8 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
@@ -44,70 +47,182 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
action?: JSX.Element
model?: ModelState
}> = (props) => {
const model = props.model ?? useLocal().model
const language = useLanguage()
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: props.onSelect,
})
const [store, setStore] = createStore({
search: "",
active: "",
collapsed: {} as Record<string, boolean>,
})
const models = createMemo(() => controller.models(store.search))
const groups = createMemo(() => controller.groups(models()))
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
let scrollRef: HTMLDivElement | undefined
const models = createMemo(() =>
model
.list()
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
const setSearch = (value: string) => {
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
setStore({ search: value, active: first ? modelKey(first) : "" })
}
const moveActive = (delta: number) => {
const keys = visibleModels().map(modelKey)
if (keys.length === 0) return
const index = keys.indexOf(store.active)
const start = index === -1 ? (delta > 0 ? -1 : 0) : index
setStore("active", keys[(start + delta + keys.length) % keys.length])
queueMicrotask(() => {
scrollRef
?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`)
?.scrollIntoView({ block: "nearest" })
})
}
const selectActive = () => {
const item = visibleModels().find((item) => modelKey(item) === store.active)
if (item) controller.select(item)
}
return (
<List
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
itemWrapper={(item, node) => (
<Tooltip
appearance="standard"
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
</Tooltip>
)}
onSelect={(x) => {
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}>
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={i.latest}>
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
<div class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 px-4 pt-px pb-3">
<div class="relative">
<TextInput
type="search"
appearance="base"
class="!w-full self-stretch"
placeholder={language.t("dialog.model.search.placeholder")}
value={store.search}
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={store.search}>
<IconButton
type="button"
variant="ghost-muted"
size="small"
class="settings-tab-search-clear"
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
)}
</List>
</div>
<div class="relative min-h-0 flex-1">
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
<Show
when={models().length > 0}
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
>
<For each={groups()}>
{(group) => {
const searching = () => store.search.length > 0
const open = () => expanded(group.category)
return (
<section class="settings-section" data-expanded={open() ? "" : undefined}>
<h3 class="settings-models-group-header">
<button
type="button"
class="settings-models-group-trigger"
aria-expanded={open()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, open())}
>
<span class="settings-models-group-chevron">
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
</span>
<span class="settings-models-group-label">
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
<span class="settings-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={open()}>
<SettingsList>
<For each={group.items}>
{(item) => (
<button
type="button"
data-component="settings-row"
data-option-key={modelKey(item)}
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => setStore("active", modelKey(item))}
onMouseLeave={() => setStore("active", "")}
onClick={() => controller.select(item)}
>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="flex items-center gap-2">
<Tooltip
placement="right-start"
gutter={12}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
<Show when={item.latest}>
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
</Show>
</div>
</div>
<div data-slot="settings-row-control" class="size-4">
<Show when={controller.current() === modelKey(item)}>
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</div>
</button>
)}
</For>
</SettingsList>
</Show>
</section>
)
}}
</For>
</Show>
</div>
</div>
</div>
)
}
@@ -431,18 +546,25 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
}
return (
<Dialog>
<DialogHeader hideClose>
<Dialog size="large" variant="settings">
<DialogHeader hideClose closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
<Button icon="plus" onClick={provider}>
{language.t("command.provider.connect")}
</Button>
</DialogHeader>
<DialogBody>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
<div class="shrink-0 border-t border-v2-border-border-muted px-4 py-3">
<button
type="button"
class="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={manage}
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.manage")}</span>
</button>
</div>
</DialogBody>
</Dialog>
)
+5 -1
View File
@@ -458,7 +458,8 @@ export const dict = {
"dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.description":
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"dialog.releaseNotes.action.getStarted": "Get started",
@@ -570,10 +571,13 @@ export const dict = {
"toast.update.action.notYet": "Not yet",
"error.page.title": "Something went wrong",
"error.page.title.status": "Server request failed",
"error.page.description": "An error occurred while loading the application.",
"error.page.description.status": "The server returned an HTTP {{status}} response.",
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.reload": "Reload",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.exportLogs": "Export Logs",
+12 -5
View File
@@ -101,6 +101,10 @@ export function createActiveSessionRegion(input: {
const focus = () => {
if (!input.session.data.isChild()) promptRef?.focus()
}
const openParent = () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
}
const editable = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
@@ -113,6 +117,7 @@ export function createActiveSessionRegion(input: {
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const active = activeElement()
@@ -124,6 +129,11 @@ export function createActiveSessionRegion(input: {
) {
return
}
if (event.key === "Escape" && input.session.data.isChild()) {
event.preventDefault()
openParent()
return
}
if (active === promptRef) {
if (event.key === "Escape") promptRef?.blur()
return
@@ -172,10 +182,7 @@ export function createActiveSessionRegion(input: {
},
region: {
centered: input.screen.centered,
openParent: () => {
const id = input.session.data.parentID()
if (id) navigate(sessionHref(requireServerKey(input.session.identity.params.serverKey), id))
},
openParent,
prompt,
setDockRef: input.timeline.view.setDockRef,
setPromptRef: (element: HTMLDivElement) => {
@@ -204,7 +211,7 @@ export function ActiveSessionComposerRegion(props: {
}) {
const region = createSessionComposerRegionController({
state: props.model.region.state,
sessionID: () => props.session.identity.params.id,
parentID: props.session.data.parentID,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
@@ -1,21 +1,15 @@
import { type Accessor, createMemo } from "solid-js"
import { useData } from "@/runtime/server/current"
import type { Accessor } from "solid-js"
import type { SessionRequestModel } from "../requests/model"
export function createSessionComposerRegionController(input: {
state: SessionRequestModel
sessionID: Accessor<string | undefined>
parentID: Accessor<string | undefined>
centered: Accessor<boolean>
onResponseSubmit: () => void
openParent: () => void
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
const data = useData()
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? data.session.get(id)?.parentID : undefined
})
return {
state: input.state,
centered: input.centered,
@@ -23,9 +17,9 @@ export function createSessionComposerRegionController(input: {
openParent: input.openParent,
setPromptRef: input.setPromptRef,
setDockRef: input.setDockRef,
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
parentID: input.parentID,
child: () => !!input.parentID(),
showComposer: () => !input.state.blocked() || !!input.parentID(),
}
}
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { clearSessionMessageHandoff, getSessionMessageHandoff, setSessionMessageHandoff } from "./handoff"
test("stores and clears a message handoff", () => {
const message = {
id: "msg_handoff",
type: "user",
text: "",
files: [{ data: "", mime: "image/png", source: { type: "uri", uri: "blob:image" } }],
time: { created: 1 },
} satisfies SessionMessageUser
setSessionMessageHandoff("session-key", message)
expect(getSessionMessageHandoff("session-key")).toEqual(message)
clearSessionMessageHandoff("session-key", message.id)
expect(getSessionMessageHandoff("session-key")).toBeUndefined()
})
+24
View File
@@ -1,4 +1,6 @@
import type { SelectedLineRange } from "@/workspaces/files/model"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createStore } from "solid-js/store"
type HandoffSession = {
files: Record<string, SelectedLineRange | null>
@@ -10,6 +12,8 @@ const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
const messageOrder = new Map<string, true>()
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
@@ -28,6 +32,26 @@ export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) =
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => {
messageOrder.delete(key)
messageOrder.set(key, true)
setMessages(key, message)
while (messageOrder.size > MAX) {
const first = messageOrder.keys().next().value
if (first === undefined) return
messageOrder.delete(first)
setMessages(first, undefined)
}
}
export const getSessionMessageHandoff = (key: string) => messages[key]
export const clearSessionMessageHandoff = (key: string, messageID: string) => {
if (messages[key]?.id !== messageID) return
messageOrder.delete(key)
setMessages(key, undefined)
}
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
+14 -1
View File
@@ -15,6 +15,8 @@ import {
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@@ -23,6 +25,8 @@ const idle = { type: "idle" as const }
export function useSessionModel() {
const file = useFile()
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -31,7 +35,16 @@ export function useSessionModel() {
const id = sessionID()
return id ? data.session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parentID = createMemo(() => {
const current = info()?.parentID
if (current) return current
const id = sessionID()
if (!id) return
const tab = shellTabs.store.find(
(item) => item.type === "session" && item.server === server.key && item.routeSessionId === id,
)
return tab?.type === "session" ? (tab.routeParentId ?? tab.sessionId) : undefined
})
const parent = createMemo(() => {
const id = parentID()
return id ? data.session.get(id) : undefined
+3
View File
@@ -0,0 +1,3 @@
export function looksLikeSessionID(value: string) {
return value.length > 20 && value.startsWith("ses_")
}
@@ -1,6 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
@@ -9,6 +10,7 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "./title"
export function SessionTitleHeader(props: ParentProps) {
@@ -26,11 +28,36 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const info = createMemo(
() => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })],
const navigate = useNavigate()
const tab = createMemo(() =>
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
),
)
const info = createMemo(() => {
const current = tab()
return current ? tabs.info[tabKey(current)] : undefined
})
const parentID = createMemo(() => {
if (props.session?.parentID) return props.session.parentID
const current = tab()
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
return current.routeParentId ?? current.sessionId
})
const parent = createMemo(() => {
const id = parentID()
return id ? server.ctx.data.session.get(id) : undefined
})
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
})
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title))
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
@@ -44,9 +71,16 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()
if (!id || current?.type !== "session") return
tabs.rememberSessionRoute(current, id, parent()?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<Show when={title() || showProjectIcon()}>
<Show when={title() || parentTitle() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
@@ -69,9 +103,32 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
/>
</Show>
</span>
<Show when={parentTitle()}>
{(value) => (
<button
type="button"
data-slot="session-title-parent"
dir="auto"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{value()}
</button>
)}
</Show>
<Show when={parentTitle() && title()}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
+12 -1
View File
@@ -221,6 +221,14 @@ export const Terminal = (props: TerminalProps) => {
let drop: VoidFunction | undefined
let reconn: ReturnType<typeof setTimeout> | undefined
let tries = 0
let revealed = false
const reveal = () => {
if (revealed) return
if (!serializeAddon.serializeAsText({ trimWhitespace: true })) return
revealed = true
container.style.opacity = "1"
}
const cleanup = () => {
if (!cleanups.length) return
@@ -500,6 +508,7 @@ export const Terminal = (props: TerminalProps) => {
if (restore && restoreSize) {
await write(restore)
reveal()
fit.fit()
scheduleSize(t.cols, t.rows)
if (scrollY !== undefined) t.scrollToLine(scrollY)
@@ -509,6 +518,7 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(t.cols, t.rows)
if (restore) {
await write(restore)
reveal()
if (scrollY !== undefined) t.scrollToLine(scrollY)
}
startResize()
@@ -605,6 +615,7 @@ export const Terminal = (props: TerminalProps) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
if (!revealed) output?.flush(reveal)
cursor += data.length
seek = cursor
}
@@ -685,7 +696,7 @@ export const Terminal = (props: TerminalProps) => {
dir="ltr"
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}
style={{ "background-color": terminalColors().background, "caret-color": "transparent", opacity: 0 }}
classList={{
...local.classList,
"select-text": true,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { visibleTimelineMessages } from "./controller-projection"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -41,3 +41,37 @@ describe("visibleTimelineMessages", () => {
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
})
})
describe("applyTimelineMessageHandoff", () => {
const handoff = {
id: "msg_image",
type: "user",
text: "",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "blob:image" },
name: "image.png",
},
],
time: { created: 1 },
} satisfies SessionMessageInfo
test("shows a promoted image-only prompt before client admission", () => {
expect(applyTimelineMessageHandoff([], handoff)).toEqual([handoff])
})
test("adds attachments to the client's optimistic row", () => {
const optimistic = { id: handoff.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([optimistic], handoff)).toEqual([{ ...optimistic, files: handoff.files }])
})
test("keeps the durable attachment payload", () => {
const durable = {
...handoff,
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
})
})
@@ -1,4 +1,13 @@
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
if (!handoff) return messages
const index = messages.findIndex((message) => message.id === handoff.id)
if (index < 0) return [...messages, handoff]
const message = messages[index]
if (message.type !== "user" || message.files?.length) return messages
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
}
export function visibleTimelineMessages(
messages: SessionMessageInfo[],
@@ -16,9 +16,15 @@ import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import {
applyTimelineMessageHandoff,
timelineChildTitle,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
@@ -52,10 +58,16 @@ export function createTimelineController(input: { session: TimelineSessionSource
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
),
)
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
input.session.history.messages(),
handedOffMessages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -9,7 +8,7 @@ import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const supported = true
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +71,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
+34 -23
View File
@@ -15,6 +15,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
import { createSessionTabs } from "@/session/helpers"
import { useSessionLayout } from "@/session/session-layout"
import { useServer } from "@/runtime/server/current"
import { looksLikeSessionID } from "@/session/search"
export type CommandPaletteEntry = {
id: string
@@ -147,6 +148,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
get: (sessionID, signal) => serverSDK.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -221,6 +223,7 @@ export function createServerSessionEntries(props: {
opened: () => LocalProject[]
stored: () => Project[]
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
get: (sessionID: string, signal: AbortSignal) => Promise<SessionInfo>
untitled: () => string
category: () => string
}) {
@@ -253,28 +256,36 @@ export function createServerSessionEntries(props: {
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
return props
.load(search, current.signal)
.then((result) =>
result.data
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory),
category: props.category(),
directory: session.location.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
.catch(() => [] as CommandPaletteEntry[])
return Promise.all([
props.load(search, current.signal).then(
(result) => result.data,
() => [],
),
looksLikeSessionID(search)
? props.get(search, current.signal).then(
(result) => [result],
() => [],
)
: Promise.resolve([]),
]).then(([listed, exact]) =>
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
.filter((session) => !session.time.archived)
.map((session) => {
const project =
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
return {
id: `session:${props.server}:${session.id}`,
type: "session" as const,
title: session.title || props.untitled(),
description: project ? displayName(project) : getFilename(session.location.directory),
category: props.category(),
directory: session.location.directory,
sessionID: session.id,
server: props.server,
project,
updated: session.time.updated,
}
}),
)
}
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { errorDescriptionKey } from "./description"
import { errorDescriptionKey, errorStatus } from "./description"
describe("error description", () => {
test("describes local server startup errors", () => {
@@ -15,3 +15,19 @@ describe("error description", () => {
)
})
})
describe("error status", () => {
test("finds status codes in an error cause", () => {
expect(errorStatus(new Error("UnexpectedStatus", { cause: { status: 502 } }))).toBe(502)
})
test("finds status codes in structured error data", () => {
expect(errorStatus({ name: "APIError", data: { statusCode: 401 } })).toBe(401)
})
test("ignores invalid and circular status values", () => {
const error: { status: number; cause?: unknown } = { status: 99 }
error.cause = error
expect(errorStatus(error)).toBeUndefined()
})
})
@@ -9,3 +9,21 @@ export function errorDescriptionKey(error: unknown) {
}
return "error.page.description" as const
}
export function errorStatus(error: unknown) {
const seen = new Set<object>()
const visit = (value: unknown): number | undefined => {
if (typeof value !== "object" || value === null || seen.has(value)) return
seen.add(value)
const item = value as Record<string, unknown>
for (const key of ["status", "statusCode"] as const) {
const status = item[key]
if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) return status
}
return visit(item.cause) ?? visit(item.data)
}
return visit(error)
}
+16 -9
View File
@@ -7,7 +7,7 @@ import { createStore } from "solid-js/store"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { Icon } from "@opencode-ai/ui/icon"
import { errorDescriptionKey } from "./description"
import { errorDescriptionKey, errorStatus } from "./description"
export type InitError = {
name: string
@@ -223,6 +223,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
const language = useLanguage()
const formattedError = () => formatError(props.error, language.t)
const status = () => errorStatus(props.error)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
actionError: undefined as string | undefined,
@@ -277,14 +278,20 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
return (
<div
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
class="relative flex-1 h-screen w-screen min-h-0 overflow-y-auto flex flex-col items-center justify-start sm:justify-center p-4 sm:p-8 font-sans"
data-tauri-drag-region
>
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="w-58.5 opacity-12 shrink-0" />
<div class="w-full max-w-3xl flex flex-col items-center justify-center gap-6 sm:gap-8 my-auto">
<Logo class="w-48 sm:w-58.5 opacity-12 shrink-0" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">{language.t("error.page.title")}</h1>
<p class="text-sm text-text-weak">{language.t(errorDescriptionKey(props.error))}</p>
<h1 class="text-lg font-medium text-text-strong">
{language.t(status() ? "error.page.title.status" : "error.page.title")}
</h1>
<p class="text-sm text-text-weak">
{status()
? language.t("error.page.description.status", { status: status()! })
: language.t(errorDescriptionKey(props.error))}
</p>
</div>
<TextField
value={formattedError()}
@@ -297,7 +304,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
/>
<div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
<Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")}
{language.t(platform.platform === "web" ? "error.page.action.reload" : "error.page.action.restart")}
</Button>
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
@@ -348,8 +355,8 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Show when={store.actionError}>
{(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>}
</Show>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
<div class="flex flex-col items-center gap-2 text-xs text-center">
<div class="flex flex-wrap items-center justify-center gap-1">
{language.t("error.page.report.prefix")}
<button
type="button"
@@ -215,7 +215,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
const handleSessionIdle = (sessionID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -226,7 +226,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
}
append({
directory,
directory: session.location.directory,
time,
viewed: viewedInCurrentSession(sessionID),
type: "turn-complete",
@@ -243,7 +243,6 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
}
const handleSessionError = (
directory: string,
sessionID: string,
error: ErrorNotification["error"],
time: number,
@@ -257,7 +256,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
}
append({
directory,
directory: session?.location.directory,
time,
viewed: viewedInCurrentSession(sessionID),
type: "error",
@@ -275,21 +274,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
}
const unsub = input.sdk.event.listen((event) => {
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
event.type !== "session.execution.failed"
)
return
if (event.type !== "session.execution.succeeded" && event.type !== "session.execution.failed") return
const directory = event.location?.directory
if (!directory) return
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event.data.sessionID, event.data.error, time)
handleSessionError(event.data.sessionID, event.data.error, time)
return
}
handleSessionIdle(directory, event.data.sessionID, time)
handleSessionIdle(event.data.sessionID, time)
})
onCleanup(() => {
meta.disposed = true
@@ -3,7 +3,7 @@ let loads: Record<SoundID, () => Promise<string>> | undefined
function getFiles() {
if (files) return files
files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
files = import.meta.glob("../../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
string,
() => Promise<string>
>
@@ -5,6 +5,7 @@ import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
export function SessionUIProvider(
props: ParentProps<{
@@ -15,9 +16,17 @@ export function SessionUIProvider(
const navigate = useNavigate()
const params = useParams()
const data = useData()
const tabs = useTabs()
const directory = () => props.directory
const href = (sessionID: string) => sessionHref(props.server, sessionID)
const navigateToSession = async (sessionID: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === props.server &&
(item.sessionId === params.id || item.routeSessionId === params.id),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, sessionID, params.id)
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
+19 -2
View File
@@ -7,8 +7,25 @@ export function migrateTabs(value: unknown): Tab[] {
if (!tab || typeof tab !== "object") return []
if (!("server" in tab) || typeof tab.server !== "string") return []
const server = tab.server as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
if (
tab.type === "session" &&
typeof tab.sessionId === "string" &&
(tab.routeSessionId === undefined || typeof tab.routeSessionId === "string") &&
(tab.routeParentId === undefined || typeof tab.routeParentId === "string")
) {
return [
{
type: tab.type,
server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? {
routeSessionId: tab.routeSessionId,
...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}),
}
: {}),
},
]
}
if (
tab.type === "draft" &&
+19 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import type { SessionTab, Tab } from "./tabs"
import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -27,6 +27,24 @@ describe("tab migration", () => {
expect(migrateTabs(null)).toEqual([])
expect(migrateTabs({})).toEqual([])
})
test("preserves the active child route", () => {
expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" },
])
})
test("drops an invalid child route", () => {
expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([])
})
})
test("session tab identity stays rooted while its href follows the child route", () => {
const parent = sessionTab("parent")
const child = { ...parent, routeSessionId: "child" }
expect(tabKey(child)).toBe(tabKey(parent))
expect(tabHref(child)).toContain("/session/child")
})
describe("tab memory", () => {
+32 -6
View File
@@ -19,6 +19,8 @@ export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
routeSessionId?: string
routeParentId?: string
}
export type DraftTab = {
@@ -43,12 +45,18 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.routeSessionId ?? tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
(tab.sessionId === session.id || tab.routeSessionId === session.id),
)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@@ -65,7 +73,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [info, setInfo, , infoReady] = persisted(
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
@@ -281,7 +292,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessionTab(input: Omit<SessionTab, "type">) {
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
const index = store.findIndex(
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
(tab) =>
tab.type === "session" &&
tab.server === input.server &&
(tab.sessionId === input.sessionId || tab.routeSessionId === input.sessionId),
)
if (index !== -1) removeTab(index)
},
@@ -357,6 +371,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
if (recentKey() !== key) setRecentKey(key)
},
rememberSessionRoute(tab: SessionTab, sessionId: string, parentId?: string) {
const index = store.findIndex((item) => tabKey(item) === tabKey(tab))
if (index === -1) return
setStore(
index,
produce((item) => {
if (item.type !== "session") return
item.routeSessionId = sessionId === item.sessionId ? undefined : sessionId
item.routeParentId = sessionId === item.sessionId ? undefined : parentId
}),
)
},
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
@@ -378,6 +404,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, info, ready, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady }
},
})
+23 -4
View File
@@ -151,15 +151,24 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const [session] = createResource(
const [loadedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
)
const session = createMemo(() => {
const route = layout.route()
if (route.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
if (cached) return cached
const loaded = loadedSession()
return loaded?.id === route.sessionId ? loaded : undefined
})
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
@@ -169,7 +178,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (route.type === "session") {
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
item.type === "session" &&
item.server === route.server &&
(item.sessionId === route.sessionId || item.routeSessionId === route.sessionId),
)
if (main) return main
const s = session()
@@ -190,6 +201,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (!tabs.ready()) return
const tab = currentTab()
if (tab) {
const current = session()
if (
route.type === "session" &&
tab.type === "session" &&
(route.sessionId === tab.sessionId || current?.id === route.sessionId)
) {
tabs.rememberSessionRoute(tab, route.sessionId, current?.parentID)
}
tabs.remember(tab)
return
}
@@ -36,6 +36,7 @@ describe("command palette sessions", () => {
expanded: true,
}
const searches: string[] = []
const gets: string[] = []
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
(resolve, reject) => {
createRoot((dispose) => {
@@ -49,6 +50,10 @@ describe("command palette sessions", () => {
data: [session, { ...session, id: "archived-session", time: { ...session.time, archived: 3 } }],
}
},
get: async (sessionID) => {
gets.push(sessionID)
return session
},
untitled: () => "Untitled",
category: () => "Sessions",
})
@@ -66,6 +71,7 @@ describe("command palette sessions", () => {
)
expect(searches).toEqual(["palette session"])
expect(gets).toEqual([])
expect(result).toHaveLength(1)
expect(getProjectAvatarSource(result[0]?.project?.id, result[0]?.project?.icon)).toBe("home-project-avatar")
expect(result[0]).toMatchObject({
@@ -75,4 +81,31 @@ describe("command palette sessions", () => {
project: { id: stored.id, icon: opened.icon },
})
})
test("loads an exact session when the query looks like an ID", async () => {
const exact = { ...session, id: "ses_12345678901234567890123456", title: "Exact session" }
const gets: string[] = []
const result = await new Promise<Awaited<ReturnType<ReturnType<typeof createServerSessionEntries>>>>(
(resolve, reject) => {
createRoot((dispose) => {
const search = createServerSessionEntries({
server: ServerConnection.Key.make("selected-server"),
opened: () => [],
stored: () => [stored],
load: async () => ({ data: [] }),
get: async (sessionID) => {
gets.push(sessionID)
return exact
},
untitled: () => "Untitled",
category: () => "Sessions",
})
search(exact.id).then(resolve, reject).finally(dispose)
})
},
)
expect(gets).toEqual([exact.id])
expect(result).toMatchObject([{ sessionID: exact.id, title: exact.title }])
})
})
+4 -1
View File
@@ -25,11 +25,13 @@ export default defineConfig({
desktopPlugin,
VitePWA({
strategies: "generateSW",
registerType: "prompt",
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
clientsClaim: false,
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
@@ -41,6 +43,7 @@ export default defineConfig({
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/session-*.js",
"assets/IBMPlexMono-Text-*.woff2",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
+10
View File
@@ -1291,6 +1291,15 @@ export interface CredentialApi<E = never> {
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
}
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1299,6 +1308,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -139,6 +139,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -919,6 +921,14 @@ const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -926,6 +936,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -133,6 +133,8 @@ import type {
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -1251,6 +1253,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -2269,6 +2269,14 @@ export type McpServerNotFoundError = {
export const isMcpServerNotFoundError = (value: unknown): value is McpServerNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "McpServerNotFoundError"
export type ProjectNotFoundError = {
readonly _tag: "ProjectNotFoundError"
readonly projectID: string
readonly message: string
}
export const isProjectNotFoundError = (value: unknown): value is ProjectNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ProjectNotFoundError"
export type FormNotFoundError = { readonly _tag: "FormNotFoundError"; readonly id: string; readonly message: string }
export const isFormNotFoundError = (value: unknown): value is FormNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "FormNotFoundError"
@@ -4379,6 +4387,27 @@ export type CredentialRemoveOutput = void
export type ProjectListOutput = Array<Project>
export type ProjectUpdateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly name?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["name"]
readonly icon?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["icon"]
readonly commands?: {
readonly name?: string
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
readonly commands?: { readonly start?: string }
}["commands"]
}
export type ProjectUpdateOutput = Project
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+24 -1
View File
@@ -50,7 +50,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -81,6 +81,29 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
id: "proj_test",
canonical: "/tmp/project",
commands: { start: "bun install" },
time: { created: 1, updated: 2 },
sandboxes: [],
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json(project)
},
})
expect(await client.project.update({ projectID: "proj_test", commands: { start: "bun install" } })).toEqual(project)
expect(request?.method).toBe("PATCH")
expect(request?.url).toBe("http://localhost:3000/api/project/proj_test")
expect(await request?.json()).toEqual({ commands: { start: "bun install" } })
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
+34 -1
View File
@@ -29,6 +29,13 @@ export type Current = ProjectSchema.Current
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = ProjectSchema.UpdateInput
export type UpdateInput = ProjectSchema.UpdateInput
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Project.NotFoundError", {
projectID: ID,
}) {}
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -47,6 +54,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -145,6 +153,31 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const row = yield* db
.update(ProjectTable)
.set({
name: input.name === undefined ? undefined : input.name || null,
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
commands:
input.commands?.start === undefined
? undefined
: input.commands.start
? { start: input.commands.start }
: null,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ projectID: input.projectID })
const project = fromRow(row)
yield* bus.publish(ProjectSchema.Event.Updated, project)
return project
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -258,7 +291,7 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve })
return Service.of({ list, update, resolve })
}),
)
+5
View File
@@ -13,6 +13,11 @@ export type Current = typeof Current.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Project.UpdateInput
export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
+29 -1
View File
@@ -18,6 +18,8 @@ import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
export { DirectoryUnavailableError } from "./worktree/directory.js"
@@ -87,6 +89,7 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| AppProcess.AppProcessError
| Git.WorktreeError
export interface Strategy {
@@ -147,6 +150,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
@@ -260,6 +264,30 @@ const layer = Layer.effect(
strategy: input.strategy,
}),
)
const project = yield* db
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
if (command && project) {
const shell = process.platform === "win32" ? "cmd" : "bash"
const args = process.platform === "win32" ? ["/c", command] : ["-lc", command]
yield* processService
.run(
ChildProcess.make(shell, args, {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: project.worktree,
OPENCODE_WORKTREE_PATH: result.directory,
},
extendEnv: true,
stdin: "ignore",
}),
)
.pipe(Effect.flatMap(AppProcess.requireSuccess))
}
return result
})
@@ -342,7 +370,7 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
})
export const refreshNode = makeLocationNode({
@@ -78,6 +78,7 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+1
View File
@@ -5,6 +5,7 @@ export const globalProjectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
}),
)
+1
View File
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
update: () => Effect.die("not implemented"),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
+50 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "@opencode-ai/core/project"
@@ -66,6 +66,55 @@ describe("Project.list", () => {
)
})
describe("Project.update", () => {
it.effect("updates and clears project metadata", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const project = yield* Project.Service
const id = Project.ID.make("update")
yield* db
.insert(ProjectTable)
.values({
id,
worktree: abs("/update"),
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run()
expect(
yield* project.update({
projectID: id,
name: "Updated",
icon: { color: "blue", override: "data:image/png;base64,test" },
commands: { start: "bun install" },
}),
).toMatchObject({
id,
name: "Updated",
icon: { color: "blue", override: "data:image/png;base64,test" },
commands: { start: "bun install" },
})
expect(
yield* project.update({
projectID: id,
name: "",
icon: { color: "", override: "" },
commands: { start: "" },
}),
).toMatchObject({ id })
expect((yield* project.list())[0]).toEqual({
id,
canonical: abs("/update"),
time: { created: 1, updated: expect.any(Number) },
sandboxes: [],
})
}),
)
})
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
+37
View File
@@ -192,6 +192,43 @@ describe("Worktree", () => {
}),
)
it.live("runs the project setup script with worktree paths", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* input.db
.update(ProjectTable)
.set({
commands: {
start:
"bun -e \"await Bun.write('setup.json', JSON.stringify([process.env.OPENCODE_WORKTREE_BASE, process.env.OPENCODE_WORKTREE_PATH, process.cwd()]))\"",
},
})
.where(eq(ProjectTable.id, input.projectID))
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
input.sourceDirectory,
created.directory,
created.directory,
])
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
}),
)
it.live("rejects a missing source directory", () =>
Effect.gen(function* () {
const input = yield* setup()
@@ -2,28 +2,13 @@ import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowThemeReady: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
+7 -7
View File
@@ -7,6 +7,7 @@ import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { forgetStore, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -24,7 +25,6 @@ import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const windowIDs = new WeakMap<BrowserWindow, string>()
const themeReady = new WeakMap<BrowserWindow, () => void>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
@@ -59,10 +59,6 @@ export function setAppQuitting(quitting = true) {
registry.setQuitting(quitting)
}
export function getWindowID(win: BrowserWindow) {
return windowIDs.get(win)
}
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
@@ -89,6 +85,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
const create = (id: string = randomUUID()) => {
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
const appearance = windowAppearance(path, paths)
const win = new BrowserWindow({
x: state.x,
y: state.y,
@@ -96,7 +93,11 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
height: state.height,
show: false,
autoHideMenuBar: true,
...windowAppearance(path, paths),
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: [windowIDArgument(id)],
},
})
allowRendererPermissions(win)
@@ -132,7 +133,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
}
const register = (win: BrowserWindow, id: string) => {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
+2
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -7,5 +8,6 @@ ipcRenderer.on(IpcTransportPort, (event) => {
})
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+1
View File
@@ -1,3 +1,4 @@
export type ElectronNative = {
windowID: string
getPathForFile(file: File): string
}
+1 -1
View File
@@ -42,7 +42,7 @@ export type ElectronAPI = {
draftDelete(key: string): Promise<void>
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): Promise<string>
getWindowID(): string
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+1 -1
View File
@@ -86,7 +86,7 @@ export const api: ElectronAPI = {
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => invoke("WindowGetId"),
getWindowID: () => window.electron.windowID,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+64 -6
View File
@@ -4,16 +4,22 @@
import {
AppBaseProviders,
AppInterface,
currentRoute,
PlatformProvider,
preloadRoute,
ServerConnection,
useCommand,
useCurrentRoute,
useLanguage,
useTabs,
useWslServers,
type LayoutRoute,
type UpdaterPlatform,
} from "@opencode-ai/app/desktop"
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
@@ -28,19 +34,51 @@ import { availableStartupServer, readyWslConnections } from "./wsl/connections"
const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus })))
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version })))
const windowState = { id: props.api.getWindowID(), version: props.version }
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
const route = currentRoute(url.pathname, url.search)
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
ready: false,
visible: true,
route,
})
return (
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
{(state) => <DesktopWindow api={props.api} updater={props.updater} windowState={state} />}
</Show>
<>
<DesktopWindow
api={props.api}
updater={props.updater}
windowState={windowState}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<Show when={startup.visible}>
<div
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": startup.ready }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !startup.ready) return
setStartup("visible", false)
}}
>
<LoadingSplash deep={startup.route.type === "draft"} />
</div>
</Show>
</>
)
}
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
function DesktopWindow(props: {
api: ElectronAPI
updater: UpdaterPlatform
windowState: DesktopWindowState
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
@@ -74,10 +112,15 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
)
return (
<Show when={ready()} fallback={<LoadingSplash />}>
<Show when={ready()}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
@@ -109,6 +152,21 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
)
}
function DesktopStartupReady(props: {
routeReady: () => boolean
onReady: () => void
onRoute: (route: LayoutRoute) => void
}) {
const tabs = useTabs()
const route = useCurrentRoute()
createEffect(() => props.onRoute(route()))
createEffect(() => {
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
props.onReady()
})
return null
}
function DesktopEffects(props: { api: ElectronAPI }) {
const command = useCommand()
bindDesktopMenu((id) => command.trigger(id))
@@ -1,8 +1,14 @@
import { Splash } from "@opencode-ai/ui/logo"
export function LoadingSplash() {
export function LoadingSplash(props: { deep: boolean }) {
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<div
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
export const WindowThemeReady = Rpc.make("WindowThemeReady")
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
@@ -26,7 +25,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
},
})
export const WindowRpcs = RpcGroup.make(
WindowGetId,
WindowThemeReady,
WindowGetFocused,
WindowGetFullscreen,
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
})
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
})
})
@@ -0,0 +1,11 @@
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
}
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
}
+2 -2
View File
@@ -30,8 +30,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.6",
"@opentui/solid": ">=0.5.6",
"@opentui/core": ">=0.5.7",
"@opentui/solid": ">=0.5.7",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+9
View File
@@ -62,6 +62,15 @@ export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundEr
{ httpApiStatus: 404 },
) {}
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
"ProjectNotFoundError",
{
projectID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class AgentNotFoundError extends Schema.TaggedError<AgentNotFoundError>()(
"AgentNotFoundError",
{
+17 -1
View File
@@ -1,9 +1,11 @@
import { Project } from "@opencode-ai/schema/project"
import { Schema } from "effect"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ProjectNotFoundError } from "../errors.js"
const root = "/api/project"
const UpdatePayload = Schema.Struct(Struct.omit(Project.UpdateInput.fields, ["projectID"]))
export const ProjectGroup = HttpApiGroup.make("server.project")
.add(
@@ -17,6 +19,20 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.patch("project.update", `${root}/:projectID`, {
params: { projectID: Project.ID },
payload: UpdatePayload,
success: Project.Info,
error: ProjectNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.update",
summary: "Update project",
description: "Update project display metadata and workspace commands.",
}),
),
)
.add(
HttpApiEndpoint.get("project.current", `${root}/current`, {
query: LocationQuery,
+1 -1
View File
@@ -42,7 +42,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project.",
description: "Create a worktree for a project and run its configured setup script.",
}),
),
)
+8
View File
@@ -46,5 +46,13 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "Project" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const UpdateInput = Schema.Struct({
projectID: ID,
name: optional(Schema.String),
icon: optional(Icon),
commands: optional(Commands),
}).annotate({ identifier: "Project.UpdateInput" })
export interface UpdateInput extends Schema.Schema.Type<typeof UpdateInput> {}
const Updated = ephemeral({ type: "project.updated", schema: Info.fields })
export const Event = { Updated, Definitions: inventory(Updated) }
+14
View File
@@ -3,10 +3,24 @@ import { Project } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProjectNotFoundError } from "@opencode-ai/protocol/errors"
export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) =>
handlers
.handle("project.list", () => Project.Service.use((project) => project.list()))
.handle("project.update", (ctx) =>
Project.Service.use((project) =>
project.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
Effect.mapError(
() =>
new ProjectNotFoundError({
projectID: ctx.params.projectID,
message: `Project not found: ${ctx.params.projectID}`,
}),
),
),
),
)
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({
+1 -1
View File
@@ -50,6 +50,7 @@
"test": "bun test src --only-failures"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -75,7 +76,6 @@
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
"remend": "catalog:",
@@ -30,6 +30,15 @@ describe("apply patch files", () => {
])
})
test("removes files without diff changes", () => {
expect(
patchFiles([
{ file: "src/changed.ts", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1, status: "modified" },
{ file: "src/unchanged.ts", patch: "", additions: 0, deletions: 0, status: "modified" },
]).map((file) => file.path),
).toEqual(["src/changed.ts"])
})
test("composes sequential complete patches for the same file", () => {
const before = "const a = 1\nconst b = 2\n"
const middle = "const a = 2\nconst b = 2\n"
@@ -15,18 +15,19 @@ export type ApplyPatchFile = {
export type ApplyPatchFileGroup = Omit<ApplyPatchFile, "view" | "contents"> & { views: ViewDiff[] }
function fileDiff(value: unknown): value is FileDiffInfo {
export function changedFileDiff(value: unknown): value is FileDiffInfo {
if (!value || typeof value !== "object") return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
if (!("additions" in value) || typeof value.additions !== "number") return false
if (!("deletions" in value) || typeof value.deletions !== "number") return false
if (!("status" in value)) return false
return value.status === "added" || value.status === "deleted" || value.status === "modified"
if (value.status !== "added" && value.status !== "deleted" && value.status !== "modified") return false
return value.additions > 0 || value.deletions > 0
}
export function patchFile(value: unknown): ApplyPatchFile | undefined {
if (!fileDiff(value)) return
if (!changedFileDiff(value)) return
return {
path: value.file,
type: value.status === "added" ? "add" : value.status === "deleted" ? "delete" : "update",
@@ -0,0 +1,62 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { parseMarkdownNodes } from "./markdown-solid"
beforeAll(() => GlobalRegistrator.register())
afterAll(() => GlobalRegistrator.unregister())
test("assigns stable paths to elements and individual words", () => {
expect(parseMarkdownNodes('<p class="lead">Hello <strong>streaming</strong> world</p>', true)).toEqual([
{
key: "0",
type: "element",
tag: "p",
attributes: { class: "lead" },
children: [
{ key: "0.0:0", type: "word", text: "Hello" },
{ key: "0.0:1", type: "text", text: " " },
{
key: "0.1",
type: "element",
tag: "strong",
attributes: {},
children: [{ key: "0.1.0:0", type: "word", text: "streaming" }],
},
{ key: "0.2:1", type: "text", text: " " },
{ key: "0.2:2", type: "word", text: "world" },
],
},
])
})
test("keeps completed block text compact", () => {
expect(parseMarkdownNodes("<p>Hello world</p>", false)).toEqual([
{
key: "0",
type: "element",
tag: "p",
attributes: {},
children: [{ key: "0.0", type: "text", text: "Hello world" }],
},
])
})
test("marks words for animation only when requested", () => {
expect(
parseMarkdownNodes("<p>Hello</p>", true).flatMap((node) => (node.type === "element" ? node.children : [node]))[0],
).toEqual({
key: "0.0:0",
type: "word",
text: "Hello",
})
expect(
parseMarkdownNodes("<p>Hello</p>", true, true).flatMap((node) =>
node.type === "element" ? node.children : [node],
)[0],
).toEqual({
key: "0.0:0",
type: "word",
text: "Hello",
animate: true,
})
})
@@ -0,0 +1,73 @@
import { For, onMount } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { Dynamic, render } from "solid-js/web"
type MarkdownNode =
| { key: string; type: "element"; tag: string; attributes: Record<string, string>; children: MarkdownNode[] }
| { key: string; type: "text"; text: string }
| { key: string; type: "word"; text: string; animate?: true }
export function createMarkdownRenderer(root: HTMLDivElement, html: string, words: boolean) {
const [nodes, setNodes] = createStore(parseMarkdownNodes(html, words))
let ready = false
const dispose = render(
() => <For each={nodes}>{(node) => <MarkdownDomNode node={node} animate={() => ready} />}</For>,
root,
)
ready = true
return {
update(next: string, nextWords: boolean, animate = true) {
setNodes(reconcile(parseMarkdownNodes(next, nextWords, animate), { key: "key" }))
},
dispose,
}
}
function MarkdownDomNode(props: { node: MarkdownNode; animate: () => boolean }) {
const node = props.node
if (node.type === "text") return node.text
if (node.type === "word") {
let ref: HTMLSpanElement | undefined
onMount(() => {
if (props.animate() && node.animate) ref?.setAttribute("data-markdown-enter", "")
})
return (
<span ref={ref} data-markdown-word="">
{node.text}
</span>
)
}
return (
<Dynamic component={node.tag} {...node.attributes}>
<For each={node.children}>{(node) => <MarkdownDomNode node={node} animate={props.animate} />}</For>
</Dynamic>
)
}
export function parseMarkdownNodes(html: string, words: boolean, animate = false) {
const template = document.createElement("template")
template.innerHTML = html
return Array.from(template.content.childNodes).flatMap((node, index) => parseNode(node, `${index}`, words, animate))
}
function parseNode(node: Node, key: string, words: boolean, animate: boolean): MarkdownNode[] {
if (node instanceof Text) {
if (!words) return [{ key, type: "text", text: node.data }]
return node.data.split(/(\s+)/).flatMap((text, index): MarkdownNode[] => {
if (!text) return []
if (/^\s+$/.test(text)) return [{ key: `${key}:${index}`, type: "text", text }]
return [{ key: `${key}:${index}`, type: "word", text, ...(animate ? { animate: true as const } : {}) }]
})
}
if (!(node instanceof Element)) return []
return [
{
key,
type: "element",
tag: node.tagName.toLowerCase(),
attributes: Object.fromEntries(Array.from(node.attributes).map((attribute) => [attribute.name, attribute.value])),
children: Array.from(node.childNodes).flatMap((child, index) => parseNode(child, `${key}.${index}`, words, animate)),
},
]
}
@@ -16,6 +16,14 @@
font-size: var(--font-size-base); /* 14px */
line-height: 160%;
[data-markdown-word] {
display: inline;
}
[data-markdown-word][data-markdown-enter] {
animation: markdown-word-enter 180ms ease-out both;
}
/* Spacing for flow */
> *:first-child {
margin-top: 0;
@@ -329,6 +337,21 @@
}
}
@keyframes markdown-word-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="markdown"] [data-markdown-word][data-markdown-enter] {
animation: none;
}
}
[data-component="markdown"] > * {
unicode-bidi: plaintext;
}
@@ -1,4 +1,5 @@
import { markdown } from "@opencode-ai/ui/storybook/fixtures"
import { createSignal, onCleanup } from "solid-js"
import { Markdown } from "./markdown"
export default {
@@ -32,3 +33,22 @@ export const CompactResult = {
</div>
),
}
const streamed =
"Streaming Markdown now keeps existing elements alive while each newly arriving word fades into the response."
function StreamingMarkdown() {
const words = streamed.match(/\S+\s*/g) ?? []
const [count, setCount] = createSignal(1)
const timer = setInterval(() => setCount((value) => Math.min(value + 1, words.length)), 180)
onCleanup(() => clearInterval(timer))
return <Markdown text={words.slice(0, count()).join("")} streaming={count() < words.length} />
}
export const StreamingResponse = {
render: () => (
<div class="mx-auto max-w-[760px] rounded-lg border border-border-weak-base bg-background-base px-5 py-4">
<StreamingMarkdown />
</div>
),
}
+49 -79
View File
@@ -1,5 +1,4 @@
import { useI18n } from "@opencode-ai/ui/context/i18n"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/util/encode"
import {
type ComponentProps,
@@ -32,6 +31,7 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
import { renderMermaidSvg } from "./markdown-mermaid"
import { createMarkdownRenderer } from "./markdown-solid"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -54,6 +54,10 @@ type RenderResult = {
}
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
const renderedMarkdown = new WeakMap<
HTMLDivElement,
{ renderer: ReturnType<typeof createMarkdownRenderer>; raw: string }
>()
function escape(text: string) {
return text
@@ -177,6 +181,17 @@ function disposeCopyButtons(root: Element) {
hosts.forEach(disposeCopyButton)
}
function disposeRenderedMarkdown(root: Element) {
const blocks = [
...(root instanceof HTMLDivElement && root.hasAttribute("data-markdown-block") ? [root] : []),
...Array.from(root.querySelectorAll<HTMLDivElement>("[data-markdown-block]")),
]
blocks.forEach((block) => {
renderedMarkdown.get(block)?.renderer.dispose()
renderedMarkdown.delete(block)
})
}
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
function codeKind(language: string | undefined) {
@@ -185,12 +200,6 @@ function codeKind(language: string | undefined) {
if (shellLanguages.has(value)) return "shell"
}
function codeLanguage(block: HTMLPreElement) {
const code = block.querySelector("code")
if (!(code instanceof HTMLElement)) return
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
}
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
if (language) wrapper.dataset.language = language
else delete wrapper.dataset.language
@@ -200,37 +209,6 @@ function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
else delete wrapper.dataset.codeKind
}
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement
if (!parent) return
const wrapped = parent.getAttribute("data-component") === "markdown-code"
if (!wrapped) {
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, codeLanguage(block))
parent.replaceChild(wrapper, block)
wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels))
return
}
applyCodeMetadata(parent, codeLanguage(block))
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
)
if (buttons.length === 0) {
parent.appendChild(createCopyButton(labels))
return
}
for (const button of buttons.slice(1)) {
disposeCopyButton(button)
button.remove()
}
}
function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) {
if (!code.classList.contains("language-mermaid")) {
clearMermaid(wrapper)
@@ -311,18 +289,6 @@ function markInlineCode(root: HTMLDivElement) {
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
const wrapper = block.parentElement
const code = block.querySelector("code")
if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete)
}
markInlineCode(root)
markCodeLinks(root)
}
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
@@ -552,6 +518,7 @@ export function Markdown(
delete container.dataset.markdownReady
if (content.length === 0) {
disposeCopyButtons(container)
disposeRenderedMarkdown(container)
container.innerHTML = ""
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
return
@@ -572,6 +539,7 @@ export function Markdown(
const child = container.lastElementChild
if (!child) break
disposeCopyButtons(child)
disposeRenderedMarkdown(child)
child.remove()
}
container
@@ -587,6 +555,8 @@ export function Markdown(
onCleanup(() => {
if (copyCleanup) copyCleanup()
const container = root()
if (container) disposeRenderedMarkdown(container)
disposeMarkdownProjection(owner)
activeCodeKeys.forEach(disposeCode)
completedCode.clear()
@@ -647,44 +617,43 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
updateCodeBlock(container, current, block, labels)
return
}
if (
current instanceof HTMLDivElement &&
current.dataset.markdownKey === block.key &&
current.dataset.markdownHash === block.hash
)
return
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
if (existing?.dataset.markdownHash === block.hash) return
const next = document.createElement("div")
const next = existing ?? document.createElement("div")
next.dataset.markdownBlock = ""
next.dataset.markdownKey = block.key
next.dataset.markdownHash = block.hash
next.style.display = "contents"
next.innerHTML = block.html
decorate(next, labels, block.mode === "full")
const source = document.createElement("div")
source.innerHTML = block.html
markInlineCode(source)
markCodeLinks(source)
const html = source.innerHTML
if (!(current instanceof HTMLDivElement)) {
container.appendChild(next)
if (existing) {
const rendered = renderedMarkdown.get(existing)
if (rendered) {
rendered.renderer.update(html, block.mode === "live", rendered.raw !== block.raw)
rendered.raw = block.raw
return
}
existing.innerHTML = ""
renderedMarkdown.set(existing, {
renderer: createMarkdownRenderer(existing, html, block.mode === "live"),
raw: block.raw,
})
return
}
morphdom(current, next, {
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLElement &&
toEl instanceof HTMLElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button"
) {
return false
}
if (fromEl.isEqualNode(toEl)) return false
return true
},
onBeforeNodeDiscarded: (node) => {
if (node instanceof Element) disposeCopyButtons(node)
return true
},
})
renderedMarkdown.set(next, { renderer: createMarkdownRenderer(next, html, block.mode === "live"), raw: block.raw })
if (!current) {
container.appendChild(next)
return
}
disposeCopyButtons(current)
disposeRenderedMarkdown(current)
current.replaceWith(next)
}
function updateCodeBlock(
@@ -756,6 +725,7 @@ function updateCodeBlock(
})
if (current) {
disposeCopyButtons(current)
disposeRenderedMarkdown(current)
current.replaceWith(next)
return
}
@@ -419,11 +419,44 @@
overflow-wrap: anywhere;
}
[data-slot="bash-prompt"] {
user-select: none;
&[data-variant="shell"] {
background: var(--v2-background-bg-base);
[data-slot="bash-pre"] {
padding: 0;
}
[data-slot="bash-pre"] code {
font-family: var(--v2-font-family-code);
font-weight: 440;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="bash-command"],
[data-slot="bash-result"] {
display: block;
padding: 12px;
}
[data-slot="bash-result"] {
border-block-start: 0.5px solid var(--v2-border-border-base);
background: var(--v2-background-bg-layer-01);
color: var(--v2-text-text-muted);
}
[data-slot="bash-command"] {
color: var(--v2-text-text-base);
}
}
}
[data-component="collapsible"].tool-collapsible:has(
> [data-slot="collapsible-content"] [data-component="bash-output"][data-variant="shell"]
) {
--tool-content-gap: 6px;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
@@ -12,10 +12,12 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
import type {
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageCompaction,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { SessionUserActions, SessionUserComment } from "../actions"
@@ -383,6 +385,37 @@ export function MessageDivider(props: { label: string }) {
)
}
export function SessionCompactionMessage(props: { message: SessionMessageCompaction; error: string }) {
const i18n = useI18n()
const summary = () => (props.message.status === "failed" ? "" : props.message.summary)
const error = () => {
if (props.message.status !== "failed" || props.message.error.type === "aborted") return ""
return props.error
}
return (
<div data-component="session-compaction-message">
<MessageDivider label={i18n.t("ui.messagePart.compaction")} />
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
<div data-slot="text-part-body">
<PacedMarkdown
text={summary()}
cacheKey={props.message.id}
streaming={props.message.status === "running"}
/>
</div>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()}
</Card>
</Show>
</div>
)
}
export function AssistantTextContent(props: {
id: string
text: string
@@ -507,6 +507,29 @@ export const terminalPassedDocument = document([
}),
] satisfies SessionMessageInfo[])
export const expandedShellDocument = document([
user("msg_user_shell_expanded", "Inspect the active service and its available feature surface.", 52_100),
assistant({
id: "msg_assistant_shell_expanded",
offset: 52_200,
completed: 52_900,
content: [
completedTool({
id: "tool_shell_expanded",
name: "shell",
offset: 52_300,
args: {
command:
"opencode2 api get /openapi.json > /private/var/folders/j/gd69b2|16y91666jzf3p9g22asdasc0000gn/T/opencode/temp/tes",
},
output:
'{"location":{"directory":"/Users/usrnk1","project":{"id":"global","directory":"/","canonical":"/"}},"data":[{"id":"opencode","name":"OpenCode","description":"Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, migrating from V1 to V2, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase."}]}',
metadata: { exit: 0 },
}),
],
}),
] satisfies SessionMessageInfo[])
export const terminalFailedDocument = document([
user("msg_user_terminal_failed", "Run the focused Session UI tests.", 53_000),
assistant({
@@ -917,7 +940,7 @@ export const retryDocument = document(
{ type: "busy" },
)
export const compactionDocument = document([
const compactionPrelude = [
user("msg_user_compact", "Continue the implementation after compacting context.", 60_000),
assistant({
id: "msg_assistant_before_compact",
@@ -926,6 +949,26 @@ export const compactionDocument = document([
content: [{ type: "text", text: "I inspected the timeline and identified the current message boundary." }],
error: { type: "ExecutionInterrupted", message: "Context compaction started" },
}),
] satisfies SessionMessageInfo[]
export const compactionRunningDocument = document(
[
...compactionPrelude,
{
id: "msg_compaction_running",
type: "compaction",
status: "running",
reason: "auto",
summary: "## Goal\n\nRestore compaction output while preserving timeline notices.",
recent: "",
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[],
{ type: "busy" },
)
export const compactionDocument = document([
...compactionPrelude,
{
id: "msg_compaction_complete",
type: "compaction",
@@ -943,6 +986,30 @@ export const compactionDocument = document([
}),
] satisfies SessionMessageInfo[])
export const compactionFailedDocument = document([
...compactionPrelude,
{
id: "msg_compaction_failed",
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "compaction.failed", message: "The provider rejected the compaction request." },
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[])
export const compactionCancelledDocument = document([
...compactionPrelude,
{
id: "msg_compaction_cancelled",
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "aborted", message: "Compaction cancelled" },
time: { created: STORY_TIME + 63_000 },
},
] satisfies SessionMessageInfo[])
export const subagentDocument = document(
[
user("msg_user_subagent", "Delegate the fixture review and report the result.", 70_000),
@@ -6,7 +6,10 @@ export {
activeQuestionRequest,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
compactionDocument,
compactionFailedDocument,
compactionRunningDocument,
editThenTestDocument,
emptySessionDocument,
fileChangeLoadingDocument,
@@ -533,7 +533,7 @@ function cleanHeading(value: string) {
.trim()
}
function unwrapErrorMessage(message: string) {
export function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => Option.getOrUndefined(decodeJson(value))
const read = (value: string) => {
@@ -21,8 +21,9 @@ import {
SessionUserMessage,
currentContentDefaultOpen,
} from "../message/current-message"
import { SessionCompactionMessage } from "../message/message-content"
import { SessionRetry } from "../components/session-retry"
import { createReactiveTimelineProjection, Timeline, TimelineRow } from "./projection"
import { createReactiveTimelineProjection, Timeline, TimelineRow, unwrapErrorMessage } from "./projection"
const emptyAssistantMessages: SessionMessageAssistant[] = []
type Projection = ReturnType<typeof createReactiveTimelineProjection>
@@ -203,7 +204,6 @@ export function createSessionTimelineRowRenderer(input: {
}
return { label: message.description ?? message.text }
}
if (message.type === "compaction") return { label: i18n.t("ui.messagePart.compaction"), data: message.status }
if (message.type !== "synthetic") return undefined
if (message.description === "Continuing after restart") return { label: message.description }
const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined
@@ -315,6 +315,15 @@ export function createSessionTimelineRowRenderer(input: {
return value
}
const message = createMemo(() => input.projection.messageByID().get(current().messageID))
const compaction = createMemo(() => {
const value = message()
return value?.type === "compaction" ? value : undefined
})
const compactionError = createMemo(() => {
const value = compaction()
if (value?.status !== "failed") return ""
return unwrapErrorMessage(value.error.message)
})
const moved = createMemo(() => {
const value = message()
return value?.type === "location-switched" ? value : undefined
@@ -325,6 +334,15 @@ export function createSessionTimelineRowRenderer(input: {
})
return (
<Frame row={current()}>
<Show when={compaction()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-compaction">
<SessionCompactionMessage message={message()} error={compactionError()} />
</div>
</div>
)}
</Show>
<Show
when={moved()}
fallback={
@@ -440,6 +458,7 @@ export function createSessionTimelineRowRenderer(input: {
if (value._tag !== "Thinking") throw new Error("Expected a thinking timeline row")
return value
}
const animateHeading = createMemo<boolean>((previous) => previous ?? !current().reasoningHeading)
return (
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
@@ -462,8 +481,8 @@ export function createSessionTimelineRowRenderer(input: {
<TextReveal
text={current().reasoningHeading}
class="session-turn-thinking-heading"
travel={25}
duration={700}
travel={animateHeading() ? 25 : 0}
duration={animateHeading() ? 700 : 0}
/>
</span>
</Show>
@@ -1,5 +1,6 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
expandedShellDocument,
recoveryDocument,
standaloneShellCompletedDocument,
standaloneShellRunningDocument,
@@ -72,6 +73,18 @@ export const TestsPassed = {
),
}
export const ExpandedShell = {
render: () => (
<CurrentSessionTimelineStory
title="Expanded shell"
description="The expanded shell separates the command from its output in a full-width card without a detail rail."
document={expandedShellDocument}
width="786px"
shellToolDefaultOpen
/>
),
}
export const TestFailed = {
render: () => (
<CurrentSessionTimelineStory
@@ -3,7 +3,10 @@ import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
compactionDocument,
compactionFailedDocument,
compactionRunningDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
requestHistoryDocument,
@@ -61,6 +64,17 @@ export const ProviderRetry = {
),
}
export const CompactionInProgress = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction in progress"
description="The production divider remains stable while the context summary streams below it."
document={compactionRunningDocument}
width="600px"
/>
),
}
export const CompactionAndContinuation = {
render: () => (
<CurrentSessionTimelineStory
@@ -72,6 +86,28 @@ export const CompactionAndContinuation = {
),
}
export const CompactionFailed = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction failed"
description="A failed compaction keeps the production divider and shows the provider error."
document={compactionFailedDocument}
width="600px"
/>
),
}
export const CompactionCancelled = {
render: () => (
<CurrentSessionTimelineStory
title="Compaction cancelled"
description="An interrupted compaction keeps the production divider without an empty error block."
document={compactionCancelledDocument}
width="600px"
/>
),
}
export const AnsweredQuestionAndDeclinedCommand = {
render: () => (
<CurrentSessionTimelineStory
@@ -33,7 +33,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { AnimatedCountList } from "../components/tool-count-summary"
import { ToolStatusTitle } from "../components/tool-status-title"
import { patchFileGroups } from "../components/apply-patch-file"
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
import { animate } from "motion"
import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2"
import type { SessionMessageAssistantTool, SessionMessageShell } from "@opencode-ai/client/promise"
@@ -1182,7 +1182,7 @@ ToolRegistry.register({
},
})
function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
function ConsoleOutput(props: { copy: string; children: JSX.Element; variant?: "shell" }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
@@ -1194,7 +1194,7 @@ function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
}
return (
<div data-component="bash-output" dir="ltr">
<div data-component="bash-output" data-variant={props.variant} dir="ltr">
<div data-slot="bash-copy">
<Tooltip value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
<IconButton
@@ -1273,10 +1273,7 @@ ToolRegistry.register({
if (typeof props.metadata.command === "string") return props.metadata.command
return ""
}
const text = createMemo(() => {
const out = stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
return `${command()}${out ? "\n\n" + out : ""}`
})
const output = createMemo(() => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n"))
return (
<BasicTool
{...props}
@@ -1288,10 +1285,7 @@ ToolRegistry.register({
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t("ui.tool.shell")}
active={pending()}
/>
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={!open()}>
<Show
@@ -1309,11 +1303,9 @@ ToolRegistry.register({
</div>
)}
>
<ConsoleOutput copy={command()}>
<span data-slot="bash-prompt" aria-hidden="true">
{"$ "}
</span>
{text()}
<ConsoleOutput copy={command()} variant="shell">
<span data-slot="bash-command">{command()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
</ConsoleOutput>
</BasicTool>
)
@@ -1357,11 +1349,7 @@ ToolRegistry.register({
const diff = createMemo(() => {
const files = props.metadata.files
if (!Array.isArray(files)) return undefined
const value = files.find(
(file) => !!file && typeof file === "object" && "file" in file && typeof file.file === "string",
)
if (!value || typeof value !== "object") return undefined
return value
return files.find(changedFileDiff)
})
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, inputPath()))
const path = createMemo(() => {
+1
View File
@@ -131,6 +131,7 @@
--font-family-text: "Inter", sans-serif;
--v2-font-family-sans: "Inter", sans-serif;
--v2-font-family-code: "IBM Plex Mono", var(--font-family-mono);
--line-height-tight: 12px;
--line-height-compact: 16px;
--line-height-base: 20px;