Compare commits

..

13 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 cac531148a fix(desktop): show interrupt loading state 2026-06-26 09:42:26 +00:00
vimtor eeb5b1d8bc core: prevent UI imports compiling TSX source 2026-06-26 10:25:48 +02:00
opencode-agent[bot] f8ceb30b43 chore: generate 2026-06-26 08:06:37 +00:00
usrnk1 78a5a030ce feat(app): refine session UI styling (#33860)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-26 08:04:56 +00:00
opencode-agent[bot] 219ba24d90 chore: update nix node_modules hashes 2026-06-26 05:43:54 +00:00
Aiden Cline e8fea9e63a feat(oauth): unify OAuth callback browser pages (#34025) 2026-06-26 00:36:40 -05:00
opencode-agent[bot] 11537260aa chore: generate 2026-06-26 05:31:26 +00:00
Brendan Allan 929c4aaf29 refactor(app): replace tab drag handling with dndkit (#33880)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-06-26 05:29:58 +00:00
opencode-agent[bot] 19e510f5d2 refactor(app): use dropdown for project selector (#33984) 2026-06-26 13:18:23 +08:00
opencode-agent[bot] 8a1608ed1d chore: update nix node_modules hashes 2026-06-26 05:13:59 +00:00
opencode-agent[bot] 077deb9d82 fix(app): bump ghostty-web to prevent terminal resize hangs (#34020)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-06-26 14:59:18 +10:00
opencode-agent[bot] 41f2705e72 chore: generate 2026-06-26 03:35:39 +00:00
Kit Langton ef5c9f4931 feat(sdk): expose active sessions (#33991) 2026-06-26 03:33:35 +00:00
82 changed files with 2174 additions and 1775 deletions
+1
View File
@@ -174,6 +174,7 @@ _Avoid_: Response envelope
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. - `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. - `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. - `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. - `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? - **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior. - `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
+22 -2
View File
@@ -31,6 +31,10 @@
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.11", "version": "1.17.11",
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -59,7 +63,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -1451,6 +1455,20 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -2287,6 +2305,8 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -3781,7 +3801,7 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=", "x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=", "aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=", "aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE=" "x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
} }
} }
+5 -1
View File
@@ -44,6 +44,10 @@
"vite-plugin-solid": "catalog:" "vite-plugin-solid": "catalog:"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -72,7 +76,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+3 -1
View File
@@ -16,6 +16,7 @@ import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect,
createResource, createResource,
createSignal, createSignal,
ErrorBoundary, ErrorBoundary,
@@ -278,10 +279,11 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings() const settings = useSettings()
createEffect(() => { createRenderEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns() const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled) document.body.classList.toggle("text-[13px]", enabled)
+29 -4
View File
@@ -66,6 +66,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@@ -339,6 +340,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(), () => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length), Math.floor(Math.random() * EXAMPLES.length),
) )
const [submissionState, setSubmissionState] = createStore({ interrupting: false })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({ const motion = (value: number) => ({
opacity: value, opacity: value,
@@ -1180,6 +1182,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
}) })
const interrupt = () => {
if (submissionState.interrupting) return
if (platform.platform !== "desktop" || !props.controls.newLayoutDesigns) return abort()
setSubmissionState("interrupting", true)
return Promise.resolve()
.then(() => abort())
.finally(() => setSubmissionState("interrupting", false))
}
const handleV2Submit = (event: Event) => {
if (!stopping() || platform.platform !== "desktop") return handleSubmit(event)
event.preventDefault()
return interrupt()
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault() event.preventDefault()
@@ -1232,7 +1250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (working()) { if (working()) {
void abort() void interrupt()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return return
@@ -1298,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (working()) { if (working()) {
void abort() void interrupt()
event.preventDefault() event.preventDefault()
} }
return return
@@ -1410,7 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<DockShellForm <DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"} data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit} onSubmit={handleV2Submit}
classList={{ classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true, "group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
@@ -1554,20 +1572,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<span class="relative flex size-7">
<IconButton <IconButton
data-action="prompt-submit" data-action="prompt-submit"
type="submit" type="submit"
disabled={!working() && blank()} disabled={submissionState.interrupting || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1} tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"} icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary" variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50" class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
classList={{ "[&_[data-slot=icon-svg]]:invisible": submissionState.interrupting }}
style={{ style={{
"background-image": "background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)", "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}} }}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-busy={submissionState.interrupting}
/> />
<Show when={submissionState.interrupting}>
<SessionProgressIndicatorV2 class="pointer-events-none absolute inset-[6px] size-4 opacity-50" />
</Show>
</span>
</Tooltip> </Tooltip>
</div> </div>
</DockShellForm> </DockShellForm>
@@ -1,7 +1,8 @@
import { Popover } from "@kobalte/core/popover"
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
add: (title: string, server?: string) => void add: (title: string, server?: string) => void
} }
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: { export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls> controls: Accessor<PromptProjectControls>
onDone: () => void onDone: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "" }) const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined let searchRef: HTMLInputElement | undefined
const selected = () => { const selected = () => {
@@ -53,8 +65,25 @@ export function createPromptProjectController(input: {
.controls() .controls()
.available.map((project) => project.server) .available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return servers().flatMap((server) => [
...projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
actionKey(server!.key),
])
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => { const close = () => {
setStore({ open: false, search: "" }) setStore({ open: false, search: "", active: "" })
input.onDone() input.onDone()
} }
const select = (project: PromptProject) => { const select = (project: PromptProject) => {
@@ -67,7 +96,7 @@ export function createPromptProjectController(input: {
close() close()
} }
const add = (server?: string) => { const add = (server?: string) => {
setStore("open", false) setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server) input.controls().add(language.t("command.project.open"), server)
} }
@@ -75,8 +104,11 @@ export function createPromptProjectController(input: {
selected, selected,
projects, projects,
servers, servers,
projectKey,
actionKey,
open: () => store.open, open: () => store.open,
search: () => store.search, search: () => store.search,
active: () => store.active,
labels: { labels: {
add: () => language.t("session.new.project.add"), add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"), clear: () => language.t("common.clear"),
@@ -86,50 +118,188 @@ export function createPromptProjectController(input: {
add, add,
select, select,
setOpen(open: boolean) { setOpen(open: boolean) {
setStore("open", open) if (open) {
if (open) requestAnimationFrame(() => searchRef?.focus()) setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
}, },
setSearch(value: string) { setSearch(value: string) {
setStore("search", value) const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
},
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
}, },
setSearchRef(el: HTMLInputElement) { setSearchRef(el: HTMLInputElement) {
searchRef = el searchRef = el
}, },
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
} }
} }
export type PromptProjectController = ReturnType<typeof createPromptProjectController> export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: { controller: PromptProjectController }) { export function PromptProjectSelector(props: { controller: PromptProjectController }) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
return ( return (
<Popover <DropdownMenu
open={props.controller.open()} open={props.controller.open()}
placement="bottom-start" placement="bottom-start"
gutter={4} gutter={4}
shift={-6}
modal={false} modal={false}
onOpenChange={(open) => props.controller.setOpen(open)} onOpenChange={(open) => props.controller.setOpen(open)}
> >
<Popover.Trigger as={ProjectTrigger} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<Popover.Portal> <DropdownMenu.Portal>
<Popover.Content <DropdownMenu.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none" ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" /> <Icon name="magnifying-glass" size="small" class="shrink-0" />
<input <input
ref={(el) => props.controller.setSearchRef(el)} ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()} value={props.controller.search()}
placeholder={props.controller.labels.search()} placeholder={props.controller.labels.search()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)} onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
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()
}
}}
/> />
<Show when={props.controller.search().trim()}> <Show when={props.controller.search().trim()}>
<button <button
type="button" type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover" class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={() => props.controller.setSearch("")} onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()} aria-label={props.controller.labels.clear()}
> >
<Icon name="close-small" size="small" /> <Icon name="close-small" size="small" />
@@ -139,15 +309,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<Show <Show
when={props.controller.servers().length > 1} when={props.controller.servers().length > 1}
fallback={ fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}> <For each={props.controller.projects()}>
{(project) => ( {(project) => (
<ProjectItem <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)} )}
</For> </For>
</DropdownMenu.RadioGroup>
} }
> >
<For each={props.controller.servers()}> <For each={props.controller.servers()}>
@@ -156,19 +324,14 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint"> <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name} {server!.name}
</div> </div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}> <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => ( {(project) => (
<ProjectItem <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
project={project}
selected={props.controller.selected()}
onSelect={props.controller.select}
/>
)} )}
</For> </For>
<ProjectAction </DropdownMenu.RadioGroup>
label={props.controller.labels.add()} <ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
onSelect={() => props.controller.add(server!.key)}
/>
</div> </div>
)} )}
</For> </For>
@@ -178,14 +341,15 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<div class="h-px bg-v2-border-border-muted" /> <div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<ProjectAction <ProjectAction
label={props.controller.labels.add()} server={props.controller.servers()[0]?.key}
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)} controller={props.controller}
onSelect={selectAction}
/> />
</div> </div>
</Show> </Show>
</Popover.Content> </DropdownMenu.Content>
</Popover.Portal> </DropdownMenu.Portal>
</Popover> </DropdownMenu>
) )
} }
@@ -205,15 +369,28 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
} }
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "onClick"]) const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const project = () => local.controller.selected() const project = () => local.controller.selected()
return ( return (
<button <button
{...rest} {...rest}
data-action="prompt-project" data-action="prompt-project"
type="button" type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => local.controller.setOpen(true)} classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
> >
<Show <Show
when={project()} when={project()}
@@ -237,42 +414,77 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
function ProjectItem(props: { function ProjectItem(props: {
project: PromptProject project: PromptProject
selected?: PromptProject controller: PromptProjectController
onSelect: (project: PromptProject) => void onSelect: (project: PromptProject) => void
}) { }) {
const key = () => props.controller.projectKey(props.project)
return ( return (
<button <DropdownMenu.RadioItem
type="button" id={key()}
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 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" value={key()}
onClick={() => props.onSelect(props.project)} data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
> >
<ProjectAvatar <ProjectAvatar
fallback={displayName(props.project)} fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)} src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)} variant={getProjectAvatarVariant(props.project.icon?.color)}
/> />
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<Show <DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
when={ <IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
props.selected?.worktree === props.project.worktree && </DropdownMenu.ItemIndicator>
props.selected?.server?.key === props.project.server?.key </DropdownMenu.RadioItem>
}
>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
) )
} }
function ProjectAction(props: { label: string; onSelect: () => void }) { function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return ( return (
<button <DropdownMenu.Item
type="button" id={key()}
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 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" data-option-key={key()}
onClick={props.onSelect} class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
> >
<Icon name="plus" size="small" /> <Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
</button> {props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
) )
} }
@@ -1,141 +0,0 @@
import { describe, expect, test } from "bun:test"
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
import {
canOpenTabRename,
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
forwardTabRef,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
describe("titlebar tab drag", () => {
const layout = {
listLeft: 100,
dividerWidth: 13,
tabWidthById: new Map([
["a", 40],
["b", 40],
["c", 40],
["d", 40],
]),
}
test("moves across multiple tabs from one pointer update", () => {
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
})
test("keeps the current index inside the left hysteresis deadband", () => {
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
})
test("includes slot margins in captured divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
second.style.marginLeft = "6px"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
document.body.append(list)
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
list.remove()
})
test("uses the list gap as the divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
list.style.columnGap = "13.5px"
document.body.append(list)
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
list.remove()
})
})
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("keeps the rendered tab content in the drag preview", () => {
const tab = document.createElement("div")
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
const preview = createTabDragPreview(tab)
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
})
test("captures the grab offset before navigation scrolls the tab", () => {
const tab = document.createElement("div")
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
startX: 100,
startY: 20,
grabOffsetX: 20,
grabOffsetY: 10,
width: 120,
element: tab,
})
})
test("detects when the primary pointer button was released outside the window", () => {
expect(isPrimaryPointerPressed(1)).toBe(true)
expect(isPrimaryPointerPressed(3)).toBe(true)
expect(isPrimaryPointerPressed(0)).toBe(false)
expect(isPrimaryPointerPressed(2)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -1,137 +0,0 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}
export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width)
}
let dividerWidth = 0
if (order.length >= 2) {
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
const secondId = order[1]
for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break
const style = getComputedStyle(slot)
dividerWidth =
gap ||
slot.getBoundingClientRect().width -
tab.getBoundingClientRect().width +
(Number.parseFloat(style.marginLeft) || 0) +
(Number.parseFloat(style.marginRight) || 0)
break
}
}
return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}
export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0
const others = order.filter((id) => id !== draggedId)
let target = currentIndex
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
return target
}
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}
function easeOvershoot(overshoot: number) {
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
}
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft
const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
return left
}
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE
if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
return 0
}
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch" return pointerType !== "touch"
} }
export function isPrimaryPointerPressed(buttons: number) {
return (buttons & 1) !== 0
}
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
const rect = element.getBoundingClientRect()
return {
startX: clientX,
startY: clientY,
grabOffsetX: clientX - rect.left,
grabOffsetY: clientY - rect.top,
width: rect.width,
element,
}
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) { export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing return !dragging && !editing && !committing
} }
export function createTabDragPreview(element: HTMLDivElement) {
return element.cloneNode(true) as HTMLDivElement
}
@@ -9,6 +9,10 @@
justify-content: center; justify-content: center;
} }
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 13.5px; gap: 13.5px;
} }
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
@@ -172,13 +169,6 @@ export function TabNavItem(props: {
}) })
return ( return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
>
<div <div
ref={(el) => { ref={(el) => {
tabRoot = el tabRoot = el
@@ -189,6 +179,7 @@ export function TabNavItem(props: {
data-title-overflow={titleOverflowing()} data-title-overflow={titleOverflowing()}
data-editing={editing()} data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-pressed={props.pressed}
@@ -280,7 +271,6 @@ export function TabNavItem(props: {
/> />
</div> </div>
</div> </div>
</div>
) )
} }
@@ -295,9 +285,6 @@ export function DraftTabItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -305,13 +292,6 @@ export function DraftTabItem(props: {
props.onClose() props.onClose()
} }
return ( return (
<div
data-titlebar-tab-slot
data-tab-key={props.tabKey}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
onPointerDown={props.onPointerDown}
>
<div <div
ref={(el) => forwardTabRef(props.ref, el)} ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab data-titlebar-tab
@@ -319,7 +299,8 @@ export function DraftTabItem(props: {
data-active={props.active} data-active={props.active}
data-dragging={props.dragging} data-dragging={props.dragging}
data-pressed={props.pressed} data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
closeTab(event) closeTab(event)
@@ -370,6 +351,5 @@ export function DraftTabItem(props: {
/> />
</div> </div>
</div> </div>
</div>
) )
} }
+123 -356
View File
@@ -1,18 +1,11 @@
import { import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
captureTabPointerDown,
canStartTabDrag, const sortableTransition = { duration: 0 }
createTabDragPreview,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
import {
ACTIVATION_DISTANCE,
autoscrollSpeed,
captureTabDragLayout,
clampFloaterLeft,
draftOrderChanged,
insertIndexFromVirtualLayout,
movePlaceholder,
pointerDistance,
syncLayoutScroll,
type TabDragLayout,
} from "@/components/titlebar-tab-drag"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: () => number
active: () => boolean active: () => boolean
activeServerKey: ServerConnection.Key activeServerKey: ServerConnection.Key
forceTruncate: boolean forceTruncate: boolean
dragActive: boolean
dragged: () => boolean
pressed: () => boolean
serverCtx: () => ServerCtx | undefined serverCtx: () => ServerCtx | undefined
suppressNavigation: () => boolean
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
@@ -100,11 +81,17 @@ function SessionTabSlot(props: {
}) })
return ( return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }}
>
<TabNavItem <TabNavItem
tabKey={props.id} ref={(el) => {
dragActive={props.dragActive} ref = el
onPointerDown={props.onPointerDown} }}
ref={ref}
href={tabHref(props.tab)} href={tabHref(props.tab)}
server={props.tab.server} server={props.tab.server}
session={session} session={session}
@@ -123,10 +110,50 @@ function SessionTabSlot(props: {
active={props.active()} active={props.active()}
activeServer={props.tab.server === props.activeServerKey} activeServer={props.tab.server === props.activeServerKey}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
suppressNavigation={props.suppressNavigation} dragging={sortable.isDragSource()}
pressed={props.pressed()}
hidden={props.dragged() || !session()}
/> />
</div>
)
}
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
id: string
index: () => number
active: () => boolean
title: string
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
) )
} }
@@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: {
}) { }) {
const global = useGlobal() const global = useGlobal()
const language = useLanguage() const language = useLanguage()
const [drag, setDrag] = createStore({
active: false,
draggedId: undefined as string | undefined,
placeholderIndex: 0,
draftOrder: [] as string[],
initialOrder: [] as string[],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
const [gesture, setGesture] = createStore({
pending: undefined as
| {
id: string
startX: number
startY: number
grabOffsetX: number
grabOffsetY: number
pointerId: number
width: number
element: HTMLDivElement
}
| undefined,
})
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
const [pressedId, setPressedId] = createSignal<string | undefined>()
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let dragLayout: TabDragLayout | undefined
let dragPointerId: number | undefined
let autoscrollFrame: number | undefined
let resizeFrame: number | undefined let resizeFrame: number | undefined
let dragPreview: HTMLDivElement | undefined
const tabIds = () => props.tabs.map(tabKey) const tabIds = () => props.tabs.map(tabKey)
const displayTabs = createMemo(() => {
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
})
function refreshOverflow() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
resizeFrame = requestAnimationFrame(() => { resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined resizeFrame = undefined
refreshOverflow() refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
}) })
}, },
) )
function syncScroll() {
if (!scrollRef || !listRef || !dragLayout) return
syncLayoutScroll(listRef, dragLayout)
setStripScrollLeft(scrollRef.scrollLeft)
updateInsertIndex()
}
function stopAutoscroll() {
if (autoscrollFrame === undefined) return
cancelAnimationFrame(autoscrollFrame)
autoscrollFrame = undefined
}
function tickAutoscroll() {
if (!drag.active || !scrollRef) return
const strip = scrollRef.getBoundingClientRect()
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
if (speed !== 0) {
scrollRef.scrollLeft += speed
syncScroll()
}
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function startAutoscroll() {
stopAutoscroll()
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function applyPlaceholderIndex(nextIndex: number) {
const id = drag.draggedId
if (!id) return
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
setDrag({
draftOrder: next,
placeholderIndex: nextIndex,
})
}
function updateInsertIndex() {
if (!drag.active || !dragLayout) return
const draggedId = drag.draggedId
if (!draggedId) return
const nextIndex = insertIndexFromVirtualLayout(
drag.pointerX,
drag.draftOrder,
draggedId,
drag.placeholderIndex,
dragLayout,
)
if (nextIndex === drag.placeholderIndex) return
applyPlaceholderIndex(nextIndex)
}
function startDrag(id: string) {
const order = tabIds()
const index = order.indexOf(id)
const pending = gesture.pending
if (index === -1 || !pending || !listRef || !scrollRef) return
dragLayout = captureTabDragLayout(listRef, order)
dragPreview = createTabDragPreview(pending.element)
dragPointerId = pending.pointerId
setGesture("pending", undefined)
setDrag({
active: true,
draggedId: id,
placeholderIndex: index,
draftOrder: order,
initialOrder: order,
draggedWidth: pending.width,
pointerX: pending.startX,
grabOffsetX: pending.grabOffsetX,
floaterTop: pending.startY - pending.grabOffsetY,
})
setPressedId(undefined)
setStripScrollLeft(scrollRef.scrollLeft)
startAutoscroll()
}
function endDrag(commit: boolean) {
const initial = drag.initialOrder
const final = drag.draftOrder
const moved = drag.active
if (commit && moved && draftOrderChanged(initial, final)) {
props.onReorder(final)
}
if (moved) setSuppressNavigation(true)
setDrag({
active: false,
draggedId: undefined,
placeholderIndex: 0,
draftOrder: [],
initialOrder: [],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
dragLayout = undefined
dragPreview = undefined
dragPointerId = undefined
setGesture("pending", undefined)
setPressedId(undefined)
stopAutoscroll()
refreshOverflow()
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return
const target = event.currentTarget as HTMLDivElement
const tabEl = target.matches("[data-titlebar-tab]")
? target
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id)
if (!tab) return
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
setSuppressNavigation(true)
props.onNavigate(tab, tabEl)
setPressedId(id)
setGesture("pending", {
id,
pointerId: event.pointerId,
...pointer,
})
}
function onPointerMove(event: PointerEvent) {
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
if (!isPrimaryPointerPressed(event.buttons)) {
if (drag.active) endDrag(true)
if (pending) {
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
return
}
if (pending && !drag.active) {
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
startDrag(pending.id)
}
if (!drag.active) return
setDrag("pointerX", event.clientX)
syncScroll()
}
function onPointerUp(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
setDrag("pointerX", event.clientX)
syncScroll()
endDrag(true)
return
}
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerCancel(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
endDrag(false)
return
}
if (!gesture.pending) return
if (gesture.pending.pointerId !== event.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
onMount(() => { onMount(() => {
const cleanups = [
makeEventListener(window, "pointermove", onPointerMove),
makeEventListener(window, "pointerup", onPointerUp),
makeEventListener(window, "pointercancel", onPointerCancel),
]
refreshOverflow() refreshOverflow()
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
}) })
onCleanup(stopAutoscroll)
onCleanup(() => { onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
}) })
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
refreshOverflow() refreshOverflow()
}) })
createEffect(() => {
if (!drag.active || !scrollRef) return
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
})
const floaterStyle = () => {
stripScrollLeft()
const strip = scrollRef?.getBoundingClientRect()
const left = strip
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
: drag.pointerX - drag.grabOffsetX
return {
position: "fixed" as const,
top: `${drag.floaterTop}px`,
left: `${left}px`,
width: `${drag.draggedWidth}px`,
"z-index": "10000",
"pointer-events": "none" as const,
}
}
const draggedTab = createMemo(() => {
const id = drag.draggedId
if (!id) return
return props.tabs.find((tab) => tabKey(tab) === id)
})
return ( return (
<>
<div data-slot="titlebar-tabs" class="relative min-w-0"> <div data-slot="titlebar-tabs" class="relative min-w-0">
<div <div
data-slot="titlebar-tabs-scroll" data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]" class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef} ref={scrollRef}
> >
<div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}> <DragDropProvider
<For each={displayTabs()}> sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!canStartTabDrag(event.pointerType) ||
isTabCloseTarget(event.target) ||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
}),
]}
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== Accessibility),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragStart={(event) => {
const source = event.operation.source
if (!source) return
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
if (!tab) return
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
props.onNavigate(tab, tabEl ?? undefined)
}}
onDragEnd={(event) => {
const current = tabIds()
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
const { initialIndex, index } = source
if (initialIndex !== index) {
props.onReorder(arrayMove(current, source.initialIndex, source.index))
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
{(tab, index) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref)) useTabShortcut(index, () => props.onNavigate(tab, ref))
const dragged = () => drag.active && drag.draggedId === id
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -479,46 +264,38 @@ export function TitlebarTabStrip(props: {
<SessionTabSlot <SessionTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey} activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
dragActive={drag.active}
dragged={dragged}
pressed={() => pressedId() === id}
serverCtx={serverCtx} serverCtx={serverCtx}
suppressNavigation={() => suppressNavigation()} onNavigate={(element) => {
onPointerDown={(event) => { ref = element
if (dragged()) return props.onNavigate(tab, element)
onPointerDown(id, event)
}} }}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
/> />
) )
} }
return ( return (
<DraftTabItem <DraftTabSlot
tabKey={id} tab={tab}
dragActive={drag.active} id={id}
onPointerDown={(event) => { index={index}
if (dragged()) return active={() => props.currentTab() === tab}
onPointerDown(id, event)
}}
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)} onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/> />
) )
}} }}
</For> </For>
</div> </div>
</DragDropProvider>
</div> </div>
<div <div
data-slot="titlebar-tabs-fade-left" data-slot="titlebar-tabs-fade-left"
@@ -531,16 +308,6 @@ export function TitlebarTabStrip(props: {
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]" class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/> />
</div> </div>
<Show when={drag.active && draggedTab() && dragPreview}>
{(_) => (
<Portal>
<div data-titlebar-tab-preview style={floaterStyle()}>
{dragPreview}
</div>
</Portal>
)}
</Show>
</>
) )
} }
+10 -2
View File
@@ -908,7 +908,13 @@ export default function Page() {
) )
const reviewPanel = () => ( const reviewPanel = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict"> <div
classList={{
"flex flex-col h-full overflow-hidden contain-strict": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({ {reviewContent({
diffStyle: layout.review.diffStyle(), diffStyle: layout.review.diffStyle(),
@@ -1713,7 +1719,9 @@ export default function Page() {
> >
<div <div
classList={{ classList={{
"flex-1 min-h-0 flex flex-col bg-background-stronger": true, "flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(), "rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
@@ -1,5 +1,6 @@
import { Show, type JSX } from "solid-js" import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
@@ -13,6 +14,7 @@ export function SessionComposerRegion(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const controller = props.controller const controller = props.controller
const settings = useSettings()
const rolled = () => { const rolled = () => {
const revert = controller.revert() const revert = controller.revert()
return revert?.items.length ? revert : undefined return revert?.items.length ? revert : undefined
@@ -22,7 +24,11 @@ export function SessionComposerRegion(props: {
<div <div
ref={controller.setDockRef} ref={controller.setDockRef}
data-component="session-prompt-dock" data-component="session-prompt-dock"
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none" classList={{
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
> >
<div <div
classList={{ classList={{
@@ -1019,7 +1019,13 @@ export function MessageTimeline(props: {
<div class="flex w-max min-w-full justify-end gap-2"> <div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}> <Index each={comments()}>
{(comment) => ( {(comment) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> <div
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" /> <FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment().path)}</span> <span class="truncate">{getFilename(comment().path)}</span>
@@ -1288,7 +1294,11 @@ export function MessageTimeline(props: {
<div <div
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!settings.general.newLayoutDesigns(),
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
@@ -1509,7 +1519,11 @@ export function MessageTimeline(props: {
<Button <Button
size="large" size="large"
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class={
settings.general.newLayoutDesigns()
? "w-full shadow-none border-[0.5px] border-border-weak-base"
: "w-full shadow-none border border-border-weak-base"
}
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={unshareMutation.isPending}
> >
+74 -67
View File
@@ -55,43 +55,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0] const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] } raw["session.active"]({}).pipe(
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) => Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_3Input = { type Endpoint0_4Input = {
readonly sessionID: Endpoint0_3Request["params"]["sessionID"] readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly agent: Endpoint0_3Request["payload"]["agent"] readonly agent: Endpoint0_4Request["payload"]["agent"]
} }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_4Input = { type Endpoint0_5Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"] readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly model: Endpoint0_4Request["payload"]["model"] readonly model: Endpoint0_5Request["payload"]["model"]
} }
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_5Input = { type Endpoint0_6Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
readonly id?: Endpoint0_5Request["payload"]["id"] readonly id?: Endpoint0_6Request["payload"]["id"]
readonly prompt: Endpoint0_5Request["payload"]["prompt"] readonly prompt: Endpoint0_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_5Request["payload"]["delivery"] readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_5Request["payload"]["resume"] readonly resume?: Endpoint0_6Request["payload"]["resume"]
} }
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@@ -100,23 +106,23 @@ const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_8Input = { type Endpoint0_9Input = {
readonly sessionID: Endpoint0_8Request["params"]["sessionID"] readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_8Request["payload"]["messageID"] readonly messageID: Endpoint0_9Request["payload"]["messageID"]
readonly files?: Endpoint0_8Request["payload"]["files"] readonly files?: Endpoint0_9Request["payload"]["files"]
} }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files }, payload: { messageID: input.messageID, files: input.files },
@@ -125,30 +131,30 @@ const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] } type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) => const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.events"]>[0] type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_12Input = { type Endpoint0_13Input = {
readonly sessionID: Endpoint0_12Request["params"]["sessionID"] readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
readonly after?: Endpoint0_12Request["query"]["after"] readonly after?: Endpoint0_13Request["query"]["after"]
} }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -156,17 +162,17 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I
), ),
) )
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] } type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_14Input = { type Endpoint0_15Input = {
readonly sessionID: Endpoint0_14Request["params"]["sessionID"] readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
readonly messageID: Endpoint0_14Request["params"]["messageID"] readonly messageID: Endpoint0_15Request["params"]["messageID"]
} }
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -175,19 +181,20 @@ const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14I
const adaptGroup0 = (raw: RawClient["server.session"]) => ({ const adaptGroup0 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw), list: Endpoint0_0(raw),
create: Endpoint0_1(raw), create: Endpoint0_1(raw),
get: Endpoint0_2(raw), active: Endpoint0_2(raw),
switchAgent: Endpoint0_3(raw), get: Endpoint0_3(raw),
switchModel: Endpoint0_4(raw), switchAgent: Endpoint0_4(raw),
prompt: Endpoint0_5(raw), switchModel: Endpoint0_5(raw),
compact: Endpoint0_6(raw), prompt: Endpoint0_6(raw),
wait: Endpoint0_7(raw), compact: Endpoint0_7(raw),
stage: Endpoint0_8(raw), wait: Endpoint0_8(raw),
clear: Endpoint0_9(raw), stage: Endpoint0_9(raw),
commit: Endpoint0_10(raw), clear: Endpoint0_10(raw),
context: Endpoint0_11(raw), commit: Endpoint0_11(raw),
events: Endpoint0_12(raw), context: Endpoint0_12(raw),
interrupt: Endpoint0_13(raw), events: Endpoint0_13(raw),
message: Endpoint0_14(raw), interrupt: Endpoint0_14(raw),
message: Endpoint0_15(raw),
}) })
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
+12
View File
@@ -3,6 +3,7 @@ import type {
SessionsListOutput, SessionsListOutput,
SessionsCreateInput, SessionsCreateInput,
SessionsCreateOutput, SessionsCreateOutput,
SessionsActiveOutput,
SessionsGetInput, SessionsGetInput,
SessionsGetOutput, SessionsGetOutput,
SessionsSwitchAgentInput, SessionsSwitchAgentInput,
@@ -198,6 +199,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsActiveOutput }>(
{
method: "GET",
path: `/api/session/active`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) => get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>( request<{ readonly data: SessionsGetOutput }>(
{ {
+2 -10
View File
@@ -243,6 +243,8 @@ export type SessionsCreateOutput = {
} }
}["data"] }["data"]
export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = { export type SessionsGetOutput = {
@@ -572,7 +574,6 @@ export type SessionsContextOutput = {
> >
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> } readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string readonly finish?: string
readonly settlement?: "completed" | "failed" | "interrupted"
readonly cost?: number readonly cost?: number
readonly tokens?: { readonly tokens?: {
readonly input: number readonly input: number
@@ -796,14 +797,6 @@ export type SessionsEventsOutput =
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: "unknown"; readonly message: string }
} }
} }
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.step.interrupted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly assistantMessageID: string }
}
| { | {
readonly id: string readonly id: string
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
@@ -1198,7 +1191,6 @@ export type SessionsMessageOutput = {
> >
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> } readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string readonly finish?: string
readonly settlement?: "completed" | "failed" | "interrupted"
readonly cost?: number readonly cost?: number
readonly tokens?: { readonly tokens?: {
readonly input: number readonly input: number
+8 -1
View File
@@ -37,6 +37,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
if (url.includes("/message/")) { if (url.includes("/message/")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage }))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage })))
} }
if (url.endsWith("/api/session/active")) {
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
)
}
if (request.method === "POST" && url.endsWith("/api/session")) { if (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
} }
@@ -50,6 +55,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const result = await Effect.gen(function* () { const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 }) const page = yield* client.sessions.list({ limit: 10 })
const active = yield* client.sessions.active()
const created = yield* client.sessions.create({ const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
@@ -74,10 +80,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"), messageID: SessionMessage.ID.make("msg_model"),
}) })
return { page, created, admitted, context, events, message } return { page, active, created, admitted, context, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test") expect(result.created.id).toBe("ses_test")
+5 -1
View File
@@ -32,6 +32,7 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
@@ -39,6 +40,7 @@ test("session methods use the public HTTP contract", async () => {
}) })
const page = await client.sessions.list({ limit: "10", order: "desc" }) const page = await client.sessions.list({ limit: "10", order: "desc" })
const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({ await client.sessions.switchModel({
@@ -59,6 +61,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next") expect(page.cursor.next).toBe("next")
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
@@ -66,6 +69,7 @@ test("session methods use the public HTTP contract", async () => {
expect(message).toEqual(modelSwitchedMessage) expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc"], ["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"], ["POST", "http://localhost:3000/api/session/ses_test/model"],
@@ -77,7 +81,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
]) ])
const body = requests[4]?.init?.body const body = requests.find((request) => request.url.endsWith("/api/session/ses_test/prompt"))?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body") if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({ expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" }, prompt: { text: "Hello" },
+276
View File
@@ -0,0 +1,276 @@
// Branded HTML pages for local OAuth callback servers.
//
// These are served by the loopback HTTP servers that finish an OAuth exchange
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
// a fully self-contained HTML string with no external assets, so they work
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
// etc.).
//
// The visual language mirrors the opencode app: the design tokens are a curated
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
// Keep this file in sync with those sources when the brand changes.
export interface CallbackPageOptions {
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
provider?: string
/** Attempt to close the window shortly after success. Defaults to true. */
autoClose?: boolean
}
export function success(options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization successful",
body: renderCard({
status: "success",
headline: "Authorization successful",
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
footnote: "You can close this window and return to opencode.",
}),
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
})
}
export function error(detail: string, options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization failed",
body: renderCard({
status: "error",
headline: "Authorization failed",
message: provider
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
: "opencode couldn't complete authorization.",
detail,
footnote: "Close this window and try again from opencode.",
}),
})
}
export interface BootstrapOptions {
/** Same-origin path the in-browser script POSTs the parsed callback to. */
tokenPath: string
provider?: string
}
// For flows where the credential arrives in the URL fragment (implicit grant),
// the browser must relay it back to the loopback server. This renders a pending
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
// to the success or error state in place.
export function bootstrap(options: BootstrapOptions) {
return renderDocument({
title: "Finishing sign-in",
body: renderCard({
status: "pending",
headline: "Finishing sign-in",
message: options.provider
? `Completing your ${escapeHtml(options.provider)} authorization.`
: "Completing authorization.",
footnote: "You can close this window once sign-in finishes.",
}),
script: bootstrapScript(options),
})
}
export * as OauthCallbackPage from "./page"
type Status = "pending" | "success" | "error"
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
const detail = input.detail?.trim()
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
<div class="brand">${WORDMARK}</div>
<div class="status" aria-hidden="true">
<span class="icon icon-pending">${ICON_SPINNER}</span>
<span class="icon icon-success">${ICON_CHECK}</span>
<span class="icon icon-error">${ICON_CROSS}</span>
</div>
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
<p class="message" id="oc-message">${input.message}</p>
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
</main>`
}
function renderDocument(input: { title: string; body: string; script?: string }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${escapeHtml(input.title)} · opencode</title>
<style>${STYLES}</style>
</head>
<body>
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
</body>
</html>`
}
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
function bootstrapScript(options: BootstrapOptions) {
return `var PROVIDER=${scriptString(options.provider ?? "")};
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
(function(){
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
try{
var hash=new URLSearchParams((window.location.hash||"").slice(1));
var search=new URLSearchParams(window.location.search||"");
var err=hash.get("error")||search.get("error");
var errDescription=hash.get("error_description")||search.get("error_description");
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
if(err){fail(errDescription||err);return}
ok();
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
}catch(e){fail(String(e&&e.message?e.message:e))}
})()`
}
function scriptString(value: string) {
return JSON.stringify(value).replaceAll("<", "\\u003c")
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
// host force a scheme without changing the default.
const LIGHT_VARS = `
--oc-bg: #f8f8f8;
--oc-card: #fcfcfc;
--oc-text-strong: #171717;
--oc-text-base: #6f6f6f;
--oc-text-weak: #8f8f8f;
--oc-border-weak: #e5e5e5;
--oc-icon-strong: #171717;
--oc-icon-base: #8f8f8f;
--oc-icon-weak: #dbdbdb;
--oc-success: #2dba26;
--oc-error: #ed4831;
--oc-detail-bg: #fff8f6;
--oc-detail-border: #fdc3b7;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
const DARK_VARS = `
--oc-bg: #101010;
--oc-card: #161616;
--oc-text-strong: rgba(255,255,255,.936);
--oc-text-base: rgba(255,255,255,.618);
--oc-text-weak: rgba(255,255,255,.422);
--oc-border-weak: #282828;
--oc-icon-strong: #ededed;
--oc-icon-base: #7e7e7e;
--oc-icon-weak: #343434;
--oc-success: #12c905;
--oc-error: #fc533a;
--oc-detail-bg: #28110c;
--oc-detail-border: #6a1206;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
const STYLES = `
:root { color-scheme: light dark;${LIGHT_VARS}
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
:root[data-theme="dark"] {${DARK_VARS} }
:root[data-theme="light"] {${LIGHT_VARS} }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--oc-bg);
color: var(--oc-text-base);
font-family: var(--oc-font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.card {
width: min(100%, 25rem);
padding: 2.25rem 2rem 1.75rem;
background: var(--oc-card);
border: 1px solid var(--oc-border-weak);
border-radius: 14px;
box-shadow: var(--oc-shadow);
text-align: center;
}
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
.brand svg { height: 19px; width: auto; }
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
.icon { display: none; line-height: 0; }
.icon svg { display: block; }
.card[data-status="pending"] .icon-pending,
.card[data-status="success"] .icon-success,
.card[data-status="error"] .icon-error { display: block; }
.icon-success { color: var(--oc-success); }
.icon-error { color: var(--oc-error); }
.icon-pending { color: var(--oc-text-weak); }
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
.detail {
margin: 1.25rem 0 0;
padding: 0.75rem 0.875rem;
text-align: left;
font-family: var(--oc-font-mono);
font-size: 0.8125rem;
line-height: 1.55;
color: var(--oc-text-strong);
background: var(--oc-detail-bg);
border: 1px solid var(--oc-detail-border);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 9.5rem;
overflow: auto;
}
.detail[hidden] { display: none; }
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
@keyframes oc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
`
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
</svg>`
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
+8 -8
View File
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal" import type { PluginInternal } from "../internal"
@@ -58,17 +59,21 @@ const browser = {
const value = url.searchParams.get("code") const value = url.searchParams.get("code")
if (error) { if (error) {
Effect.runFork(Deferred.fail(code, new Error(error))) Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
return return
} }
if (!value || url.searchParams.get("state") !== state) { if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code" const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message))) Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
return return
} }
Effect.runFork(Deferred.succeed(code, value)) Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
}) })
yield* Effect.callback<void, Error>((resume) => { yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error))) server.once("error", (error) => resume(Effect.fail(error)))
@@ -285,8 +290,3 @@ function claim(token: string) {
return return
} }
} }
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
+2
View File
@@ -155,6 +155,7 @@ export interface Interface {
}) => Effect.Effect<void, OperationUnavailableError> }) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError> readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError> readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly revert: { readonly revert: {
@@ -402,6 +403,7 @@ export const layer = Layer.unwrap(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" }) return yield* new OperationUnavailableError({ operation: "wait" })
}), }),
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) { resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID) yield* result.get(sessionID)
yield* execution.resume(sessionID) yield* execution.resume(sessionID)
+8 -1
View File
@@ -5,6 +5,8 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
export interface Interface { export interface Interface {
/** Snapshots active execution owned by this process. */
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Starts execution while idle or joins the active execution. */ /** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
@@ -19,5 +21,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Low-level compatibility layer for callers that only need durable Session recording. */ /** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed( export const noopLayer = Layer.succeed(
Service, Service,
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }), Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.void,
}),
) )
@@ -28,6 +28,7 @@ export const layer = Layer.effect(
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
active: coordinator.active,
interrupt: coordinator.interrupt, interrupt: coordinator.interrupt,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
@@ -210,7 +210,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp draft.time.completed = event.data.timestamp
draft.finish = event.data.finish draft.finish = event.data.finish
draft.settlement = "completed"
draft.cost = event.data.cost draft.cost = event.data.cost
draft.tokens = event.data.tokens draft.tokens = event.data.tokens
if (event.data.snapshot || event.data.files) if (event.data.snapshot || event.data.files)
@@ -225,16 +224,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp draft.time.completed = event.data.timestamp
draft.finish = "error" draft.finish = "error"
draft.settlement = "failed"
draft.error = event.data.error draft.error = event.data.error
}) })
}, },
"session.next.step.interrupted": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp
draft.settlement = "interrupted"
})
},
"session.next.text.started": (event) => { "session.next.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
-1
View File
@@ -381,7 +381,6 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
+3 -1
View File
@@ -4,6 +4,8 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
/** Serializes execution for each key while allowing different keys to run concurrently. */ /** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> { export interface Coordinator<Key, E> {
/** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts execution while idle or joins the active execution. */ /** Starts execution while idle or joins the active execution. */
readonly run: (key: Key) => Effect.Effect<void, E> readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */ /** Registers one coalesced follow-up after newly recorded work. */
@@ -98,5 +100,5 @@ export const make = <Key, E>(options: {
return Fiber.interrupt(entry.owner) return Fiber.interrupt(entry.owner)
}) })
return { run, wake, interrupt } return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
}) })
+3 -3
View File
@@ -290,7 +290,6 @@ export const layer = Layer.effect(
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) { if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.interruptAssistant())
return yield* Effect.interrupt return yield* Effect.interrupt
} }
if ( if (
@@ -299,7 +298,8 @@ export const layer = Layer.effect(
) { ) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.interruptAssistant()) if (publisher.hasActiveAssistant())
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
} }
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause) const failure = Cause.squash(settled.cause)
@@ -307,7 +307,7 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
} }
const stepSettlement = publisher.stepSettlement() const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !publisher.hasProviderError() && !publisher.hasAssistantSettled()) { if (stepSettlement && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture() const endSnapshot = yield* snapshots.capture()
const files = const files =
startSnapshot && endSnapshot startSnapshot && endSnapshot
@@ -66,13 +66,15 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
>() >()
const timestamp = DateTime.now const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined let assistantMessageID: SessionMessage.ID | undefined
let assistantSettled = false let assistantActive = false
let assistantFailed = false
let providerFailed = false let providerFailed = false
let stepSettlement: { readonly finish: SessionMessage.Finish; readonly tokens: ReturnType<typeof tokens> } | undefined let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create() assistantMessageID = SessionMessage.ID.create()
assistantActive = true
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
...input, ...input,
assistantMessageID, assistantMessageID,
@@ -194,38 +196,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments() yield* flushFragments()
}) })
const settleAssistant = Effect.fnUntraced(function* ( const failAssistant = Effect.fnUntraced(function* (message: string) {
publish: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>, if (assistantFailed) return
) {
if (assistantSettled) return
yield* flush() yield* flush()
const assistantMessageID = yield* startAssistant() const assistantMessageID = yield* startAssistant()
assistantSettled = true assistantActive = false
yield* publish(assistantMessageID) assistantFailed = true
})
const failAssistant = (message: string) =>
settleAssistant((assistantMessageID) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Step.Failed, { yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp, timestamp: yield* timestamp,
assistantMessageID, assistantMessageID,
error: { type: "unknown", message }, error: { type: "unknown", message },
}) })
}),
)
const interruptAssistant = () =>
settleAssistant((assistantMessageID) =>
Effect.gen(function* () {
yield* events.publish(SessionEvent.Step.Interrupted, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
}) })
}),
)
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string, message: string,
@@ -412,6 +395,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
case "step-finish": case "step-finish":
yield* flush() yield* flush()
assistantActive = false
if (stepSettlement) return yield* Effect.die("Duplicate step finish") if (stepSettlement) return yield* Effect.die("Duplicate step finish")
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return return
@@ -428,10 +412,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
publish, publish,
flush, flush,
failAssistant, failAssistant,
interruptAssistant,
failUnsettledTools, failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined, hasAssistantStarted: () => assistantMessageID !== undefined,
hasAssistantSettled: () => assistantSettled,
hasProviderError: () => providerFailed, hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement, stepSettlement: () => stepSettlement,
startAssistant, startAssistant,
@@ -70,7 +70,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
const assistant = (message: SessionMessage.Assistant, model: Model) => { const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel = const sameModel =
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined && message.settlement !== "interrupted" const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => { const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning") if (item.type === "reasoning")
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { OauthCallbackPage } from "../src/oauth/page"
describe("OauthCallbackPage", () => {
test("escapes bootstrap options embedded in the inline script", () => {
const html = OauthCallbackPage.bootstrap({
provider: `xAI</script><script>alert("provider")</script>`,
tokenPath: `/token</script><script>alert("path")</script>`,
})
expect(html.match(/<\/script>/g)).toHaveLength(1)
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
})
})
@@ -22,9 +22,11 @@ import { testEffect } from "./lib/effect"
const executionCalls: SessionV2.ID[] = [] const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = []
const wakeCalls: SessionV2.ID[] = [] const wakeCalls: SessionV2.ID[] = []
const activeSessions = new Set<SessionV2.ID>()
const execution = Layer.succeed( const execution = Layer.succeed(
SessionExecution.Service, SessionExecution.Service,
SessionExecution.Service.of({ SessionExecution.Service.of({
active: Effect.sync(() => new Set(activeSessions)),
resume: (sessionID) => resume: (sessionID) =>
Effect.sync(() => { Effect.sync(() => {
executionCalls.push(sessionID) executionCalls.push(sessionID)
@@ -108,6 +110,13 @@ const eventCount = (type: string) =>
) )
describe("SessionV2.prompt", () => { describe("SessionV2.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* SessionV2.Service).active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
it.effect("delegates execution continuation through SessionExecution", () => it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
@@ -66,6 +66,78 @@ describe("SessionRunCoordinator", () => {
), ),
) )
it.effect("snapshots only active executions", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const firstGate = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) =>
Deferred.succeed(key === "first" ? firstStarted : secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(key === "first" ? firstGate : secondGate)),
),
})
expect(Array.from(yield* coordinator.active)).toEqual([])
const first = yield* coordinator.run("first").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first"])
const second = yield* coordinator.run("second").pipe(Effect.forkChild)
yield* Deferred.await(secondStarted)
expect(Array.from(yield* coordinator.active)).toEqual(["first", "second"])
yield* Deferred.succeed(firstGate, undefined)
yield* Fiber.join(first)
expect(Array.from(yield* coordinator.active)).toEqual(["second"])
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(second)
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
),
)
it.effect("cleans active executions after failure and defect", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new Error("failed")
const defect = new Error("defect")
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
})
const failed = yield* coordinator.run("failure").pipe(Effect.exit)
expect(Exit.isFailure(failed) && Cause.hasFails(failed.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
const died = yield* coordinator.run("defect").pipe(Effect.exit)
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
),
)
it.effect("cleans active executions when its scope closes", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const coordinator = yield* Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
return coordinator
}),
)
expect(Array.from(yield* coordinator.active)).toEqual([])
}),
)
it.effect("coalesces wakes received during active execution", () => it.effect("coalesces wakes received during active execution", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -166,6 +238,7 @@ describe("SessionRunCoordinator", () => {
const exit = yield* Fiber.await(resumed) const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1) expect(runs).toBe(1)
}), }),
), ),
@@ -327,7 +327,7 @@ Recent work
]) ])
}) })
test("drops provider-native continuation metadata from interrupted assistant turns", () => { test("drops provider-native continuation metadata from failed assistant turns", () => {
const messages = toLLMMessages( const messages = toLLMMessages(
[ [
SessionMessage.Assistant.make({ SessionMessage.Assistant.make({
@@ -361,7 +361,8 @@ Recent work
time: { created, completed: created }, time: { created, completed: created },
}), }),
], ],
settlement: "interrupted", finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
time: { created, completed: created }, time: { created, completed: created },
}), }),
], ],
@@ -95,6 +95,7 @@ const execution = Layer.effect(
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, interrupt: coordinator.interrupt,
+3 -21
View File
@@ -254,6 +254,7 @@ const execution = Layer.effect(
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }), drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, interrupt: coordinator.interrupt,
@@ -519,7 +520,6 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
{ {
type: "assistant", type: "assistant",
finish: "error", finish: "error",
settlement: "failed",
error: { type: "unknown", message: "Provider unavailable" }, error: { type: "unknown", message: "Provider unavailable" },
content: [fixture.expectedContent], content: [fixture.expectedContent],
}, },
@@ -543,28 +543,12 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild) const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Deferred.await(streamed) yield* Deferred.await(streamed)
yield* Fiber.interrupt(fiber) yield* Fiber.interrupt(fiber)
const { db } = yield* Database.Service
const interruptedVersion = SessionEvent.Step.Interrupted.durable?.version
expect(interruptedVersion).toBe(2)
if (interruptedVersion === undefined) return yield* Effect.die("Step.Interrupted must be durable")
const settlements = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.all()
.pipe(Effect.orDie)
expect(
settlements.filter(({ type }) =>
[SessionEvent.Step.Ended.type, SessionEvent.Step.Failed.type, SessionEvent.Step.Interrupted.type].some((settled) =>
type.startsWith(settled),
),
),
).toEqual([{ type: EventV2.versionedType(SessionEvent.Step.Interrupted.type, interruptedVersion) }])
expect(yield* session.context(sessionID)).toMatchObject([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: prompt }, { type: "user", text: prompt },
{ {
type: "assistant", type: "assistant",
settlement: "interrupted", finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
content: [ content: [
kind === "tool input" kind === "tool input"
? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } }
@@ -2670,7 +2654,6 @@ describe("SessionRunnerLLM", () => {
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
}, },
], ],
settlement: "interrupted",
}, },
]) ])
}), }),
@@ -2815,7 +2798,6 @@ describe("SessionRunnerLLM", () => {
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
}, },
], ],
settlement: "interrupted",
}, },
]) ])
}), }),
+6 -47
View File
@@ -1,6 +1,6 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http" import { createServer } from "http"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const OAUTH_CALLBACK_HOST = "127.0.0.1" const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -9,47 +9,6 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
let currentPort = OAUTH_CALLBACK_PORT let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
interface PendingAuth { interface PendingAuth {
resolve: (code: string) => void resolve: (code: string) => void
reject: (error: Error) => void reject: (error: Error) => void
@@ -98,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!state) { if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack" const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return return
} }
@@ -112,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.reject(new Error(errorMsg)) pending.reject(new Error(errorMsg))
} }
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
stopIfIdle() stopIfIdle()
return return
} }
if (!code) { if (!code) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR("No authorization code provided")) res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
return return
} }
@@ -127,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!pendingAuths.has(state)) { if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack" const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return return
} }
@@ -139,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.resolve(code) pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "MCP" }))
stopIfIdle() stopIfIdle()
} }
+2 -60
View File
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http" import { createServer } from "http"
import open from "open" import open from "open"
@@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string {
return `${DO_AUTHORIZE_URL}?${params.toString()}` return `${DO_AUTHORIZE_URL}?${params.toString()}`
} }
const HTML_CALLBACK = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OpenCode - DigitalOcean Authorization</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
.container { text-align: center; padding: 2rem; max-width: 32rem; }
h1 { color: #e8eef9; margin-bottom: 1rem; }
p { color: #9aa9c0; }
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Finishing sign-in...</h1>
<p id="msg">You can close this window once it says you're signed in.</p>
</div>
<script>
(async function() {
const params = new URLSearchParams((window.location.hash || "").slice(1))
const search = new URLSearchParams(window.location.search)
const error = params.get("error") || search.get("error")
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const detail = await res.text().catch(function () { return "" })
throw new Error(detail || ("callback failed (" + res.status + ")"))
}
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
msgEl.className = "error"
return
}
titleEl.textContent = "Authorization Successful"
msgEl.textContent = "You can close this window and return to OpenCode."
setTimeout(function () { window.close() }, 2000)
} catch (e) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = String(e && e.message ? e.message : e)
msgEl.className = "error"
}
})()
</script>
</body>
</html>`
async function startOAuthServer(): Promise<void> { async function startOAuthServer(): Promise<void> {
if (oauthServer) return if (oauthServer) return
oauthServer = createServer((req, res) => { oauthServer = createServer((req, res) => {
@@ -124,7 +66,7 @@ async function startOAuthServer(): Promise<void> {
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_CALLBACK) res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
return return
} }
+4 -91
View File
@@ -5,7 +5,7 @@ import os from "os"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http" import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool" import { OpenAIWebSocketPool } from "./ws-pool"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com" const ISSUER = "https://auth.openai.com"
@@ -138,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
return response.json() return response.json()
} }
const HTML_SUCCESS = `<!doctype html> // Kept as a named export for plugin.codex tests; delegates to the shared branded page.
<html> export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
export const renderOAuthError = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
interface PendingOAuth { interface PendingOAuth {
pkce: PkceCodes pkce: PkceCodes
@@ -287,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err)) .catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
return return
} }
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth" import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http" import { createServer } from "http"
import open from "open" import open from "open"
@@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) {
return token return token
} }
const HTML_SUCCESS = `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:36rem; padding:2rem;">
<h1 style="color:#7ee787;">Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 1500)</script>
</body>
</html>`
const htmlError = (message: string) => `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:48rem; padding:2rem;">
<h1 style="color:#ff7b72;">Authorization Failed</h1>
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
</div>
</body>
</html>`
async function startOAuthServer() { async function startOAuthServer() {
if (oauthServer) return if (oauthServer) return
@@ -203,7 +181,7 @@ async function startOAuthServer() {
pendingOAuth?.reject(new Error(message)) pendingOAuth?.reject(new Error(message))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -214,7 +192,7 @@ async function startOAuthServer() {
const message = errorDescription || error const message = errorDescription || error
current.reject(new Error(message)) current.reject(new Error(message))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -222,7 +200,7 @@ async function startOAuthServer() {
const message = "Missing authorization code" const message = "Missing authorization code"
current.reject(new Error(message)) current.reject(new Error(message))
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -231,7 +209,7 @@ async function startOAuthServer() {
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
}) })
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
+5 -95
View File
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth" import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http" import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
@@ -285,96 +285,6 @@ export async function pollDeviceCodeToken(
throw new Error("xAI device authorization timed out") throw new Error("xAI device authorization timed out")
} }
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
// CORS allowlist for the loopback callback. The redirect_uri itself is // CORS allowlist for the loopback callback. The redirect_uri itself is
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
// xAI's own auth origins for additional defense-in-depth on the OPTIONS // xAI's own auth origins for additional defense-in-depth on the OPTIONS
@@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err)) .catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "xAI" }))
return return
} }
@@ -9,7 +9,7 @@ describe("public event manifest", () => {
expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
expect(EventManifest.Latest.size).toBe(89) expect(EventManifest.Latest.size).toBe(88)
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false)
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
) )
expect(await response.text()).toContain('<div class="error">The user denied access</div>') expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
}) })
test("binds the callback server to IPv4 loopback", async () => { test("binds the callback server to IPv4 loopback", async () => {
@@ -964,6 +964,7 @@ const scenarios: Scenario[] = [
headers: ctx.headers(), headers: ctx.headers(),
})) }))
.status(400, undefined, "none"), .status(400, undefined, "none"),
http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"),
http.protected http.protected
.post("/api/session", "v2.session.create") .post("/api/session", "v2.session.create")
.at((ctx) => ({ .at((ctx) => ({
+16
View File
@@ -73,6 +73,10 @@ export const SessionsCursor = Schema.String.pipe(
) )
export type SessionsCursor = typeof SessionsCursor.Type export type SessionsCursor = typeof SessionsCursor.Type
const SessionActive = Schema.Struct({
type: Schema.Literal("running"),
}).annotate({ identifier: "SessionActive" })
const SessionsQueryCursor = SessionsCursor.annotate({ const SessionsQueryCursor = SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}) })
@@ -124,6 +128,18 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}), }),
), ),
) )
.add(
HttpApiEndpoint.get("session.active", "/api/session/active", {
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.active",
summary: "List active sessions",
description:
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
}),
),
)
.add( .add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", { HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
-13
View File
@@ -165,7 +165,6 @@ export namespace Step {
schema: { schema: {
...Base, ...Base,
assistantMessageID: SessionMessage.ID, assistantMessageID: SessionMessage.ID,
// Step.Ended v2 was originally persisted with an open string schema.
finish: Schema.String, finish: Schema.String,
cost: Schema.Finite, cost: Schema.Finite,
tokens: Schema.Struct({ tokens: Schema.Struct({
@@ -193,16 +192,6 @@ export namespace Step {
}, },
}) })
export type Failed = typeof Failed.Type export type Failed = typeof Failed.Type
export const Interrupted = Event.define({
type: "session.next.step.interrupted",
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
},
})
export type Interrupted = typeof Interrupted.Type
} }
export namespace Text { export namespace Text {
@@ -469,7 +458,6 @@ export const DurableDefinitions = Event.inventory(
Step.Started, Step.Started,
Step.Ended, Step.Ended,
Step.Failed, Step.Failed,
Step.Interrupted,
Text.Started, Text.Started,
Text.Ended, Text.Ended,
Tool.Input.Started, Tool.Input.Started,
@@ -501,7 +489,6 @@ export const Definitions = Event.inventory(
Step.Started, Step.Started,
Step.Ended, Step.Ended,
Step.Failed, Step.Failed,
Step.Interrupted,
Text.Started, Text.Started,
Text.Delta, Text.Delta,
Text.Ended, Text.Ended,
-15
View File
@@ -21,19 +21,6 @@ export const UnknownError = Schema.Struct({
message: Schema.String, message: Schema.String,
}).annotate({ identifier: "Session.Error.Unknown" }) }).annotate({ identifier: "Session.Error.Unknown" })
export const Finish = Schema.Literals([
"stop",
"length",
"tool-calls",
"content-filter",
"error",
"unknown",
])
export type Finish = typeof Finish.Type
export const Settlement = Schema.Literals(["completed", "failed", "interrupted"])
export type Settlement = typeof Settlement.Type
const Base = { const Base = {
id: ID, id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
@@ -182,9 +169,7 @@ export const Assistant = Schema.Struct({
end: Schema.String.pipe(optional), end: Schema.String.pipe(optional),
files: Schema.Array(RelativePath).pipe(optional), files: Schema.Array(RelativePath).pipe(optional),
}).pipe(optional), }).pipe(optional),
// Projected histories predate the typed provider finish model and may contain arbitrary values.
finish: Schema.String.pipe(optional), finish: Schema.String.pipe(optional),
settlement: Settlement.pipe(optional),
cost: Schema.Finite.pipe(optional), cost: Schema.Finite.pipe(optional),
tokens: Schema.Struct({ tokens: Schema.Struct({
input: Schema.Finite, input: Schema.Finite,
+5 -22
View File
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src"
import { EventManifest } from "../src/event-manifest" import { EventManifest } from "../src/event-manifest"
import { IdeEvent } from "../src/ide-event" import { IdeEvent } from "../src/ide-event"
@@ -10,8 +9,8 @@ import { WorkspaceEvent } from "../src/workspace-event"
describe("public event manifest", () => { describe("public event manifest", () => {
test("owns the complete public event surface", () => { test("owns the complete public event surface", () => {
expect(EventManifest.ServerDefinitions.length).toBe(59) expect(EventManifest.ServerDefinitions.length).toBe(55)
expect(EventManifest.Definitions.length).toBe(89) expect(EventManifest.Definitions.length).toBe(85)
expect(SessionV1.Event.Definitions).toEqual([ expect(SessionV1.Event.Definitions).toEqual([
SessionV1.Event.Created, SessionV1.Event.Created,
SessionV1.Event.Updated, SessionV1.Event.Updated,
@@ -24,8 +23,8 @@ describe("public event manifest", () => {
SessionV1.Event.Diff, SessionV1.Event.Diff,
SessionV1.Event.Error, SessionV1.Event.Error,
]) ])
expect(EventManifest.Latest.size).toBe(89) expect(EventManifest.Latest.size).toBe(85)
expect(EventManifest.Durable.size).toBe(36) expect(EventManifest.Durable.size).toBe(32)
}) })
test("uses canonical definitions for current public events", () => { test("uses canonical definitions for current public events", () => {
@@ -43,28 +42,12 @@ describe("public event manifest", () => {
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
const partDelta = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta) expect(EventManifest.Definitions.slice(40, 43)).toEqual([
expect(partDelta).toBeGreaterThanOrEqual(0)
expect(EventManifest.Definitions.slice(partDelta, partDelta + 3)).toEqual([
SessionV1.Event.PartDelta, SessionV1.Event.PartDelta,
SessionV1.Event.Diff, SessionV1.Event.Diff,
SessionV1.Event.Error, SessionV1.Event.Error,
]) ])
expect(EventManifest.Latest.get("session.next.step.interrupted")).toBe(SessionEvent.Step.Interrupted)
expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
}) })
test("decodes legacy Step.Ended v2 finish strings", () => {
const event = Schema.decodeUnknownSync(SessionEvent.Step.Ended.data)({
sessionID: "ses_legacy",
timestamp: 0,
assistantMessageID: "msg_legacy",
finish: "legacy-provider-reason",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
expect(event.finish).toBe("legacy-provider-reason")
})
}) })
@@ -1,22 +0,0 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { SessionMessage } from "../src/session-message"
test("does not model interruption as a provider finish reason", () => {
expect(() => Schema.decodeUnknownSync(SessionMessage.Finish)("interrupted")).toThrow()
expect(Schema.decodeUnknownSync(SessionMessage.Finish)("error")).toBe("error")
})
test("decodes projected assistant histories with arbitrary finish strings", () => {
const message = Schema.decodeUnknownSync(SessionMessage.Message)({
id: "msg_legacy",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
finish: "legacy-provider-reason",
time: { created: 0, completed: 1 },
})
expect(message).toMatchObject({ type: "assistant", finish: "legacy-provider-reason" })
})
+2
View File
@@ -33,6 +33,7 @@ test("embedded client uses the real router and handlers", async () => {
yield* opencode.sessions.switchModel({ sessionID, model }) yield* opencode.sessions.switchModel({ sessionID, model })
const selected = yield* opencode.sessions.get({ sessionID }) const selected = yield* opencode.sessions.get({ sessionID })
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
const active = yield* opencode.sessions.active()
const admitted = yield* opencode.sessions.prompt({ const admitted = yield* opencode.sessions.prompt({
sessionID, sessionID,
prompt: Prompt.make({ text: "Do not run" }), prompt: Prompt.make({ text: "Do not run" }),
@@ -81,6 +82,7 @@ test("embedded client uses the real router and handlers", async () => {
expect(selected.model?.id).toBe(model.id) expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID) expect(selected.model?.providerID).toBe(model.providerID)
expect(page.data.some((session) => session.id === sessionID)).toBe(true) expect(page.data.some((session) => session.id === sessionID)).toBe(true)
expect(active).toEqual({})
expect(admitted.sessionID).toBe(sessionID) expect(admitted.sessionID).toBe(sessionID)
expect(prompted.type).toBe("session.next.prompted") expect(prompted.type).toBe("session.next.prompted")
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
+14
View File
@@ -333,6 +333,8 @@ import type {
V2QuestionRequestListResponses, V2QuestionRequestListResponses,
V2ReferenceListErrors, V2ReferenceListErrors,
V2ReferenceListResponses, V2ReferenceListResponses,
V2SessionActiveErrors,
V2SessionActiveResponses,
V2SessionCompactErrors, V2SessionCompactErrors,
V2SessionCompactResponses, V2SessionCompactResponses,
V2SessionContextErrors, V2SessionContextErrors,
@@ -5501,6 +5503,18 @@ export class Session3 extends HeyApiClient {
}) })
} }
/**
* List active sessions
*
* Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.
*/
public active<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2SessionActiveResponses, V2SessionActiveErrors, ThrowOnError>({
url: "/api/session/active",
...options,
})
}
/** /**
* Get session * Get session
* *
+37 -77
View File
@@ -28,7 +28,6 @@ export type Event =
| EventSessionNextStepStarted | EventSessionNextStepStarted
| EventSessionNextStepEnded | EventSessionNextStepEnded
| EventSessionNextStepFailed | EventSessionNextStepFailed
| EventSessionNextStepInterrupted
| EventSessionNextTextStarted | EventSessionNextTextStarted
| EventSessionNextTextDelta | EventSessionNextTextDelta
| EventSessionNextTextEnded | EventSessionNextTextEnded
@@ -956,15 +955,6 @@ export type GlobalEvent = {
error: SessionErrorUnknown error: SessionErrorUnknown
} }
} }
| {
id: string
type: "session.next.step.interrupted"
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
}
}
| { | {
id: string id: string
type: "session.next.text.started" type: "session.next.text.started"
@@ -1630,7 +1620,6 @@ export type GlobalEvent = {
| SyncEventSessionNextStepStarted | SyncEventSessionNextStepStarted
| SyncEventSessionNextStepEnded | SyncEventSessionNextStepEnded
| SyncEventSessionNextStepFailed | SyncEventSessionNextStepFailed
| SyncEventSessionNextStepInterrupted
| SyncEventSessionNextTextStarted | SyncEventSessionNextTextStarted
| SyncEventSessionNextTextEnded | SyncEventSessionNextTextEnded
| SyncEventSessionNextReasoningStarted | SyncEventSessionNextReasoningStarted
@@ -2701,6 +2690,10 @@ export type InvalidCursorError = {
message: string message: string
} }
export type SessionActive = {
type: "running"
}
export type SessionNotFoundError = { export type SessionNotFoundError = {
_tag: "SessionNotFoundError" _tag: "SessionNotFoundError"
sessionID: string sessionID: string
@@ -2770,7 +2763,6 @@ export type V2Event =
| V2EventSessionNextStepStarted | V2EventSessionNextStepStarted
| V2EventSessionNextStepEnded | V2EventSessionNextStepEnded
| V2EventSessionNextStepFailed | V2EventSessionNextStepFailed
| V2EventSessionNextStepInterrupted
| V2EventSessionNextTextStarted | V2EventSessionNextTextStarted
| V2EventSessionNextTextDelta | V2EventSessionNextTextDelta
| V2EventSessionNextTextEnded | V2EventSessionNextTextEnded
@@ -3412,22 +3404,6 @@ export type SyncEventSessionNextStepFailed = {
} }
} }
export type SyncEventSessionNextStepInterrupted = {
type: "sync"
id: string
syncEvent: {
type: "session.next.step.interrupted.2"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
assistantMessageID: string
}
}
}
export type SyncEventSessionNextTextStarted = { export type SyncEventSessionNextTextStarted = {
type: "sync" type: "sync"
id: string id: string
@@ -4023,7 +3999,6 @@ export type SessionMessageAssistant = {
files?: Array<string> files?: Array<string>
} }
finish?: string finish?: string
settlement?: "completed" | "failed" | "interrupted"
cost?: number cost?: number
tokens?: { tokens?: {
input: number input: number
@@ -4318,25 +4293,6 @@ export type SessionNextStepFailed = {
} }
} }
export type SessionNextStepInterrupted = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.step.interrupted"
durable?: {
aggregateID: string
seq: number | "NaN" | "Infinity" | "-Infinity"
version: number | "NaN" | "Infinity" | "-Infinity"
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
assistantMessageID: string
}
}
export type SessionNextTextStarted = { export type SessionNextTextStarted = {
id: string id: string
metadata?: { metadata?: {
@@ -5392,25 +5348,6 @@ export type V2EventSessionNextStepFailed = {
} }
} }
export type V2EventSessionNextStepInterrupted = {
id: string
metadata?: {
[key: string]: unknown
}
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
type: "session.next.step.interrupted"
data: {
timestamp: number
sessionID: string
assistantMessageID: string
}
}
export type V2EventSessionNextTextStarted = { export type V2EventSessionNextTextStarted = {
id: string id: string
metadata?: { metadata?: {
@@ -6998,16 +6935,6 @@ export type EventSessionNextStepFailed = {
} }
} }
export type EventSessionNextStepInterrupted = {
id: string
type: "session.next.step.interrupted"
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
}
}
export type EventSessionNextTextStarted = { export type EventSessionNextTextStarted = {
id: string id: string
type: "session.next.text.started" type: "session.next.text.started"
@@ -12017,6 +11944,39 @@ export type V2SessionCreateResponses = {
export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses] export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses]
export type V2SessionActiveData = {
body?: never
path?: never
query?: never
url: "/api/session/active"
}
export type V2SessionActiveErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2SessionActiveError = V2SessionActiveErrors[keyof V2SessionActiveErrors]
export type V2SessionActiveResponses = {
/**
* Success
*/
200: {
data: {
[key: string]: unknown | SessionActive
}
}
}
export type V2SessionActiveResponse = V2SessionActiveResponses[keyof V2SessionActiveResponses]
export type V2SessionGetData = { export type V2SessionGetData = {
body?: never body?: never
path: { path: {
+71
View File
@@ -10187,6 +10187,66 @@
] ]
} }
}, },
"/api/session/active": {
"get": {
"tags": ["sessions"],
"operationId": "v2.session.active",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"patternProperties": {
"^ses": {
"$ref": "#/components/schemas/SessionActive"
}
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
"summary": "List active sessions",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.active({\n ...\n})"
}
]
}
},
"/api/session/{sessionID}": { "/api/session/{sessionID}": {
"get": { "get": {
"tags": ["sessions"], "tags": ["sessions"],
@@ -23445,6 +23505,17 @@
"required": ["_tag", "message"], "required": ["_tag", "message"],
"additionalProperties": false "additionalProperties": false
}, },
"SessionActive": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["running"]
}
},
"required": ["type"],
"additionalProperties": false
},
"SessionNotFoundError": { "SessionNotFoundError": {
"type": "object", "type": "object",
"properties": { "properties": {
+10
View File
@@ -76,6 +76,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
} }
}), }),
) )
.handle(
"session.active",
Effect.fn(function* () {
return {
data: Object.fromEntries(
Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
),
}
}),
)
.handle( .handle(
"session.get", "session.get",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -43,7 +43,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-weak); color: var(--v2-text-text-faint);
[data-component="spinner"] { [data-component="spinner"] {
width: 16px; width: 16px;
@@ -89,14 +89,14 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
&.capitalize { &.capitalize {
text-transform: capitalize; text-transform: capitalize;
} }
&.agent-title { &.agent-title {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
} }
@@ -114,7 +114,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
&.clickable { &.clickable {
cursor: pointer; cursor: pointer;
@@ -122,7 +122,7 @@
transition: color 0.15s ease; transition: color 0.15s ease;
&:hover { &:hover {
color: var(--text-base); color: var(--v2-text-text-muted);
} }
} }
@@ -160,7 +160,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="basic-tool-tool-action"] { [data-slot="basic-tool-tool-action"] {
@@ -178,7 +178,7 @@
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-base) 92%, transparent); background: color-mix(in srgb, var(--background-base) 92%, transparent);
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
@@ -213,7 +213,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--icon-weak); color: var(--v2-text-text-faint);
margin-left: auto; margin-left: auto;
opacity: 0; opacity: 0;
transition: transition:
@@ -233,7 +233,7 @@
} }
[data-slot="basic-tool-tool-subtitle"] { [data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
&:hover, &:hover,
@@ -246,3 +246,34 @@
} }
} }
} }
body:not([data-new-layout]) {
[data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] {
color: var(--text-weak);
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-title"].agent-title {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-subtitle"].clickable:hover,
[data-slot="basic-tool-tool-arg"] {
color: var(--text-base);
}
}
[data-component="task-tool-card"] {
border-width: 1px;
[data-component="task-tool-action"] {
color: var(--icon-weak);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
}
}
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { inlineCodeKind } from "./markdown-inline-code-kind"
describe("inlineCodeKind", () => {
test("leaves code expressions as normal inline code", () => {
expect(
inlineCodeKind(
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
),
).toBeUndefined()
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
expect(inlineCodeKind(`window.api`)).toBeUndefined()
})
test("detects file and directory paths", () => {
expect(inlineCodeKind(`app.tsx`)).toBe("path")
expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
expect(inlineCodeKind(`@opencode-ai/app`)).toBe("path")
expect(inlineCodeKind(`session/status`)).toBe("path")
})
test("detects urls", () => {
expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
})
})
@@ -0,0 +1,15 @@
export function inlineCodeKind(text: string): "path" | "url" | undefined {
if (/^https?:\/\//i.test(text)) return "url"
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
if (/\s/.test(text)) return
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
if (
/[/\\]/.test(text) ||
/^\.\.?[/\\]/.test(text) ||
/\.(tsx?|jsx?|json|py|go|rs|rb|php|css|html|md|sh|ya?ml|toml|sql|c|cpp|h|java|kt|swift|scala|xml|png|jpe?g|gif|svg|ico)$/i.test(
text,
)
)
return "path"
}
+137 -24
View File
@@ -1,9 +1,17 @@
[data-component="markdown"] { [data-component="markdown"] {
--markdown-inline-code-path-color: var(--v2-text-text-code-accent);
--markdown-inline-code-color: var(--v2-text-text-code-accent);
--markdown-inline-code-bg-mix: 8%;
--markdown-shell-code-background: var(--v2-background-bg-layer-02);
--markdown-shell-code-border: var(--v2-border-border-muted);
--markdown-shell-code-color: var(--v2-text-text-base);
--markdown-shell-code-token-color: currentColor;
/* Reset & Base Typography */ /* Reset & Base Typography */
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
overflow-wrap: break-word; overflow-wrap: break-word;
color: var(--text-strong); color: var(--v2-text-text-base);
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */ font-size: var(--font-size-base); /* 14px */
line-height: 160%; line-height: 160%;
@@ -22,15 +30,31 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Headings: Same size, distinguished by color and spacing */ /* Headings: Sized by level, distinguished by color and spacing */
h1, h1 {
h2, font-size: 17px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h2 {
font-size: 15px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
font-size: 14px; font-size: 13px;
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-top: 0px; margin-top: 0px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -40,7 +64,7 @@
/* Emphasis & Strong: Neutral strong color */ /* Emphasis & Strong: Neutral strong color */
strong, strong,
b { b {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
@@ -95,7 +119,7 @@
} }
li::marker { li::marker {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
/* Nested lists spacing */ /* Nested lists spacing */
@@ -112,10 +136,10 @@
/* Blockquotes */ /* Blockquotes */
blockquote { blockquote {
border-left: 2px solid var(--border-weak-base); border-left: 0.5px solid var(--v2-border-border-base);
margin: 1.5rem 0; margin: 1.5rem 0;
padding-left: 0.5rem; padding-left: 0.5rem;
color: var(--text-weak); color: var(--v2-text-text-muted);
font-style: normal; font-style: normal;
} }
@@ -132,13 +156,27 @@
font-size: 13px; font-size: 13px;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
border: 0.5px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-component="markdown-code"] { [data-component="markdown-code"] {
position: relative; position: relative;
} }
[data-component="markdown-code"][data-code-kind="shell"] .shiki {
background: var(--markdown-shell-code-background);
border-color: var(--markdown-shell-code-border);
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code {
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code span {
color: var(--markdown-shell-code-token-color) !important;
}
[data-slot="markdown-copy-button"] { [data-slot="markdown-copy-button"] {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -160,7 +198,7 @@
background: var(--surface-float-base); background: var(--surface-float-base);
color: var(--text-invert-strong); color: var(--text-invert-strong);
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
pointer-events: none; pointer-events: none;
@@ -185,11 +223,11 @@
[data-slot="markdown-copy-button"][data-variant="secondary"] { [data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none; box-shadow: none;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base); color: var(--v2-icon-icon-base);
} }
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
@@ -222,15 +260,28 @@
:not(pre) > code { :not(pre) > code {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings); font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--syntax-string); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
/* font-size: 13px; */ padding: 2px 4px;
border-radius: 4px;
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
}
/* padding: 2px 2px; */ :not(pre) > code[data-inline-code-kind="path"] {
/* margin: 0 1.5px; */ color: var(--markdown-inline-code-path-color);
/* border-radius: 2px; */ background: color-mix(
/* background: var(--surface-base); */ in oklch,
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ var(--markdown-inline-code-path-color) var(--markdown-inline-code-bg-mix),
transparent
);
}
a.external-link > code[data-inline-code-kind="url"] {
color: var(--markdown-inline-code-color);
background: none;
text-decoration: underline;
text-decoration-color: color-mix(in oklch, var(--markdown-inline-code-color) 25%, transparent);
text-underline-offset: 2px;
} }
/* Tables */ /* Tables */
@@ -246,16 +297,16 @@
th, th,
td { td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 1px solid var(--border-weaker-base); border-bottom: 0.5px solid var(--v2-border-border-muted);
padding: 12px; padding: 12px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--border-weak-base); border-bottom: 0.5px solid var(--v2-border-border-base);
} }
/* Images */ /* Images */
@@ -268,6 +319,68 @@
} }
} }
body:not([data-new-layout]) [data-component="markdown"] {
color: var(--text-strong);
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
strong,
b {
color: var(--text-strong);
}
li::marker {
color: var(--text-weak);
}
blockquote {
border-left: 2px solid var(--border-weak-base);
color: var(--text-weak);
}
.shiki {
border-color: var(--border-weak-base);
}
:not(pre) > code {
color: var(--syntax-string);
padding: 0;
border-radius: 0;
background: none;
}
[data-slot="markdown-copy-button"]::after {
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
th,
td {
border-bottom: 1px solid var(--border-weaker-base);
}
th {
color: var(--text-strong);
border-bottom-color: var(--border-weak-base);
}
}
[data-component="markdown"] a.external-link:hover > code { [data-component="markdown"] a.external-link:hover > code {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
@@ -25,6 +25,7 @@ import {
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
type RenderedBlock = type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> }) | (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -140,6 +141,35 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
button.setAttribute("data-tooltip", labels.copy) button.setAttribute("data-tooltip", labels.copy)
} }
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
function codeKind(language: string | undefined) {
const value = language?.toLowerCase()
if (!value) return
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 (!document.body.hasAttribute("data-new-layout")) {
delete wrapper.dataset.language
delete wrapper.dataset.codeKind
return
}
if (language) wrapper.dataset.language = language
else delete wrapper.dataset.language
const kind = codeKind(language)
if (kind) wrapper.dataset.codeKind = kind
else delete wrapper.dataset.codeKind
}
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement const parent = block.parentElement
if (!parent) return if (!parent) return
@@ -147,12 +177,15 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
if (!wrapped) { if (!wrapped) {
const wrapper = document.createElement("div") const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code") wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, codeLanguage(block))
parent.replaceChild(wrapper, block) parent.replaceChild(wrapper, block)
wrapper.appendChild(block) wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels)) wrapper.appendChild(createCopyButton(labels))
return return
} }
applyCodeMetadata(parent, codeLanguage(block))
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter( const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement, (el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
) )
@@ -196,11 +229,23 @@ function markCodeLinks(root: HTMLDivElement) {
} }
} }
function markInlineCode(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
if (!(code instanceof HTMLElement)) continue
delete code.dataset.inlineCodeKind
const kind = inlineCodeKind(code.textContent ?? "")
if (kind) code.dataset.inlineCodeKind = kind
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) { function decorate(root: HTMLDivElement, labels: CopyLabels) {
const blocks = Array.from(root.querySelectorAll("pre")) const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) { for (const block of blocks) {
ensureCodeWrapper(block, labels) ensureCodeWrapper(block, labels)
} }
if (!document.body.hasAttribute("data-new-layout")) return
markInlineCode(root)
markCodeLinks(root) markCodeLinks(root)
} }
@@ -522,6 +567,8 @@ function updateCodeBlock(
const code = existing?.querySelector("code") const code = existing?.querySelector("code")
if (code instanceof HTMLElement) { if (code instanceof HTMLElement) {
const wrapper = code.closest('[data-component="markdown-code"]')
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
code.className = `language-${block.language}` code.className = `language-${block.language}`
const previous = renderedCodeTokens.get(next) const previous = renderedCodeTokens.get(next)
const reset = shouldResetCodeTokens(previous, { const reset = shouldResetCodeTokens(previous, {
@@ -552,6 +599,7 @@ function updateCodeBlock(
const wrapper = document.createElement("div") const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code") wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, block.language)
const pre = document.createElement("pre") const pre = document.createElement("pre")
pre.className = "shiki OpenCode" pre.className = "shiki OpenCode"
const codeElement = document.createElement("code") const codeElement = document.createElement("code")
@@ -14,7 +14,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
@@ -41,15 +41,15 @@
min-width: 0; min-width: 0;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--surface-weak); background: var(--v2-background-bg-layer-02);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
cursor: default; cursor: default;
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
opacity 0.3s ease; opacity 0.3s ease;
&:hover { &:hover {
border-color: var(--border-strong-base); border-color: var(--v2-border-border-strong);
} }
&[data-clickable] { &[data-clickable] {
@@ -107,7 +107,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--text-base); color: var(--v2-text-text-muted);
font-size: var(--font-size-small); font-size: var(--font-size-small);
line-height: var(--line-height-large); line-height: var(--line-height-large);
} }
@@ -130,10 +130,10 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
overflow: hidden; overflow: hidden;
background: var(--surface-base); background: var(--v2-background-bg-layer-02);
border: 1px solid var(--border-weak-base); border: none;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 10px;
[data-highlight="file"] { [data-highlight="file"] {
color: var(--syntax-property); color: var(--syntax-property);
@@ -199,7 +199,7 @@
} }
.text-text-strong { .text-text-strong {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
.font-medium { .font-medium {
@@ -267,7 +267,7 @@
[data-slot="compaction-part-line"] { [data-slot="compaction-part-line"] {
flex: 1 1 auto; flex: 1 1 auto;
height: 1px; height: 1px;
background: var(--border-weak-base); background: var(--v2-border-border-base);
} }
[data-slot="compaction-part-label"] { [data-slot="compaction-part-label"] {
@@ -279,18 +279,18 @@
[data-component="reasoning-part"] { [data-component="reasoning-part"] {
width: 100%; width: 100%;
color: var(--text-base); color: var(--v2-text-text-muted);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
[data-component="markdown"] { [data-component="markdown"] {
margin-top: 16px; margin-top: 16px;
font-style: normal; font-style: normal;
font-size: 13px; font-size: 13px;
color: var(--text-weak); color: var(--v2-text-text-muted);
strong, strong,
b { b {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
p:has(strong) { p:has(strong) {
@@ -357,7 +357,7 @@
[data-component="bash-output"] { [data-component="bash-output"] {
width: 100%; width: 100%;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
position: relative; position: relative;
@@ -380,7 +380,7 @@
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none; box-shadow: none;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] { [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
@@ -442,7 +442,7 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="message-part-title-spinner"] { [data-slot="message-part-title-spinner"] {
@@ -453,7 +453,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-weak); color: var(--v2-text-text-muted);
[data-component="spinner"] { [data-component="spinner"] {
width: 16px; width: 16px;
@@ -464,7 +464,7 @@
[data-slot="message-part-title-text"] { [data-slot="message-part-title-text"] {
flex-shrink: 0; flex-shrink: 0;
text-transform: capitalize; text-transform: capitalize;
color: var(--text-strong); color: var(--v2-text-text-base);
} }
[data-slot="message-part-title-filename"] { [data-slot="message-part-title-filename"] {
@@ -484,7 +484,7 @@
} }
[data-slot="message-part-directory"] { [data-slot="message-part-directory"] {
color: var(--text-weak); color: var(--v2-text-text-muted);
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -493,7 +493,7 @@
} }
[data-slot="message-part-filename"] { [data-slot="message-part-filename"] {
color: var(--text-strong); color: var(--v2-text-text-base);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -508,7 +508,7 @@
[data-component="edit-content"] { [data-component="edit-content"] {
border-radius: inherit; border-radius: inherit;
border-top: 1px solid var(--border-weaker-base); border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible; overflow-y: visible;
@@ -527,7 +527,7 @@
[data-component="write-content"] { [data-component="write-content"] {
border-radius: inherit; border-radius: inherit;
border-top: 1px solid var(--border-weaker-base); border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible; overflow-y: visible;
@@ -561,7 +561,7 @@
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-base);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="basic-tool-tool-subtitle"].exa-tool-query { [data-slot="basic-tool-tool-subtitle"].exa-tool-query {
@@ -583,7 +583,7 @@
max-width: 100%; max-width: 100%;
font: inherit; font: inherit;
line-height: inherit; line-height: inherit;
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
overflow: hidden; overflow: hidden;
@@ -591,11 +591,11 @@
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
} }
&:visited { &:visited {
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
} }
} }
@@ -615,7 +615,7 @@
&[data-completed="completed"] { &[data-completed="completed"] {
text-decoration: line-through; text-decoration: line-through;
color: var(--text-weaker); color: var(--v2-text-text-faint);
} }
} }
} }
@@ -660,8 +660,8 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 8px 12px; padding: 8px 12px;
background-color: var(--surface-critical-weak); background-color: var(--v2-state-bg-danger);
border-top: 1px solid var(--border-critical-base); border-top: 0.5px solid var(--v2-state-border-danger);
[data-slot="diagnostic"] { [data-slot="diagnostic"] {
display: flex; display: flex;
@@ -673,7 +673,7 @@
} }
[data-slot="diagnostic-label"] { [data-slot="diagnostic-label"] {
color: var(--text-on-critical-base); color: var(--v2-state-fg-danger);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: -0.5px; letter-spacing: -0.5px;
@@ -681,12 +681,12 @@
} }
[data-slot="diagnostic-location"] { [data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak); color: var(--v2-state-fg-danger);
flex-shrink: 0; flex-shrink: 0;
} }
[data-slot="diagnostic-message"] { [data-slot="diagnostic-message"] {
color: var(--text-on-critical-base); color: var(--v2-state-fg-danger);
word-break: break-word; word-break: break-word;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -761,7 +761,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
} }
@@ -778,7 +778,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
padding: 0; padding: 0;
} }
@@ -856,7 +856,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
} }
@@ -916,7 +916,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
padding: 0 10px; padding: 0 10px;
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
@@ -927,7 +927,7 @@
font-size: 13px; font-size: 13px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
padding: 0 10px; padding: 0 10px;
} }
@@ -953,8 +953,8 @@
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 8px 8px 8px 10px; padding: 8px 8px 8px 10px;
background-color: var(--surface-raised-stronger-non-alpha); background-color: var(--v2-background-bg-layer-01);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px; border-radius: 6px;
box-shadow: none; box-shadow: none;
text-align: left; text-align: left;
@@ -966,11 +966,11 @@
box-shadow 0.15s ease; box-shadow 0.15s ease;
&:hover:not([data-picked="true"]) { &:hover:not([data-picked="true"]) {
background-color: var(--background-base); background-color: var(--v2-background-bg-base);
} }
&[data-picked="true"] { &[data-picked="true"] {
background-color: var(--surface-interactive-weak); background-color: var(--v2-state-bg-info);
border-color: transparent; border-color: transparent;
box-shadow: var(--shadow-xs-border-hover); box-shadow: var(--shadow-xs-border-hover);
} }
@@ -991,7 +991,7 @@
height: 16px; height: 16px;
padding: 2px; padding: 2px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1014,7 +1014,7 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 999px; border-radius: 999px;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-layer-01);
opacity: 0; opacity: 0;
} }
@@ -1052,7 +1052,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
} }
[data-slot="option-description"] { [data-slot="option-description"] {
@@ -1060,7 +1060,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: normal; white-space: normal;
@@ -1088,7 +1088,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
min-width: 0; min-width: 0;
cursor: text; cursor: text;
resize: none; resize: none;
@@ -1096,11 +1096,11 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
&::placeholder { &::placeholder {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
&:focus-visible { &:focus-visible {
outline: 1px solid var(--border-interactive-base); outline: 0.5px solid var(--v2-border-border-focus);
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
} }
@@ -1139,11 +1139,11 @@
font-size: 13px; font-size: 13px;
[data-slot="question-text"] { [data-slot="question-text"] {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
[data-slot="answer-text"] { [data-slot="answer-text"] {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
} }
} }
@@ -1166,13 +1166,13 @@
z-index: 20; z-index: 20;
height: calc(32px + var(--tool-content-gap)); height: calc(32px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap); padding-bottom: var(--tool-content-gap);
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
} }
} }
[data-component="accordion"][data-scope="apply-patch"] { [data-component="accordion"][data-scope="apply-patch"] {
[data-slot="accordion-trigger"] { [data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important; background-color: var(--v2-background-bg-base) !important;
} }
[data-slot="apply-patch-trigger-content"] { [data-slot="apply-patch-trigger-content"] {
@@ -1199,7 +1199,7 @@
} }
[data-slot="apply-patch-directory"] { [data-slot="apply-patch-directory"] {
color: var(--text-base); color: var(--v2-text-text-muted);
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -1208,7 +1208,7 @@
} }
[data-slot="apply-patch-filename"] { [data-slot="apply-patch-filename"] {
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1268,10 +1268,199 @@
font-size: var(--font-size-small); font-size: var(--font-size-small);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
[data-component="icon"] { [data-component="icon"] {
flex-shrink: 0; flex-shrink: 0;
color: var(--icon-weak); color: var(--icon-weak);
} }
} }
body:not([data-new-layout]) {
[data-component="user-message"] {
color: var(--text-strong);
[data-slot="user-message-attachment"] {
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
&:hover {
border-color: var(--border-strong-base);
}
}
[data-slot="user-message-attachment-name"] {
color: var(--text-base);
}
[data-slot="user-message-text"] {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: 6px;
}
.text-text-strong {
color: var(--text-strong);
}
}
[data-slot="compaction-part-line"] {
background: var(--border-weak-base);
}
[data-component="reasoning-part"] {
color: var(--text-base);
[data-component="markdown"],
[data-component="markdown"] :is(strong, b) {
color: var(--text-weak);
}
}
[data-component="bash-output"] {
border: 1px solid var(--border-weak-base);
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
[data-slot="message-part-title"] {
color: var(--text-base);
}
[data-slot="message-part-title-spinner"],
[data-slot="message-part-directory"] {
color: var(--text-weak);
}
[data-slot="message-part-title-text"],
[data-slot="message-part-filename"] {
color: var(--text-strong);
}
}
[data-component="edit-content"],
[data-component="write-content"] {
border-top: 1px solid var(--border-weaker-base);
}
[data-component="exa-tool-output"] {
color: var(--text-base);
}
[data-slot="exa-tool-link"],
[data-slot="exa-tool-link"]:hover,
[data-slot="exa-tool-link"]:visited {
color: var(--text-interactive-base);
}
[data-slot="message-part-todo-content"][data-completed="completed"] {
color: var(--text-weaker);
}
[data-component="diagnostics"] {
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);
[data-slot="diagnostic-label"],
[data-slot="diagnostic-message"] {
color: var(--text-on-critical-base);
}
[data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak);
}
}
[data-component="dock-prompt"][data-kind="permission"] {
[data-slot="permission-header-title"] {
color: var(--text-strong);
}
[data-slot="permission-hint"] {
color: var(--text-weak);
}
}
[data-component="dock-prompt"][data-kind="question"] {
[data-slot="question-header-title"],
[data-slot="question-text"],
[data-slot="option-label"] {
color: var(--text-strong);
}
[data-slot="question-hint"] {
color: var(--text-weak);
}
[data-slot="question-option"] {
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
}
&[data-picked="true"] {
background-color: var(--surface-interactive-weak);
}
}
[data-slot="question-option-box"] {
border: 1px solid var(--border-weak-base);
[data-slot="question-option-radio-dot"] {
background-color: var(--background-stronger);
}
}
[data-slot="option-description"],
[data-slot="question-custom-input"] {
color: var(--text-base);
}
[data-slot="question-custom-input"] {
&::placeholder {
color: var(--text-weak);
}
&:focus-visible {
outline: 1px solid var(--border-interactive-base);
}
}
}
[data-component="question-answers"] {
[data-slot="question-text"] {
color: var(--text-weak);
}
[data-slot="answer-text"] {
color: var(--text-strong);
}
}
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"][aria-expanded="true"],
[data-component="accordion"][data-scope="apply-patch"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="apply-patch-directory"] {
color: var(--text-base);
}
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
}
}
[data-component="tool-loaded-file"] {
color: var(--text-weak);
}
}
@@ -21,7 +21,7 @@
[data-slot="session-review-header"] { [data-slot="session-review-header"] {
z-index: 120; z-index: 120;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
height: 40px; height: 40px;
padding-bottom: 8px; padding-bottom: 8px;
flex-shrink: 0; flex-shrink: 0;
@@ -211,7 +211,7 @@
[data-slot="session-review-large-diff"] { [data-slot="session-review-large-diff"] {
padding: 12px; padding: 12px;
background: var(--background-stronger); background: var(--v2-background-bg-base);
} }
[data-slot="session-review-large-diff-title"] { [data-slot="session-review-large-diff-title"] {
@@ -235,3 +235,13 @@
margin-top: 10px; margin-top: 10px;
} }
} }
body:not([data-new-layout]) [data-component="session-review"] {
[data-slot="session-review-header"] {
background-color: var(--background-stronger);
}
[data-slot="session-review-large-diff"] {
background: var(--background-stronger);
}
}
@@ -71,7 +71,7 @@
} }
.error-card { .error-card {
color: var(--text-on-critical-base); color: var(--v2-text-text-base);
max-height: 240px; max-height: 240px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -102,7 +102,7 @@
position: sticky; position: sticky;
top: var(--sticky-accordion-top, 0px); top: var(--sticky-accordion-top, 0px);
z-index: 20; z-index: 20;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
height: 44px; height: 44px;
} }
@@ -226,6 +226,16 @@
} }
} }
body:not([data-new-layout]) [data-component="session-turn"] {
.error-card {
color: var(--text-on-critical-base);
}
[data-slot="session-turn-diffs-header"] {
background-color: var(--background-stronger);
}
}
[data-slot="session-turn-list"] { [data-slot="session-turn-list"] {
gap: 24px; gap: 24px;
} }
@@ -2,6 +2,19 @@
--card-pad-y: 8px; --card-pad-y: 8px;
--card-line-pad: 12px; --card-line-pad: 12px;
[data-slot="basic-tool-tool-title"] {
color: var(--v2-text-text-base);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--v2-text-text-muted);
}
[data-slot="collapsible-arrow"],
[data-slot="collapsible-arrow-icon"] {
color: var(--v2-text-text-faint);
}
> [data-component="collapsible"].tool-collapsible { > [data-component="collapsible"].tool-collapsible {
gap: 0px; gap: 0px;
} }
@@ -43,10 +56,34 @@
} }
[data-slot="tool-error-card-content"] :where(*)::selection { [data-slot="tool-error-card-content"] :where(*)::selection {
background: var(--surface-critical-base); background: var(--v2-state-bg-danger);
color: var(--text-on-critical-base); color: var(--v2-text-text-base);
} }
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--v2-state-bg-danger);
color: var(--v2-text-text-base);
}
}
body:not([data-new-layout]) [data-component="card"][data-kind="tool-error-card"] {
[data-slot="basic-tool-tool-title"] {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow-icon"] {
color: var(--icon-weaker);
}
[data-slot="tool-error-card-content"] :where(*)::selection,
[data-slot="tool-error-card-content"] :where(*)::-moz-selection { [data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--surface-critical-base); background: var(--surface-critical-base);
color: var(--text-on-critical-base); color: var(--text-on-critical-base);
-10
View File
@@ -227,7 +227,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = event.data.finish currentAssistant.finish = event.data.finish
currentAssistant.settlement = "completed"
currentAssistant.cost = event.data.cost currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens currentAssistant.tokens = event.data.tokens
if (event.data.snapshot) if (event.data.snapshot)
@@ -240,18 +239,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = "error" currentAssistant.finish = "error"
currentAssistant.settlement = "failed"
currentAssistant.error = event.data.error currentAssistant.error = event.data.error
}) })
break break
case "session.next.step.interrupted":
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp
currentAssistant.settlement = "interrupted"
})
break
case "session.next.text.started": case "session.next.text.started":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({ message.assistant(draft, event.data.assistantMessageID)?.content.push({
-66
View File
@@ -370,72 +370,6 @@ test("settles pending tools when a live failure arrives", async () => {
} }
}) })
test("marks an interrupted assistant inactive without a finish reason", async () => {
const events = createEventSource()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await mounted
emitEvent(events, {
id: "evt_step_started_interrupted",
type: "session.next.step.started",
properties: {
sessionID: "session-interrupted",
assistantMessageID: "msg_interrupted",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitEvent(events, {
id: "evt_step_interrupted",
type: "session.next.step.interrupted",
properties: {
sessionID: "session-interrupted",
assistantMessageID: "msg_interrupted",
timestamp: 2,
},
})
await wait(() => {
const message = sync.session.message.list("session-interrupted")?.[0]
return message?.type === "assistant" && message.time.completed === 2
})
const assistant = sync.session.message.list("session-interrupted")?.[0]
expect(assistant).toMatchObject({
type: "assistant",
settlement: "interrupted",
time: { completed: 2 },
})
expect(assistant).not.toHaveProperty("finish")
} finally {
app.renderer.destroy()
}
})
test("renders admitted prompts only after they become model-visible", async () => { test("renders admitted prompts only after they become model-visible", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
+2
View File
@@ -15,6 +15,7 @@
"**/*.css" "**/*.css"
], ],
"files": [ "files": [
"dist/**/*.d.ts",
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"!src/**/*.test.ts", "!src/**/*.test.ts",
@@ -55,6 +56,7 @@
"./v2/styles/*": "./src/v2/styles/*" "./v2/styles/*": "./src/v2/styles/*"
}, },
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test src --only-failures", "test": "bun test src --only-failures",
"dev": "vite", "dev": "vite",
+1
View File
@@ -246,6 +246,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
export async function pack() {
const original = await Bun.file("package.json").text()
const pkg = JSON.parse(original) as {
name: string
version: string
exports: Record<string, string | { types: string; import: string }>
}
const tarball = path.resolve(`${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`)
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string" || (!value.endsWith(".ts") && !value.endsWith(".tsx"))) return [key, value]
return [
key,
{
types: value.replace("./src/", "./dist/").replace(/\.tsx?$/, ".d.ts"),
import: value,
},
]
}),
)
await rm(tarball, { force: true })
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
try {
await $`bun pm pack`
return tarball
} finally {
await Bun.write("package.json", original)
}
}
if (import.meta.main) console.log(await pack())
+2 -2
View File
@@ -4,6 +4,7 @@ import { Script } from "@opencode-ai/script"
import { $ } from "bun" import { $ } from "bun"
import { rm } from "node:fs/promises" import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { pack } from "./pack"
process.chdir(fileURLToPath(new URL("..", import.meta.url))) process.chdir(fileURLToPath(new URL("..", import.meta.url)))
@@ -18,8 +19,7 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
try { try {
await $`bun run typecheck` await $`bun run typecheck`
await $`bun run test` await $`bun run test`
await rm(tarball, { force: true }) await pack()
await $`bun pm pack`
await $`npm publish ${tarball} --access public --tag ${Script.channel}` await $`npm publish ${tarball} --access public --tag ${Script.channel}`
} finally { } finally {
await rm(tarball, { force: true }) await rm(tarball, { force: true })
+31 -7
View File
@@ -35,12 +35,12 @@
cursor: default; cursor: default;
user-select: none; user-select: none;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
overflow: clip; overflow: clip;
color: var(--text-strong); color: var(--v2-text-text-base);
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
/* text-12-regular */ /* text-12-regular */
@@ -52,10 +52,10 @@
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
&:hover:not([data-disabled]) { &:hover:not([data-disabled]) {
background-color: var(--surface-base-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }
&:active:not([data-disabled]) { &:active:not([data-disabled]) {
background-color: var(--surface-base-active); background-color: var(--v2-overlay-simple-overlay-pressed);
} }
&:focus-visible { &:focus-visible {
outline: none; outline: none;
@@ -82,9 +82,9 @@
&[data-expanded] { &[data-expanded] {
[data-slot="accordion-content"] { [data-slot="accordion-content"] {
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-top: 0; border-top: 0;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-layer-01);
} }
} }
@@ -104,6 +104,30 @@
} }
} }
body:not([data-new-layout]) [data-component="accordion"] {
[data-slot="accordion-item"] [data-slot="accordion-header"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger);
border-width: 1px;
border-color: var(--border-weak-base);
color: var(--text-strong);
&:hover:not([data-disabled]) {
background-color: var(--surface-base-hover);
}
&:active:not([data-disabled]) {
background-color: var(--surface-base-active);
}
}
[data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
border-width: 1px;
border-color: var(--border-weak-base);
border-top: 0;
background-color: var(--background-stronger);
}
}
@keyframes slideDown { @keyframes slideDown {
from { from {
height: 0; height: 0;
+26 -5
View File
@@ -19,14 +19,14 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
--card-gap: 8px; --card-gap: 8px;
--card-icon: 16px; --card-icon: 16px;
--card-indent: 0px; --card-indent: 0px;
--card-line-pad: 8px; --card-line-pad: 8px;
--card-accent: var(--icon-active); --card-accent: var(--v2-icon-icon-base);
&:has([data-slot="card-title"]) { &:has([data-slot="card-title"]) {
gap: 8px; gap: 8px;
@@ -48,7 +48,7 @@
} }
:where([data-card="title"], [data-slot="card-title"]) { :where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
@@ -72,7 +72,7 @@
} }
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak); color: var(--v2-text-text-faint);
} }
:where([data-slot="card-title-icon"]) :where([data-slot="card-title-icon"])
@@ -82,7 +82,7 @@
} }
:where([data-card="description"], [data-slot="card-description"]) { :where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base); color: var(--v2-text-text-muted);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
@@ -92,3 +92,24 @@
padding-left: var(--card-indent); padding-left: var(--card-indent);
} }
} }
body:not([data-new-layout]) [data-component="card"] {
color: var(--text-strong);
--card-accent: var(--icon-active);
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
}
&[data-variant="error"] {
--card-accent: var(--icon-critical-base) !important;
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Card(props: CardProps) {
const variant = () => split.variant ?? "normal" const variant = () => split.variant ?? "normal"
const accent = () => { const accent = () => {
const v = variant() const v = variant()
if (v === "error") return "var(--icon-critical-base)" if (v === "error") return "var(--v2-state-fg-danger)"
if (v === "warning") return "var(--icon-warning-active)" if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)" if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)" if (v === "info") return "var(--icon-info-active)"
@@ -249,6 +249,7 @@
--color-v2-text-text-contrast: var(--v2-text-text-contrast); --color-v2-text-text-contrast: var(--v2-text-text-contrast);
--color-v2-text-text-accent: var(--v2-text-text-accent); --color-v2-text-text-accent: var(--v2-text-text-accent);
--color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover); --color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base); --color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted); --color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse); --color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
+20 -14
View File
@@ -20,6 +20,8 @@
"text-base": "#6F6F6F", "text-base": "#6F6F6F",
"text-weak": "#8F8F8F", "text-weak": "#8F8F8F",
"text-weaker": "#C7C7C7", "text-weaker": "#C7C7C7",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#DBDBDB", "border-weak-base": "#DBDBDB",
"border-weaker-base": "#E8E8E8", "border-weaker-base": "#E8E8E8",
"icon-base": "#8F8F8F", "icon-base": "#8F8F8F",
@@ -31,14 +33,14 @@
"surface-interactive-weak": "#F5FAFF", "surface-interactive-weak": "#F5FAFF",
"icon-success-base": "#0ABE00", "icon-success-base": "#0ABE00",
"surface-success-base": "#E6FFE5", "surface-success-base": "#E6FFE5",
"syntax-comment": "#7a7a7a", "syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "#a753ae", "syntax-keyword": "var(--v2-pink-800)",
"syntax-string": "#00ceb9", "syntax-string": "var(--v2-green-800)",
"syntax-primitive": "#034cff", "syntax-primitive": "var(--v2-pink-800)",
"syntax-property": "#a753ae", "syntax-property": "var(--v2-orange-800)",
"syntax-type": "#8a6f00", "syntax-type": "var(--v2-purple-800)",
"syntax-constant": "#007b80", "syntax-constant": "#007b80",
"syntax-critical": "#ff8c00", "syntax-critical": "var(--v2-red-800)",
"syntax-diff-delete": "#ff8c00", "syntax-diff-delete": "#ff8c00",
"syntax-diff-unknown": "#a753ae", "syntax-diff-unknown": "#a753ae",
"surface-critical-base": "#FFF2F0" "surface-critical-base": "#FFF2F0"
@@ -170,6 +172,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)", "v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-600)", "v2-text-text-accent": "var(--v2-blue-600)",
"v2-text-text-accent-hover": "var(--v2-blue-700)", "v2-text-text-accent-hover": "var(--v2-blue-700)",
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)", "v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)", "v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-50)", "v2-icon-icon-inverse": "var(--v2-grey-50)",
@@ -252,6 +255,8 @@
"text-base": "#A0A0A0", "text-base": "#A0A0A0",
"text-weak": "#707070", "text-weak": "#707070",
"text-weaker": "#505050", "text-weaker": "#505050",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#282828", "border-weak-base": "#282828",
"border-weaker-base": "#232323", "border-weaker-base": "#232323",
"icon-base": "#7E7E7E", "icon-base": "#7E7E7E",
@@ -262,14 +267,14 @@
"surface-base-hover": "#FFFFFF0D", "surface-base-hover": "#FFFFFF0D",
"surface-interactive-weak": "#0D172B", "surface-interactive-weak": "#0D172B",
"surface-success-base": "#022B00", "surface-success-base": "#022B00",
"syntax-comment": "#8f8f8f", "syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "#edb2f1", "syntax-keyword": "var(--v2-pink-400)",
"syntax-string": "#00ceb9", "syntax-string": "var(--v2-green-400)",
"syntax-primitive": "#8cb0ff", "syntax-primitive": "var(--v2-pink-400)",
"syntax-property": "#fab283", "syntax-property": "var(--v2-orange-400)",
"syntax-type": "#fcd53a", "syntax-type": "var(--v2-purple-400)",
"syntax-constant": "#93e9f6", "syntax-constant": "#93e9f6",
"syntax-critical": "#fab283", "syntax-critical": "var(--v2-red-400)",
"syntax-diff-delete": "#fab283", "syntax-diff-delete": "#fab283",
"syntax-diff-unknown": "#edb2f1", "syntax-diff-unknown": "#edb2f1",
"surface-critical-base": "#1F0603" "surface-critical-base": "#1F0603"
@@ -401,6 +406,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)", "v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-400)", "v2-text-text-accent": "var(--v2-blue-400)",
"v2-text-text-accent-hover": "var(--v2-blue-300)", "v2-text-text-accent-hover": "var(--v2-blue-300)",
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)", "v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)", "v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)", "v2-icon-icon-inverse": "var(--v2-grey-1100)",
+2
View File
@@ -18,6 +18,7 @@ const light: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"), "v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-600"), "v2-text-text-accent": ref("v2-blue-600"),
"v2-text-text-accent-hover": ref("v2-blue-700"), "v2-text-text-accent-hover": ref("v2-blue-700"),
"v2-text-text-code-accent": ref("v2-blue-900"),
"v2-border-border-muted": ref("v2-alpha-dark-8"), "v2-border-border-muted": ref("v2-alpha-dark-8"),
"v2-border-border-base": ref("v2-alpha-dark-10"), "v2-border-border-base": ref("v2-alpha-dark-10"),
"v2-border-border-strong": ref("v2-alpha-dark-20"), "v2-border-border-strong": ref("v2-alpha-dark-20"),
@@ -81,6 +82,7 @@ const dark: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"), "v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-400"), "v2-text-text-accent": ref("v2-blue-400"),
"v2-text-text-accent-hover": ref("v2-blue-300"), "v2-text-text-accent-hover": ref("v2-blue-300"),
"v2-text-text-code-accent": ref("v2-blue-400"),
"v2-border-border-muted": ref("v2-alpha-light-8"), "v2-border-border-muted": ref("v2-alpha-light-8"),
"v2-border-border-base": ref("v2-alpha-light-10"), "v2-border-border-base": ref("v2-alpha-light-10"),
"v2-border-border-strong": ref("v2-alpha-light-20"), "v2-border-border-strong": ref("v2-alpha-light-20"),
+4
View File
@@ -49,6 +49,10 @@ const icons = {
viewBox: "0 0 16 16", viewBox: "0 0 16 16",
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`, body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
}, },
check: {
viewBox: "0 0 16 16",
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
},
close: { close: {
viewBox: "0 0 20 20", viewBox: "0 0 20 20",
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`, body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
+16
View File
@@ -1,4 +1,16 @@
@layer theme { @layer theme {
html[data-theme="oc-2"] body:not([data-new-layout]) {
--text-diff-add-base: light-dark(#167517, #c4ffc0);
--text-diff-delete-base: light-dark(#fa3012, #ec2f14);
--syntax-comment: light-dark(#7a7a7a, #8f8f8f);
--syntax-keyword: light-dark(#a753ae, #edb2f1);
--syntax-string: #00ceb9;
--syntax-primitive: light-dark(#034cff, #8cb0ff);
--syntax-property: light-dark(#a753ae, #fab283);
--syntax-type: light-dark(#8a6f00, #fcd53a);
--syntax-critical: light-dark(#ff8c00, #fab283);
}
:root { :root {
color-scheme: light; color-scheme: light;
@@ -22,6 +34,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
/* ── Icon ── */ /* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
@@ -141,6 +154,7 @@
--v2-text-text-contrast: var(--v2-grey-100); --v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-400); --v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300); --v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400); --v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
@@ -243,6 +257,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
@@ -352,6 +367,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-400); --v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300); --v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400); --v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
// Publish declarations so consumer compiler options do not typecheck our TSX implementation.
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"],
"exclude": ["**/*.stories.*", "**/*.test.*", "**/*.mdx"]
}
+7
View File
@@ -25,6 +25,11 @@ sessions.interrupt(sessionID)
-> clears a coalesced follow-up wake already registered with this coordinator -> clears a coalesced follow-up wake already registered with this coordinator
-> preserves durable inbox rows for a later wake or resume -> preserves durable inbox rows for a later wake or resume
-> idle or missing Session is a no-op -> idle or missing Session is a no-op
sessions.active()
-> snapshots foreground Session drains owned by this process
-> returns only active Session IDs with { type: "running" }
-> absence means inactive; activity is not durable across process restarts
``` ```
`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. `session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
@@ -161,6 +166,8 @@ Post-crash continuation recovery is intentionally deferred. A wake does not infe
A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location. A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location.
The coordinator's active registry is also the source for `sessions.active()`. It represents only foreground Session drains owned by the current process; background subagents and tasks do not add parent Sessions to this registry. The snapshot is runtime state and is empty after a process restart.
Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.
Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.