mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cac531148a | |||
| eeb5b1d8bc | |||
| f8ceb30b43 | |||
| 78a5a030ce | |||
| 219ba24d90 | |||
| e8fea9e63a | |||
| 11537260aa | |||
| 929c4aaf29 | |||
| 19e510f5d2 | |||
| 8a1608ed1d | |||
| 077deb9d82 | |||
| 41f2705e72 | |||
| ef5c9f4931 |
@@ -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.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.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.
|
||||
- **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.
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"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:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -59,7 +63,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"luxon": "catalog:",
|
||||
"marked": "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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=",
|
||||
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=",
|
||||
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=",
|
||||
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE="
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"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:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -72,7 +76,7 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
ErrorBoundary,
|
||||
@@ -278,10 +279,11 @@ function QueryProvider(props: ParentProps) {
|
||||
function BodyDesignClass() {
|
||||
const settings = useSettings()
|
||||
|
||||
createEffect(() => {
|
||||
createRenderEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const enabled = settings.general.newLayoutDesigns()
|
||||
document.body.toggleAttribute("data-new-layout", enabled)
|
||||
document.body.classList.toggle("text-12-regular", !enabled)
|
||||
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
|
||||
document.body.classList.toggle("text-[13px]", enabled)
|
||||
|
||||
@@ -66,6 +66,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { createPromptInputTransientState } from "./prompt-input/transient-state"
|
||||
import { showToast } from "@/utils/toast"
|
||||
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>
|
||||
|
||||
@@ -339,6 +340,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
() => prompt.capture(),
|
||||
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 motion = (value: number) => ({
|
||||
opacity: value,
|
||||
@@ -1180,6 +1182,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
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) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
event.preventDefault()
|
||||
@@ -1232,7 +1250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
if (working()) {
|
||||
void abort()
|
||||
void interrupt()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
@@ -1298,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
if (working()) {
|
||||
void abort()
|
||||
void interrupt()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
@@ -1410,7 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleV2Submit}
|
||||
classList={{
|
||||
"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,
|
||||
@@ -1554,20 +1572,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"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%)",
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
<span class="relative flex size-7">
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={submissionState.interrupting || (!working() && blank())}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
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={{
|
||||
"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%)",
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
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 { getProjectAvatarVariant } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
|
||||
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: {
|
||||
controls: Accessor<PromptProjectControls>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ open: false, search: "" })
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
@@ -53,8 +65,25 @@ export function createPromptProjectController(input: {
|
||||
.controls()
|
||||
.available.map((project) => project.server)
|
||||
.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 = () => {
|
||||
setStore({ open: false, search: "" })
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.onDone()
|
||||
}
|
||||
const select = (project: PromptProject) => {
|
||||
@@ -67,7 +96,7 @@ export function createPromptProjectController(input: {
|
||||
close()
|
||||
}
|
||||
const add = (server?: string) => {
|
||||
setStore("open", false)
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
input.controls().add(language.t("command.project.open"), server)
|
||||
}
|
||||
|
||||
@@ -75,8 +104,11 @@ export function createPromptProjectController(input: {
|
||||
selected,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
actionKey,
|
||||
open: () => store.open,
|
||||
search: () => store.search,
|
||||
active: () => store.active,
|
||||
labels: {
|
||||
add: () => language.t("session.new.project.add"),
|
||||
clear: () => language.t("common.clear"),
|
||||
@@ -86,50 +118,188 @@ export function createPromptProjectController(input: {
|
||||
add,
|
||||
select,
|
||||
setOpen(open: boolean) {
|
||||
setStore("open", open)
|
||||
if (open) requestAnimationFrame(() => searchRef?.focus())
|
||||
if (open) {
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
return
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
},
|
||||
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) {
|
||||
searchRef = el
|
||||
},
|
||||
focusSearch() {
|
||||
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
|
||||
|
||||
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 (
|
||||
<Popover
|
||||
<DropdownMenu
|
||||
open={props.controller.open()}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
shift={-6}
|
||||
modal={false}
|
||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||
>
|
||||
<Popover.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
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()}
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<input
|
||||
ref={(el) => props.controller.setSearchRef(el)}
|
||||
value={props.controller.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"
|
||||
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()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={() => props.controller.setSearch("")}
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => props.controller.clearSearch()}
|
||||
aria-label={props.controller.labels.clear()}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
@@ -139,15 +309,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects()}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
{server!.name}
|
||||
</div>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem
|
||||
project={project}
|
||||
selected={props.controller.selected()}
|
||||
onSelect={props.controller.select}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(server!.key)}
|
||||
/>
|
||||
<DropdownMenu.RadioGroup value={selectedValue()}>
|
||||
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
|
||||
{(project) => (
|
||||
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
|
||||
)}
|
||||
</For>
|
||||
</DropdownMenu.RadioGroup>
|
||||
<ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -178,14 +341,15 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<ProjectAction
|
||||
label={props.controller.labels.add()}
|
||||
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)}
|
||||
server={props.controller.servers()[0]?.key}
|
||||
controller={props.controller}
|
||||
onSelect={selectAction}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,15 +369,28 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
|
||||
}
|
||||
|
||||
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()
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
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"
|
||||
onClick={() => local.controller.setOpen(true)}
|
||||
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"
|
||||
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
|
||||
when={project()}
|
||||
@@ -237,42 +414,77 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
||||
|
||||
function ProjectItem(props: {
|
||||
project: PromptProject
|
||||
selected?: PromptProject
|
||||
controller: PromptProjectController
|
||||
onSelect: (project: PromptProject) => void
|
||||
}) {
|
||||
const key = () => props.controller.projectKey(props.project)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => props.onSelect(props.project)}
|
||||
<DropdownMenu.RadioItem
|
||||
id={key()}
|
||||
value={key()}
|
||||
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
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span>
|
||||
<Show
|
||||
when={
|
||||
props.selected?.worktree === props.project.worktree &&
|
||||
props.selected?.server?.key === props.project.server?.key
|
||||
}
|
||||
>
|
||||
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
|
||||
<IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={props.onSelect}
|
||||
<DropdownMenu.Item
|
||||
id={key()}
|
||||
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",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
props.controller.setActive(key())
|
||||
props.controller.focusSearch()
|
||||
}}
|
||||
onSelect={() => props.onSelect(props.server)}
|
||||
>
|
||||
<Icon name="plus" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span>
|
||||
</button>
|
||||
<DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
|
||||
{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"
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return !dragging && !editing && !committing
|
||||
}
|
||||
|
||||
export function createTabDragPreview(element: HTMLDivElement) {
|
||||
return element.cloneNode(true) as HTMLDivElement
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab-list] {
|
||||
gap: 13.5px;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
@@ -173,112 +170,105 @@ export function TabNavItem(props: {
|
||||
|
||||
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}
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
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)]"
|
||||
classList={{ invisible: props.hidden }}
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
forwardTabRef(props.ref, el)
|
||||
}}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-title-overflow={titleOverflowing()}
|
||||
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)]"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -295,9 +285,6 @@ export function DraftTabItem(props: {
|
||||
dragging?: boolean
|
||||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
tabKey: string
|
||||
dragActive: boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
}) {
|
||||
const closeTab = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -306,69 +293,62 @@ export function DraftTabItem(props: {
|
||||
}
|
||||
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}
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-pressed={props.pressed}
|
||||
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) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(el) => forwardTabRef(props.ref, el)}
|
||||
data-titlebar-tab
|
||||
data-slot="titlebar-tab-item"
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
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)]"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 1) return
|
||||
closeTab(event)
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<IconV2 name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
</a>
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
<IconButtonV2
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label="Close tab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
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 { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
|
||||
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 { ServerConnection } from "@/context/server"
|
||||
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
|
||||
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
captureTabPointerDown,
|
||||
canStartTabDrag,
|
||||
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"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
|
||||
const sortableTransition = { duration: 0 }
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
dragActive: boolean
|
||||
dragged: () => boolean
|
||||
pressed: () => boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
suppressNavigation: () => boolean
|
||||
onPointerDown: (event: PointerEvent) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
let ref!: HTMLDivElement
|
||||
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
|
||||
@@ -100,33 +81,79 @@ function SessionTabSlot(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<TabNavItem
|
||||
tabKey={props.id}
|
||||
dragActive={props.dragActive}
|
||||
onPointerDown={props.onPointerDown}
|
||||
ref={ref}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
suppressNavigation={props.suppressNavigation}
|
||||
pressed={props.pressed()}
|
||||
hidden={props.dragged() || !session()}
|
||||
/>
|
||||
<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
|
||||
ref={(el) => {
|
||||
ref = el
|
||||
}}
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onTitleChangeFailed={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
if (value && ctx) ctx.sync.session.remember({ ...value, title })
|
||||
}}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
</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 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 listRef!: HTMLDivElement
|
||||
let dragLayout: TabDragLayout | undefined
|
||||
let dragPointerId: number | undefined
|
||||
let autoscrollFrame: number | undefined
|
||||
let resizeFrame: number | undefined
|
||||
let dragPreview: HTMLDivElement | undefined
|
||||
|
||||
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() {
|
||||
if (!scrollRef) return
|
||||
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
|
||||
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
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(() => {
|
||||
const cleanups = [
|
||||
makeEventListener(window, "pointermove", onPointerMove),
|
||||
makeEventListener(window, "pointerup", onPointerUp),
|
||||
makeEventListener(window, "pointercancel", onPointerCancel),
|
||||
]
|
||||
refreshOverflow()
|
||||
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
|
||||
})
|
||||
|
||||
onCleanup(stopAutoscroll)
|
||||
onCleanup(() => {
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
})
|
||||
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
|
||||
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 (
|
||||
<>
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
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]"
|
||||
ref={scrollRef}
|
||||
<div data-slot="titlebar-tabs" class="relative min-w-0">
|
||||
<div
|
||||
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]"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<DragDropProvider
|
||||
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 min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={displayTabs()}>
|
||||
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
|
||||
<For each={props.tabs}>
|
||||
{(tab, index) => {
|
||||
const id = tabKey(tab)
|
||||
let ref!: HTMLDivElement
|
||||
useTabShortcut(index, () => props.onNavigate(tab, ref))
|
||||
|
||||
const dragged = () => drag.active && drag.draggedId === id
|
||||
const serverCtx = createMemo(() => {
|
||||
if (tab.type !== "session") return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
@@ -479,68 +264,50 @@ export function TitlebarTabStrip(props: {
|
||||
<SessionTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragActive={drag.active}
|
||||
dragged={dragged}
|
||||
pressed={() => pressedId() === id}
|
||||
serverCtx={serverCtx}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
}}
|
||||
onNavigate={(element) => props.onNavigate(tab, element)}
|
||||
onClose={() => props.onClose(tab)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DraftTabItem
|
||||
tabKey={id}
|
||||
dragActive={drag.active}
|
||||
onPointerDown={(event) => {
|
||||
if (dragged()) return
|
||||
onPointerDown(id, event)
|
||||
}}
|
||||
ref={ref}
|
||||
href={tabHref(tab)}
|
||||
<DraftTabSlot
|
||||
tab={tab}
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
title={language.t("command.session.new")}
|
||||
onNavigate={() => props.onNavigate(tab, ref)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
}}
|
||||
onClose={() => props.onClose(tab)}
|
||||
suppressNavigation={() => suppressNavigation()}
|
||||
active={props.currentTab() === tab}
|
||||
pressed={pressedId() === id}
|
||||
hidden={dragged()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
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)]"
|
||||
/>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<Show when={drag.active && draggedTab() && dragPreview}>
|
||||
{(_) => (
|
||||
<Portal>
|
||||
<div data-titlebar-tab-preview style={floaterStyle()}>
|
||||
{dragPreview}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-left"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="titlebar-tabs-fade-right"
|
||||
aria-hidden="true"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -908,7 +908,13 @@ export default function Page() {
|
||||
)
|
||||
|
||||
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">
|
||||
{reviewContent({
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
@@ -1713,7 +1719,9 @@ export default function Page() {
|
||||
>
|
||||
<div
|
||||
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(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
|
||||
@@ -13,6 +14,7 @@ export function SessionComposerRegion(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const controller = props.controller
|
||||
const settings = useSettings()
|
||||
const rolled = () => {
|
||||
const revert = controller.revert()
|
||||
return revert?.items.length ? revert : undefined
|
||||
@@ -22,7 +24,11 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
ref={controller.setDockRef}
|
||||
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
|
||||
classList={{
|
||||
|
||||
@@ -1019,7 +1019,13 @@ export function MessageTimeline(props: {
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(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">
|
||||
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
@@ -1288,7 +1294,11 @@ export function MessageTimeline(props: {
|
||||
<div
|
||||
data-session-title
|
||||
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,
|
||||
"pb-4": true,
|
||||
"pr-3": true,
|
||||
@@ -1509,7 +1519,11 @@ export function MessageTimeline(props: {
|
||||
<Button
|
||||
size="large"
|
||||
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}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
|
||||
@@ -55,43 +55,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_3Input = {
|
||||
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_3Request["payload"]["agent"]
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_4Request["payload"]["model"]
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_5Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_5Request["payload"]["resume"]
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_6Input = {
|
||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||
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"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
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),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = 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_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
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))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_8Input = {
|
||||
readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_8Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_8Request["payload"]["files"]
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_9Input = {
|
||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||
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"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
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),
|
||||
)
|
||||
|
||||
type Endpoint0_9Request = 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_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
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"] }
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_12Input = {
|
||||
readonly sessionID: Endpoint0_12Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_12Request["query"]["after"]
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
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(
|
||||
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
|
||||
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_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] }
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_14Input = {
|
||||
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_14Request["params"]["messageID"]
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
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"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
get: Endpoint0_2(raw),
|
||||
switchAgent: Endpoint0_3(raw),
|
||||
switchModel: Endpoint0_4(raw),
|
||||
prompt: Endpoint0_5(raw),
|
||||
compact: Endpoint0_6(raw),
|
||||
wait: Endpoint0_7(raw),
|
||||
stage: Endpoint0_8(raw),
|
||||
clear: Endpoint0_9(raw),
|
||||
commit: Endpoint0_10(raw),
|
||||
context: Endpoint0_11(raw),
|
||||
events: Endpoint0_12(raw),
|
||||
interrupt: Endpoint0_13(raw),
|
||||
message: Endpoint0_14(raw),
|
||||
active: Endpoint0_2(raw),
|
||||
get: Endpoint0_3(raw),
|
||||
switchAgent: Endpoint0_4(raw),
|
||||
switchModel: Endpoint0_5(raw),
|
||||
prompt: Endpoint0_6(raw),
|
||||
compact: Endpoint0_7(raw),
|
||||
wait: Endpoint0_8(raw),
|
||||
stage: Endpoint0_9(raw),
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
SessionsListOutput,
|
||||
SessionsCreateInput,
|
||||
SessionsCreateOutput,
|
||||
SessionsActiveOutput,
|
||||
SessionsGetInput,
|
||||
SessionsGetOutput,
|
||||
SessionsSwitchAgentInput,
|
||||
@@ -198,6 +199,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).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) =>
|
||||
request<{ readonly data: SessionsGetOutput }>(
|
||||
{
|
||||
|
||||
@@ -243,6 +243,8 @@ export type SessionsCreateOutput = {
|
||||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
|
||||
|
||||
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionsGetOutput = {
|
||||
@@ -572,7 +574,6 @@ export type SessionsContextOutput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: string
|
||||
readonly settlement?: "completed" | "failed" | "interrupted"
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -796,14 +797,6 @@ export type SessionsEventsOutput =
|
||||
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 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 finish?: string
|
||||
readonly settlement?: "completed" | "failed" | "interrupted"
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
|
||||
@@ -37,6 +37,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
if (url.includes("/message/")) {
|
||||
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")) {
|
||||
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 client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const page = yield* client.sessions.list({ limit: 10 })
|
||||
const active = yield* client.sessions.active()
|
||||
const created = yield* client.sessions.create({
|
||||
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"),
|
||||
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)
|
||||
|
||||
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.created)).toBe(Object.prototype)
|
||||
expect(result.created.id).toBe("ses_test")
|
||||
|
||||
@@ -32,6 +32,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
if (url.includes("/prompt")) return Response.json(admission)
|
||||
if (url.includes("/context")) return Response.json({ data: [] })
|
||||
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") return new Response(null, { status: 204 })
|
||||
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 active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
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" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
@@ -66,6 +69,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
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/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["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"],
|
||||
["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")
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
prompt: { text: "Hello" },
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
// 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>`
|
||||
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
|
||||
@@ -58,17 +59,21 @@ const browser = {
|
||||
const value = url.searchParams.get("code")
|
||||
if (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
|
||||
}
|
||||
if (!value || url.searchParams.get("state") !== state) {
|
||||
const message = value ? "Invalid OAuth state" : "Missing authorization code"
|
||||
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
|
||||
}
|
||||
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) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
@@ -285,8 +290,3 @@ function claim(token: string) {
|
||||
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>`
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface Interface {
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
readonly compact: (input: CompactInput) => 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 interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly revert: {
|
||||
@@ -402,6 +403,7 @@ export const layer = Layer.unwrap(
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
active: execution.active,
|
||||
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
|
||||
@@ -5,6 +5,8 @@ import { SessionRunner } from "./runner/index"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
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. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** 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. */
|
||||
export const noopLayer = Layer.succeed(
|
||||
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({
|
||||
active: coordinator.active,
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
|
||||
@@ -210,7 +210,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = event.data.finish
|
||||
draft.settlement = "completed"
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
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) => {
|
||||
draft.time.completed = event.data.timestamp
|
||||
draft.finish = "error"
|
||||
draft.settlement = "failed"
|
||||
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) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
|
||||
@@ -381,7 +381,6 @@ export const layer = Layer.effectDiscard(
|
||||
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.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.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
|
||||
|
||||
@@ -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. */
|
||||
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. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** 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 { run, wake, interrupt }
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
|
||||
})
|
||||
|
||||
@@ -290,7 +290,6 @@ export const layer = Layer.effect(
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* withPublication(publisher.interruptAssistant())
|
||||
return yield* Effect.interrupt
|
||||
}
|
||||
if (
|
||||
@@ -299,7 +298,8 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
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)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
@@ -307,7 +307,7 @@ export const layer = Layer.effect(
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !publisher.hasProviderError() && !publisher.hasAssistantSettled()) {
|
||||
if (stepSettlement && !publisher.hasProviderError()) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
|
||||
@@ -66,13 +66,15 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let assistantSettled = false
|
||||
let assistantActive = false
|
||||
let assistantFailed = 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* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
assistantActive = true
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
@@ -194,39 +196,20 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const settleAssistant = Effect.fnUntraced(function* (
|
||||
publish: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>,
|
||||
) {
|
||||
if (assistantSettled) return
|
||||
const failAssistant = Effect.fnUntraced(function* (message: string) {
|
||||
if (assistantFailed) return
|
||||
yield* flush()
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
assistantSettled = true
|
||||
yield* publish(assistantMessageID)
|
||||
assistantActive = false
|
||||
assistantFailed = true
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID,
|
||||
error: { type: "unknown", message },
|
||||
})
|
||||
})
|
||||
|
||||
const failAssistant = (message: string) =>
|
||||
settleAssistant((assistantMessageID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* timestamp,
|
||||
assistantMessageID,
|
||||
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* (
|
||||
message: string,
|
||||
hostedOnly = false,
|
||||
@@ -412,6 +395,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
assistantActive = false
|
||||
if (stepSettlement) return yield* Effect.die("Duplicate step finish")
|
||||
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
|
||||
return
|
||||
@@ -428,10 +412,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
publish,
|
||||
flush,
|
||||
failAssistant,
|
||||
interruptAssistant,
|
||||
failUnsettledTools,
|
||||
hasActiveAssistant: () => assistantActive,
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasAssistantSettled: () => assistantSettled,
|
||||
hasProviderError: () => providerFailed,
|
||||
stepSettlement: () => stepSettlement,
|
||||
startAssistant,
|
||||
|
||||
@@ -70,7 +70,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
||||
const assistant = (message: SessionMessage.Assistant, model: Model) => {
|
||||
const sameModel =
|
||||
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[] => {
|
||||
if (item.type === "text") return [{ type: "text", text: item.text }]
|
||||
if (item.type === "reasoning")
|
||||
|
||||
@@ -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 interruptCalls: SessionV2.ID[] = []
|
||||
const wakeCalls: SessionV2.ID[] = []
|
||||
const activeSessions = new Set<SessionV2.ID>()
|
||||
const execution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.sync(() => new Set(activeSessions)),
|
||||
resume: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
@@ -108,6 +110,13 @@ const eventCount = (type: string) =>
|
||||
)
|
||||
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
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", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -166,6 +238,7 @@ describe("SessionRunCoordinator", () => {
|
||||
|
||||
const exit = yield* Fiber.await(resumed)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
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(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
@@ -361,7 +361,8 @@ Recent work
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
settlement: "interrupted",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -95,6 +95,7 @@ const execution = Layer.effect(
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
|
||||
@@ -254,6 +254,7 @@ const execution = Layer.effect(
|
||||
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
||||
})
|
||||
return SessionExecution.Service.of({
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
interrupt: coordinator.interrupt,
|
||||
@@ -519,7 +520,6 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
settlement: "failed",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
content: [fixture.expectedContent],
|
||||
},
|
||||
@@ -543,28 +543,12 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
const fiber = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamed)
|
||||
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([
|
||||
{ type: "user", text: prompt },
|
||||
{
|
||||
type: "assistant",
|
||||
settlement: "interrupted",
|
||||
finish: "error",
|
||||
error: { type: "unknown", message: "Provider turn interrupted" },
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? { 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" } },
|
||||
},
|
||||
],
|
||||
settlement: "interrupted",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -2815,7 +2798,6 @@ describe("SessionRunnerLLM", () => {
|
||||
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
|
||||
},
|
||||
],
|
||||
settlement: "interrupted",
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createConnection } from "net"
|
||||
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"
|
||||
|
||||
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 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 {
|
||||
resolve: (code: string) => void
|
||||
reject: (error: Error) => void
|
||||
@@ -98,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
if (!state) {
|
||||
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -127,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
if (!pendingAuths.has(state)) {
|
||||
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
pending.resolve(code)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_SUCCESS)
|
||||
res.end(OauthCallbackPage.success({ provider: "MCP" }))
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
@@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string {
|
||||
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> {
|
||||
if (oauthServer) return
|
||||
oauthServer = createServer((req, res) => {
|
||||
@@ -124,7 +66,7 @@ async function startOAuthServer(): Promise<void> {
|
||||
|
||||
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_CALLBACK)
|
||||
res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createServer } from "http"
|
||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||
import { escapeHtml } from "@/util/html"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const ISSUER = "https://auth.openai.com"
|
||||
@@ -138,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!doctype html>
|
||||
<html>
|
||||
<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>`
|
||||
// Kept as a named export for plugin.codex tests; delegates to the shared branded page.
|
||||
export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
@@ -287,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(HTML_SUCCESS)
|
||||
res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
@@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) {
|
||||
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() {
|
||||
if (oauthServer) return
|
||||
|
||||
@@ -203,7 +181,7 @@ async function startOAuthServer() {
|
||||
pendingOAuth?.reject(new Error(message))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,7 +192,7 @@ async function startOAuthServer() {
|
||||
const message = errorDescription || error
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -222,7 +200,7 @@ async function startOAuthServer() {
|
||||
const message = "Missing authorization code"
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(htmlError(message))
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -231,7 +209,7 @@ async function startOAuthServer() {
|
||||
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { createServer } from "http"
|
||||
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
|
||||
// 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")
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(HTML_ERROR(errorMsg))
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_SUCCESS)
|
||||
res.end(OauthCallbackPage.success({ provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("public event manifest", () => {
|
||||
expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
|
||||
expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
|
||||
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("todo.updated")).toBe(Todo.Event.Updated)
|
||||
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")}`,
|
||||
)
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -964,6 +964,7 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected.get("/api/session/active", "v2.session.active").json(200, data(object), "none"),
|
||||
http.protected
|
||||
.post("/api/session", "v2.session.create")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -73,6 +73,10 @@ export const SessionsCursor = Schema.String.pipe(
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
}).annotate({ identifier: "SessionActive" })
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
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(
|
||||
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -165,7 +165,6 @@ export namespace Step {
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
// Step.Ended v2 was originally persisted with an open string schema.
|
||||
finish: Schema.String,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
@@ -193,16 +192,6 @@ export namespace Step {
|
||||
},
|
||||
})
|
||||
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 {
|
||||
@@ -469,7 +458,6 @@ export const DurableDefinitions = Event.inventory(
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Step.Interrupted,
|
||||
Text.Started,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
@@ -501,7 +489,6 @@ export const Definitions = Event.inventory(
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Step.Failed,
|
||||
Step.Interrupted,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
|
||||
@@ -21,19 +21,6 @@ export const UnknownError = Schema.Struct({
|
||||
message: Schema.String,
|
||||
}).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 = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
@@ -182,9 +169,7 @@ export const Assistant = Schema.Struct({
|
||||
end: Schema.String.pipe(optional),
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
}).pipe(optional),
|
||||
// Projected histories predate the typed provider finish model and may contain arbitrary values.
|
||||
finish: Schema.String.pipe(optional),
|
||||
settlement: Settlement.pipe(optional),
|
||||
cost: Schema.Finite.pipe(optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src"
|
||||
import { EventManifest } from "../src/event-manifest"
|
||||
import { IdeEvent } from "../src/ide-event"
|
||||
@@ -10,8 +9,8 @@ import { WorkspaceEvent } from "../src/workspace-event"
|
||||
|
||||
describe("public event manifest", () => {
|
||||
test("owns the complete public event surface", () => {
|
||||
expect(EventManifest.ServerDefinitions.length).toBe(59)
|
||||
expect(EventManifest.Definitions.length).toBe(89)
|
||||
expect(EventManifest.ServerDefinitions.length).toBe(55)
|
||||
expect(EventManifest.Definitions.length).toBe(85)
|
||||
expect(SessionV1.Event.Definitions).toEqual([
|
||||
SessionV1.Event.Created,
|
||||
SessionV1.Event.Updated,
|
||||
@@ -24,8 +23,8 @@ describe("public event manifest", () => {
|
||||
SessionV1.Event.Diff,
|
||||
SessionV1.Event.Error,
|
||||
])
|
||||
expect(EventManifest.Latest.size).toBe(89)
|
||||
expect(EventManifest.Durable.size).toBe(36)
|
||||
expect(EventManifest.Latest.size).toBe(85)
|
||||
expect(EventManifest.Durable.size).toBe(32)
|
||||
})
|
||||
|
||||
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(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||
const partDelta = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta)
|
||||
expect(partDelta).toBeGreaterThanOrEqual(0)
|
||||
expect(EventManifest.Definitions.slice(partDelta, partDelta + 3)).toEqual([
|
||||
expect(EventManifest.Definitions.slice(40, 43)).toEqual([
|
||||
SessionV1.Event.PartDelta,
|
||||
SessionV1.Event.Diff,
|
||||
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.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" })
|
||||
})
|
||||
@@ -33,6 +33,7 @@ test("embedded client uses the real router and handlers", async () => {
|
||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
||||
const selected = yield* opencode.sessions.get({ sessionID })
|
||||
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
|
||||
const active = yield* opencode.sessions.active()
|
||||
const admitted = yield* opencode.sessions.prompt({
|
||||
sessionID,
|
||||
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?.providerID).toBe(model.providerID)
|
||||
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
|
||||
expect(active).toEqual({})
|
||||
expect(admitted.sessionID).toBe(sessionID)
|
||||
expect(prompted.type).toBe("session.next.prompted")
|
||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||
|
||||
@@ -333,6 +333,8 @@ import type {
|
||||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2SessionActiveErrors,
|
||||
V2SessionActiveResponses,
|
||||
V2SessionCompactErrors,
|
||||
V2SessionCompactResponses,
|
||||
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
|
||||
*
|
||||
|
||||
@@ -28,7 +28,6 @@ export type Event =
|
||||
| EventSessionNextStepStarted
|
||||
| EventSessionNextStepEnded
|
||||
| EventSessionNextStepFailed
|
||||
| EventSessionNextStepInterrupted
|
||||
| EventSessionNextTextStarted
|
||||
| EventSessionNextTextDelta
|
||||
| EventSessionNextTextEnded
|
||||
@@ -956,15 +955,6 @@ export type GlobalEvent = {
|
||||
error: SessionErrorUnknown
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.step.interrupted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.text.started"
|
||||
@@ -1630,7 +1620,6 @@ export type GlobalEvent = {
|
||||
| SyncEventSessionNextStepStarted
|
||||
| SyncEventSessionNextStepEnded
|
||||
| SyncEventSessionNextStepFailed
|
||||
| SyncEventSessionNextStepInterrupted
|
||||
| SyncEventSessionNextTextStarted
|
||||
| SyncEventSessionNextTextEnded
|
||||
| SyncEventSessionNextReasoningStarted
|
||||
@@ -2701,6 +2690,10 @@ export type InvalidCursorError = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SessionActive = {
|
||||
type: "running"
|
||||
}
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
_tag: "SessionNotFoundError"
|
||||
sessionID: string
|
||||
@@ -2770,7 +2763,6 @@ export type V2Event =
|
||||
| V2EventSessionNextStepStarted
|
||||
| V2EventSessionNextStepEnded
|
||||
| V2EventSessionNextStepFailed
|
||||
| V2EventSessionNextStepInterrupted
|
||||
| V2EventSessionNextTextStarted
|
||||
| V2EventSessionNextTextDelta
|
||||
| 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 = {
|
||||
type: "sync"
|
||||
id: string
|
||||
@@ -4023,7 +3999,6 @@ export type SessionMessageAssistant = {
|
||||
files?: Array<string>
|
||||
}
|
||||
finish?: string
|
||||
settlement?: "completed" | "failed" | "interrupted"
|
||||
cost?: number
|
||||
tokens?: {
|
||||
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 = {
|
||||
id: string
|
||||
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 = {
|
||||
id: string
|
||||
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 = {
|
||||
id: string
|
||||
type: "session.next.text.started"
|
||||
@@ -12017,6 +11944,39 @@ export type 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 = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -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}": {
|
||||
"get": {
|
||||
"tags": ["sessions"],
|
||||
@@ -23445,6 +23505,17 @@
|
||||
"required": ["_tag", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionActive": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["running"]
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SessionNotFoundError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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(
|
||||
"session.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-faint);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
@@ -89,14 +89,14 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&.agent-title {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -122,7 +122,7 @@
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-action"] {
|
||||
@@ -178,7 +178,7 @@
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
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);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
@@ -213,7 +213,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
color: var(--v2-text-text-faint);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition:
|
||||
@@ -233,7 +233,7 @@
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
&: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"
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
[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 */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
@@ -22,15 +30,31 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Headings: Same size, distinguished by color and spacing */
|
||||
h1,
|
||||
h2,
|
||||
/* Headings: Sized by level, distinguished by color and spacing */
|
||||
h1 {
|
||||
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,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: 0px;
|
||||
margin-bottom: 24px;
|
||||
@@ -40,7 +64,7 @@
|
||||
/* Emphasis & Strong: Neutral strong color */
|
||||
strong,
|
||||
b {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -95,7 +119,7 @@
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
/* Nested lists spacing */
|
||||
@@ -112,10 +136,10 @@
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
border-left: 2px solid var(--border-weak-base);
|
||||
border-left: 0.5px solid var(--v2-border-border-base);
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -132,13 +156,27 @@
|
||||
font-size: 13px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 0.5px solid var(--border-weak-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"] {
|
||||
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"] {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -160,7 +198,7 @@
|
||||
background: var(--surface-float-base);
|
||||
color: var(--text-invert-strong);
|
||||
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);
|
||||
|
||||
pointer-events: none;
|
||||
@@ -185,11 +223,11 @@
|
||||
|
||||
[data-slot="markdown-copy-button"][data-variant="secondary"] {
|
||||
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"] {
|
||||
color: var(--icon-base);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
|
||||
@@ -222,15 +260,28 @@
|
||||
:not(pre) > code {
|
||||
font-family: var(--font-family-mono);
|
||||
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-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; */
|
||||
/* margin: 0 1.5px; */
|
||||
/* border-radius: 2px; */
|
||||
/* background: var(--surface-base); */
|
||||
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
|
||||
:not(pre) > code[data-inline-code-kind="path"] {
|
||||
color: var(--markdown-inline-code-path-color);
|
||||
background: color-mix(
|
||||
in oklch,
|
||||
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 */
|
||||
@@ -246,16 +297,16 @@
|
||||
th,
|
||||
td {
|
||||
/* 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;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
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 */
|
||||
@@ -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 {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
||||
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
||||
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
||||
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
||||
|
||||
type RenderedBlock =
|
||||
| (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)
|
||||
}
|
||||
|
||||
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) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
@@ -147,12 +177,15 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
if (!wrapped) {
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
applyCodeMetadata(wrapper, codeLanguage(block))
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
return
|
||||
}
|
||||
|
||||
applyCodeMetadata(parent, codeLanguage(block))
|
||||
|
||||
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
||||
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
||||
)
|
||||
@@ -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) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
}
|
||||
if (!document.body.hasAttribute("data-new-layout")) return
|
||||
markInlineCode(root)
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
||||
@@ -522,6 +567,8 @@ function updateCodeBlock(
|
||||
|
||||
const code = existing?.querySelector("code")
|
||||
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}`
|
||||
const previous = renderedCodeTokens.get(next)
|
||||
const reset = shouldResetCodeTokens(previous, {
|
||||
@@ -552,6 +599,7 @@ function updateCodeBlock(
|
||||
|
||||
const wrapper = document.createElement("div")
|
||||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
applyCodeMetadata(wrapper, block.language)
|
||||
const pre = document.createElement("pre")
|
||||
pre.className = "shiki OpenCode"
|
||||
const codeElement = document.createElement("code")
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
@@ -41,15 +41,15 @@
|
||||
min-width: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-weak);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
cursor: default;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-strong-base);
|
||||
border-color: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
&[data-clickable] {
|
||||
@@ -107,7 +107,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
@@ -130,10 +130,10 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
|
||||
[data-highlight="file"] {
|
||||
color: var(--syntax-property);
|
||||
@@ -199,7 +199,7 @@
|
||||
}
|
||||
|
||||
.text-text-strong {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
@@ -267,7 +267,7 @@
|
||||
[data-slot="compaction-part-line"] {
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
background: var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="compaction-part-label"] {
|
||||
@@ -279,18 +279,18 @@
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 16px;
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
strong,
|
||||
b {
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
p:has(strong) {
|
||||
@@ -357,7 +357,7 @@
|
||||
|
||||
[data-component="bash-output"] {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
@@ -380,7 +380,7 @@
|
||||
|
||||
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
|
||||
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"] {
|
||||
@@ -442,7 +442,7 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-spinner"] {
|
||||
@@ -453,7 +453,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
@@ -464,7 +464,7 @@
|
||||
[data-slot="message-part-title-text"] {
|
||||
flex-shrink: 0;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
@@ -484,7 +484,7 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory"] {
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -493,7 +493,7 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-filename"] {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@
|
||||
|
||||
[data-component="edit-content"] {
|
||||
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-y: visible;
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
|
||||
[data-component="write-content"] {
|
||||
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-y: visible;
|
||||
|
||||
@@ -561,7 +561,7 @@
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
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 {
|
||||
@@ -583,7 +583,7 @@
|
||||
max-width: 100%;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
color: var(--text-interactive-base);
|
||||
color: var(--v2-text-text-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
overflow: hidden;
|
||||
@@ -591,11 +591,11 @@
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-interactive-base);
|
||||
color: var(--v2-text-text-accent);
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: var(--text-interactive-base);
|
||||
color: var(--v2-text-text-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@
|
||||
|
||||
&[data-completed="completed"] {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-weaker);
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,8 +660,8 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--surface-critical-weak);
|
||||
border-top: 1px solid var(--border-critical-base);
|
||||
background-color: var(--v2-state-bg-danger);
|
||||
border-top: 0.5px solid var(--v2-state-border-danger);
|
||||
|
||||
[data-slot="diagnostic"] {
|
||||
display: flex;
|
||||
@@ -673,7 +673,7 @@
|
||||
}
|
||||
|
||||
[data-slot="diagnostic-label"] {
|
||||
color: var(--text-on-critical-base);
|
||||
color: var(--v2-state-fg-danger);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.5px;
|
||||
@@ -681,12 +681,12 @@
|
||||
}
|
||||
|
||||
[data-slot="diagnostic-location"] {
|
||||
color: var(--text-on-critical-weak);
|
||||
color: var(--v2-state-fg-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="diagnostic-message"] {
|
||||
color: var(--text-on-critical-base);
|
||||
color: var(--v2-state-fg-danger);
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -761,7 +761,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -856,7 +856,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -916,7 +916,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
padding: 0 10px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
@@ -927,7 +927,7 @@
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@@ -953,8 +953,8 @@
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 8px 8px 10px;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
@@ -966,11 +966,11 @@
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:hover:not([data-picked="true"]) {
|
||||
background-color: var(--background-base);
|
||||
background-color: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
&[data-picked="true"] {
|
||||
background-color: var(--surface-interactive-weak);
|
||||
background-color: var(--v2-state-bg-info);
|
||||
border-color: transparent;
|
||||
box-shadow: var(--shadow-xs-border-hover);
|
||||
}
|
||||
@@ -991,7 +991,7 @@
|
||||
height: 16px;
|
||||
padding: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1014,7 +1014,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--background-stronger);
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -1052,7 +1052,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="option-description"] {
|
||||
@@ -1060,7 +1060,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
@@ -1088,7 +1088,7 @@
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
min-width: 0;
|
||||
cursor: text;
|
||||
resize: none;
|
||||
@@ -1096,11 +1096,11 @@
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--border-interactive-base);
|
||||
outline: 0.5px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
@@ -1139,11 +1139,11 @@
|
||||
font-size: 13px;
|
||||
|
||||
[data-slot="question-text"] {
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="answer-text"] {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1166,13 +1166,13 @@
|
||||
z-index: 20;
|
||||
height: calc(32px + 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-slot="accordion-trigger"] {
|
||||
background-color: var(--background-stronger) !important;
|
||||
background-color: var(--v2-background-bg-base) !important;
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-trigger-content"] {
|
||||
@@ -1199,7 +1199,7 @@
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-directory"] {
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -1208,7 +1208,7 @@
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-filename"] {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1268,10 +1268,199 @@
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-component="icon"] {
|
||||
flex-shrink: 0;
|
||||
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"] {
|
||||
z-index: 120;
|
||||
background-color: var(--background-stronger);
|
||||
background-color: var(--v2-background-bg-base);
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
@@ -211,7 +211,7 @@
|
||||
|
||||
[data-slot="session-review-large-diff"] {
|
||||
padding: 12px;
|
||||
background: var(--background-stronger);
|
||||
background: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-large-diff-title"] {
|
||||
@@ -235,3 +235,13 @@
|
||||
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 {
|
||||
color: var(--text-on-critical-base);
|
||||
color: var(--v2-text-text-base);
|
||||
max-height: 240px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -102,7 +102,7 @@
|
||||
position: sticky;
|
||||
top: var(--sticky-accordion-top, 0px);
|
||||
z-index: 20;
|
||||
background-color: var(--background-stronger);
|
||||
background-color: var(--v2-background-bg-base);
|
||||
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"] {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
--card-pad-y: 8px;
|
||||
--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 {
|
||||
gap: 0px;
|
||||
}
|
||||
@@ -43,10 +56,34 @@
|
||||
}
|
||||
|
||||
[data-slot="tool-error-card-content"] :where(*)::selection {
|
||||
background: var(--surface-critical-base);
|
||||
color: var(--text-on-critical-base);
|
||||
background: var(--v2-state-bg-danger);
|
||||
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 {
|
||||
background: var(--surface-critical-base);
|
||||
color: var(--text-on-critical-base);
|
||||
|
||||
@@ -227,7 +227,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.settlement = "completed"
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
@@ -240,18 +239,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.data.timestamp
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.settlement = "failed"
|
||||
currentAssistant.error = event.data.error
|
||||
})
|
||||
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":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.assistant(draft, event.data.assistantMessageID)?.content.push({
|
||||
|
||||
@@ -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 () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch(undefined, events)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"**/*.css"
|
||||
],
|
||||
"files": [
|
||||
"dist/**/*.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"!src/**/*.test.ts",
|
||||
@@ -55,6 +56,7 @@
|
||||
"./v2/styles/*": "./src/v2/styles/*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test src --only-failures",
|
||||
"dev": "vite",
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
--v2-text-text-contrast: var(--v2-grey-50);
|
||||
--v2-text-text-accent: var(--v2-blue-600);
|
||||
--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-muted: var(--v2-grey-600);
|
||||
|
||||
@@ -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())
|
||||
@@ -4,6 +4,7 @@ import { Script } from "@opencode-ai/script"
|
||||
import { $ } from "bun"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { pack } from "./pack"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
@@ -18,8 +19,7 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
|
||||
try {
|
||||
await $`bun run typecheck`
|
||||
await $`bun run test`
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
await pack()
|
||||
await $`npm publish ${tarball} --access public --tag ${Script.channel}`
|
||||
} finally {
|
||||
await rm(tarball, { force: true })
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--background-stronger);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background-color: var(--v2-background-bg-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: clip;
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
/* text-12-regular */
|
||||
@@ -52,10 +52,10 @@
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--surface-base-hover);
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
&:active:not([data-disabled]) {
|
||||
background-color: var(--surface-base-active);
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
@@ -82,9 +82,9 @@
|
||||
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
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 {
|
||||
from {
|
||||
height: 0;
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
--card-gap: 8px;
|
||||
--card-icon: 16px;
|
||||
--card-indent: 0px;
|
||||
--card-line-pad: 8px;
|
||||
|
||||
--card-accent: var(--icon-active);
|
||||
--card-accent: var(--v2-icon-icon-base);
|
||||
|
||||
&:has([data-slot="card-title"]) {
|
||||
gap: 8px;
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
|
||||
:where([data-card="title"], [data-slot="card-title"]) {
|
||||
color: var(--text-strong);
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
: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"])
|
||||
@@ -82,7 +82,7 @@
|
||||
}
|
||||
|
||||
:where([data-card="description"], [data-slot="card-description"]) {
|
||||
color: var(--text-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
@@ -92,3 +92,24 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Card(props: CardProps) {
|
||||
const variant = () => split.variant ?? "normal"
|
||||
const accent = () => {
|
||||
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 === "success") return "var(--icon-success-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-accent: var(--v2-text-text-accent);
|
||||
--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-muted: var(--v2-icon-icon-muted);
|
||||
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"text-base": "#6F6F6F",
|
||||
"text-weak": "#8F8F8F",
|
||||
"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-weaker-base": "#E8E8E8",
|
||||
"icon-base": "#8F8F8F",
|
||||
@@ -31,14 +33,14 @@
|
||||
"surface-interactive-weak": "#F5FAFF",
|
||||
"icon-success-base": "#0ABE00",
|
||||
"surface-success-base": "#E6FFE5",
|
||||
"syntax-comment": "#7a7a7a",
|
||||
"syntax-keyword": "#a753ae",
|
||||
"syntax-string": "#00ceb9",
|
||||
"syntax-primitive": "#034cff",
|
||||
"syntax-property": "#a753ae",
|
||||
"syntax-type": "#8a6f00",
|
||||
"syntax-comment": "var(--v2-text-text-muted)",
|
||||
"syntax-keyword": "var(--v2-pink-800)",
|
||||
"syntax-string": "var(--v2-green-800)",
|
||||
"syntax-primitive": "var(--v2-pink-800)",
|
||||
"syntax-property": "var(--v2-orange-800)",
|
||||
"syntax-type": "var(--v2-purple-800)",
|
||||
"syntax-constant": "#007b80",
|
||||
"syntax-critical": "#ff8c00",
|
||||
"syntax-critical": "var(--v2-red-800)",
|
||||
"syntax-diff-delete": "#ff8c00",
|
||||
"syntax-diff-unknown": "#a753ae",
|
||||
"surface-critical-base": "#FFF2F0"
|
||||
@@ -170,6 +172,7 @@
|
||||
"v2-text-text-contrast": "var(--v2-grey-50)",
|
||||
"v2-text-text-accent": "var(--v2-blue-600)",
|
||||
"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-muted": "var(--v2-grey-600)",
|
||||
"v2-icon-icon-inverse": "var(--v2-grey-50)",
|
||||
@@ -252,6 +255,8 @@
|
||||
"text-base": "#A0A0A0",
|
||||
"text-weak": "#707070",
|
||||
"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-weaker-base": "#232323",
|
||||
"icon-base": "#7E7E7E",
|
||||
@@ -262,14 +267,14 @@
|
||||
"surface-base-hover": "#FFFFFF0D",
|
||||
"surface-interactive-weak": "#0D172B",
|
||||
"surface-success-base": "#022B00",
|
||||
"syntax-comment": "#8f8f8f",
|
||||
"syntax-keyword": "#edb2f1",
|
||||
"syntax-string": "#00ceb9",
|
||||
"syntax-primitive": "#8cb0ff",
|
||||
"syntax-property": "#fab283",
|
||||
"syntax-type": "#fcd53a",
|
||||
"syntax-comment": "var(--v2-text-text-muted)",
|
||||
"syntax-keyword": "var(--v2-pink-400)",
|
||||
"syntax-string": "var(--v2-green-400)",
|
||||
"syntax-primitive": "var(--v2-pink-400)",
|
||||
"syntax-property": "var(--v2-orange-400)",
|
||||
"syntax-type": "var(--v2-purple-400)",
|
||||
"syntax-constant": "#93e9f6",
|
||||
"syntax-critical": "#fab283",
|
||||
"syntax-critical": "var(--v2-red-400)",
|
||||
"syntax-diff-delete": "#fab283",
|
||||
"syntax-diff-unknown": "#edb2f1",
|
||||
"surface-critical-base": "#1F0603"
|
||||
@@ -401,6 +406,7 @@
|
||||
"v2-text-text-contrast": "var(--v2-grey-50)",
|
||||
"v2-text-text-accent": "var(--v2-blue-400)",
|
||||
"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-muted": "var(--v2-grey-600)",
|
||||
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
|
||||
|
||||
@@ -18,6 +18,7 @@ const light: Record<string, V2ColorValue> = {
|
||||
"v2-text-text-contrast": ref("v2-grey-100"),
|
||||
"v2-text-text-accent": ref("v2-blue-600"),
|
||||
"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-base": ref("v2-alpha-dark-10"),
|
||||
"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-accent": ref("v2-blue-400"),
|
||||
"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-base": ref("v2-alpha-light-10"),
|
||||
"v2-border-border-strong": ref("v2-alpha-light-20"),
|
||||
|
||||
@@ -49,6 +49,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
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: {
|
||||
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"/>`,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
@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 {
|
||||
color-scheme: light;
|
||||
|
||||
@@ -22,6 +34,7 @@
|
||||
--v2-text-text-contrast: var(--v2-grey-50);
|
||||
--v2-text-text-accent: var(--v2-blue-600);
|
||||
--v2-text-text-accent-hover: var(--v2-blue-700);
|
||||
--v2-text-text-code-accent: var(--v2-blue-900);
|
||||
|
||||
/* ── Icon ── */
|
||||
--v2-icon-icon-base: var(--v2-grey-800);
|
||||
@@ -141,6 +154,7 @@
|
||||
--v2-text-text-contrast: var(--v2-grey-100);
|
||||
--v2-text-text-accent: var(--v2-blue-400);
|
||||
--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-muted: var(--v2-grey-600);
|
||||
@@ -243,6 +257,7 @@
|
||||
--v2-text-text-contrast: var(--v2-grey-50);
|
||||
--v2-text-text-accent: var(--v2-blue-600);
|
||||
--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-muted: var(--v2-grey-600);
|
||||
@@ -352,6 +367,7 @@
|
||||
--v2-text-text-contrast: var(--v2-grey-50);
|
||||
--v2-text-text-accent: var(--v2-blue-400);
|
||||
--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-muted: var(--v2-grey-600);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -25,6 +25,11 @@ sessions.interrupt(sessionID)
|
||||
-> clears a coalesced follow-up wake already registered with this coordinator
|
||||
-> preserves durable inbox rows for a later wake or resume
|
||||
-> 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.
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user