Compare commits

...

11 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 cac531148a fix(desktop): show interrupt loading state 2026-06-26 09:42:26 +00:00
vimtor eeb5b1d8bc core: prevent UI imports compiling TSX source 2026-06-26 10:25:48 +02:00
opencode-agent[bot] f8ceb30b43 chore: generate 2026-06-26 08:06:37 +00:00
usrnk1 78a5a030ce feat(app): refine session UI styling (#33860)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-06-26 08:04:56 +00:00
opencode-agent[bot] 219ba24d90 chore: update nix node_modules hashes 2026-06-26 05:43:54 +00:00
Aiden Cline e8fea9e63a feat(oauth): unify OAuth callback browser pages (#34025) 2026-06-26 00:36:40 -05:00
opencode-agent[bot] 11537260aa chore: generate 2026-06-26 05:31:26 +00:00
Brendan Allan 929c4aaf29 refactor(app): replace tab drag handling with dndkit (#33880)
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
2026-06-26 05:29:58 +00:00
opencode-agent[bot] 19e510f5d2 refactor(app): use dropdown for project selector (#33984) 2026-06-26 13:18:23 +08:00
opencode-agent[bot] 8a1608ed1d chore: update nix node_modules hashes 2026-06-26 05:13:59 +00:00
opencode-agent[bot] 077deb9d82 fix(app): bump ghostty-web to prevent terminal resize hangs (#34020)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-06-26 14:59:18 +10:00
47 changed files with 1785 additions and 1399 deletions
+22 -2
View File
@@ -31,6 +31,10 @@
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.17.11", "version": "1.17.11",
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -59,7 +63,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
@@ -1451,6 +1455,20 @@
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="],
"@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="],
"@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="],
"@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="],
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
@@ -2287,6 +2305,8 @@
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
"@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="],
"@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="],
"@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="],
@@ -3781,7 +3801,7 @@
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="], "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-drc/Ev96W6b8b0b5LqdZeeGDQ1SMgsz8r5cMO91ei2o=", "x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-Ti0hNjhUgkVtdb54vea/lpI0ltDwLoPitVyHtx4JGwY=", "aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-br4iQ/kK3tSGp+1FefiCTlwsCRhHHhGbKzSixGWaCto=", "aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-h7yje968Kyh8/mVY19YmDB5g693XDhIf0XnuwukzCWE=" "x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
} }
} }
+5 -1
View File
@@ -44,6 +44,10 @@
"vite-plugin-solid": "catalog:" "vite-plugin-solid": "catalog:"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -72,7 +76,7 @@
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main", "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
+3 -1
View File
@@ -16,6 +16,7 @@ import {
type Component, type Component,
createEffect, createEffect,
createMemo, createMemo,
createRenderEffect,
createResource, createResource,
createSignal, createSignal,
ErrorBoundary, ErrorBoundary,
@@ -278,10 +279,11 @@ function QueryProvider(props: ParentProps) {
function BodyDesignClass() { function BodyDesignClass() {
const settings = useSettings() const settings = useSettings()
createEffect(() => { createRenderEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns() const enabled = settings.general.newLayoutDesigns()
document.body.toggleAttribute("data-new-layout", enabled)
document.body.classList.toggle("text-12-regular", !enabled) document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled) document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled) document.body.classList.toggle("text-[13px]", enabled)
+42 -17
View File
@@ -66,6 +66,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state" import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@@ -339,6 +340,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(), () => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length), Math.floor(Math.random() * EXAMPLES.length),
) )
const [submissionState, setSubmissionState] = createStore({ interrupting: false })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({ const motion = (value: number) => ({
opacity: value, opacity: value,
@@ -1180,6 +1182,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
}) })
const interrupt = () => {
if (submissionState.interrupting) return
if (platform.platform !== "desktop" || !props.controls.newLayoutDesigns) return abort()
setSubmissionState("interrupting", true)
return Promise.resolve()
.then(() => abort())
.finally(() => setSubmissionState("interrupting", false))
}
const handleV2Submit = (event: Event) => {
if (!stopping() || platform.platform !== "desktop") return handleSubmit(event)
event.preventDefault()
return interrupt()
}
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault() event.preventDefault()
@@ -1232,7 +1250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (working()) { if (working()) {
void abort() void interrupt()
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return return
@@ -1298,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (working()) { if (working()) {
void abort() void interrupt()
event.preventDefault() event.preventDefault()
} }
return return
@@ -1410,7 +1428,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<DockShellForm <DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"} data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit} onSubmit={handleV2Submit}
classList={{ classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true, "group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
@@ -1554,20 +1572,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
</div> </div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}> <Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton <span class="relative flex size-7">
data-action="prompt-submit" <IconButton
type="submit" data-action="prompt-submit"
disabled={!working() && blank()} type="submit"
tabIndex={store.mode === "normal" ? undefined : -1} disabled={submissionState.interrupting || (!working() && blank())}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"} tabIndex={store.mode === "normal" ? undefined : -1}
variant="primary" icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50" variant="primary"
style={{ class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
"background-image": classList={{ "[&_[data-slot=icon-svg]]:invisible": submissionState.interrupting }}
"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%)", style={{
}} "background-image":
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} "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> </Tooltip>
</div> </div>
</DockShellForm> </DockShellForm>
@@ -1,7 +1,8 @@
import { Popover } from "@kobalte/core/popover"
import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { getProjectAvatarVariant } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -25,12 +26,23 @@ export type PromptProjectControls = {
add: (title: string, server?: string) => void add: (title: string, server?: string) => void
} }
const actionPrefix = "action:"
const projectPrefix = "project:"
function projectKey(project: PromptProject) {
return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}`
}
function actionKey(server?: string) {
return `${actionPrefix}${encodeURIComponent(server ?? "")}`
}
export function createPromptProjectController(input: { export function createPromptProjectController(input: {
controls: Accessor<PromptProjectControls> controls: Accessor<PromptProjectControls>
onDone: () => void onDone: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "" }) const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined let searchRef: HTMLInputElement | undefined
const selected = () => { const selected = () => {
@@ -53,8 +65,25 @@ export function createPromptProjectController(input: {
.controls() .controls()
.available.map((project) => project.server) .available.map((project) => project.server)
.filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index)
const keys = () => {
if (servers().length <= 1) {
return [...projects().map(projectKey), actionKey(servers()[0]?.key)]
}
return servers().flatMap((server) => [
...projects()
.filter((project) => project.server?.key === server!.key)
.map(projectKey),
actionKey(server!.key),
])
}
const initialActive = () => {
const selectedKey = selected() ? projectKey(selected()!) : undefined
const options = keys()
if (selectedKey && options.includes(selectedKey)) return selectedKey
return options[0] ?? ""
}
const close = () => { const close = () => {
setStore({ open: false, search: "" }) setStore({ open: false, search: "", active: "" })
input.onDone() input.onDone()
} }
const select = (project: PromptProject) => { const select = (project: PromptProject) => {
@@ -67,7 +96,7 @@ export function createPromptProjectController(input: {
close() close()
} }
const add = (server?: string) => { const add = (server?: string) => {
setStore("open", false) setStore({ open: false, search: "", active: "" })
input.controls().add(language.t("command.project.open"), server) input.controls().add(language.t("command.project.open"), server)
} }
@@ -75,8 +104,11 @@ export function createPromptProjectController(input: {
selected, selected,
projects, projects,
servers, servers,
projectKey,
actionKey,
open: () => store.open, open: () => store.open,
search: () => store.search, search: () => store.search,
active: () => store.active,
labels: { labels: {
add: () => language.t("session.new.project.add"), add: () => language.t("session.new.project.add"),
clear: () => language.t("common.clear"), clear: () => language.t("common.clear"),
@@ -86,50 +118,188 @@ export function createPromptProjectController(input: {
add, add,
select, select,
setOpen(open: boolean) { setOpen(open: boolean) {
setStore("open", open) if (open) {
if (open) requestAnimationFrame(() => searchRef?.focus()) setStore({ open: true, active: initialActive() })
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
return
}
setStore({ open: false, search: "", active: "" })
}, },
setSearch(value: string) { setSearch(value: string) {
setStore("search", value) const search = value.trim().toLowerCase()
const first = input
.controls()
.available.find((project) => !search || displayName(project).toLowerCase().includes(search))
setStore({ search: value, active: first ? projectKey(first) : actionKey(servers()[0]?.key) })
},
clearSearch() {
setStore({ search: "", active: initialActive() })
setTimeout(() => searchRef?.focus())
},
setActive(key: string) {
setStore("active", key)
},
moveActive(delta: number) {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
},
activeProject() {
return store.active.startsWith(projectPrefix)
? projects().find((project) => projectKey(project) === store.active)
: undefined
},
activeServer() {
return store.active.startsWith(actionPrefix)
? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined
: undefined
}, },
setSearchRef(el: HTMLInputElement) { setSearchRef(el: HTMLInputElement) {
searchRef = el searchRef = el
}, },
focusSearch() {
setTimeout(() => requestAnimationFrame(() => searchRef?.focus()))
},
} }
} }
export type PromptProjectController = ReturnType<typeof createPromptProjectController> export type PromptProjectController = ReturnType<typeof createPromptProjectController>
export function PromptProjectSelector(props: { controller: PromptProjectController }) { export function PromptProjectSelector(props: { controller: PromptProjectController }) {
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
if (project) {
selectProject(project)
return
}
selectAction(props.controller.activeServer())
}
const moveActive = (delta: number) => {
props.controller.moveActive(delta)
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const focusPreviousControl = () => {
const target = Array.from(
document.querySelectorAll<HTMLElement>(
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
})
}
const selectedValue = () => {
const project = props.controller.selected()
return project ? props.controller.projectKey(project) : undefined
}
return ( return (
<Popover <DropdownMenu
open={props.controller.open()} open={props.controller.open()}
placement="bottom-start" placement="bottom-start"
gutter={4} gutter={4}
shift={-6}
modal={false} modal={false}
onOpenChange={(open) => props.controller.setOpen(open)} onOpenChange={(open) => props.controller.setOpen(open)}
> >
<Popover.Trigger as={ProjectTrigger} controller={props.controller} /> <DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
<Popover.Portal> <DropdownMenu.Portal>
<Popover.Content <DropdownMenu.Content
class="w-[243px] overflow-hidden rounded-md bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none" ref={contentRef}
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
> >
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded px-3 text-v2-icon-icon-muted"> <div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" /> <Icon name="magnifying-glass" size="small" class="shrink-0" />
<input <input
ref={(el) => props.controller.setSearchRef(el)} ref={(el) => props.controller.setSearchRef(el)}
value={props.controller.search()} value={props.controller.search()}
placeholder={props.controller.labels.search()} placeholder={props.controller.labels.search()}
aria-autocomplete="list"
aria-controls="prompt-project-menu"
aria-activedescendant={props.controller.active() || undefined}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => props.controller.setSearch(event.currentTarget.value)} onInput={(event) => props.controller.setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") {
event.preventDefault()
event.stopPropagation()
if (event.shiftKey) {
focusPreviousControl()
return
}
activeItem()?.focus()
return
}
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
props.controller.setOpen(false)
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/> />
<Show when={props.controller.search().trim()}> <Show when={props.controller.search().trim()}>
<button <button
type="button" type="button"
class="flex size-5 items-center justify-center rounded text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover" class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={() => props.controller.setSearch("")} onPointerDown={(event) => event.preventDefault()}
onClick={() => props.controller.clearSearch()}
aria-label={props.controller.labels.clear()} aria-label={props.controller.labels.clear()}
> >
<Icon name="close-small" size="small" /> <Icon name="close-small" size="small" />
@@ -139,15 +309,13 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<Show <Show
when={props.controller.servers().length > 1} when={props.controller.servers().length > 1}
fallback={ fallback={
<For each={props.controller.projects()}> <DropdownMenu.RadioGroup value={selectedValue()}>
{(project) => ( <For each={props.controller.projects()}>
<ProjectItem {(project) => (
project={project} <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
selected={props.controller.selected()} )}
onSelect={props.controller.select} </For>
/> </DropdownMenu.RadioGroup>
)}
</For>
} }
> >
<For each={props.controller.servers()}> <For each={props.controller.servers()}>
@@ -156,19 +324,14 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint"> <div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name} {server!.name}
</div> </div>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}> <DropdownMenu.RadioGroup value={selectedValue()}>
{(project) => ( <For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
<ProjectItem {(project) => (
project={project} <ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
selected={props.controller.selected()} )}
onSelect={props.controller.select} </For>
/> </DropdownMenu.RadioGroup>
)} <ProjectAction server={server!.key} controller={props.controller} onSelect={selectAction} />
</For>
<ProjectAction
label={props.controller.labels.add()}
onSelect={() => props.controller.add(server!.key)}
/>
</div> </div>
)} )}
</For> </For>
@@ -178,14 +341,15 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
<div class="h-px bg-v2-border-border-muted" /> <div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5"> <div class="flex flex-col p-0.5">
<ProjectAction <ProjectAction
label={props.controller.labels.add()} server={props.controller.servers()[0]?.key}
onSelect={() => props.controller.add(props.controller.servers()[0]?.key)} controller={props.controller}
onSelect={selectAction}
/> />
</div> </div>
</Show> </Show>
</Popover.Content> </DropdownMenu.Content>
</Popover.Portal> </DropdownMenu.Portal>
</Popover> </DropdownMenu>
) )
} }
@@ -205,15 +369,28 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
} }
function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) {
const [local, rest] = splitProps(props, ["controller", "class", "onClick"]) const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"])
const project = () => local.controller.selected() const project = () => local.controller.selected()
return ( return (
<button <button
{...rest} {...rest}
data-action="prompt-project" data-action="prompt-project"
type="button" type="button"
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={() => local.controller.setOpen(true)} classList={{
...local.classList,
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
}}
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
onKeyDown={(event) => {
if (!local.controller.open() && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault()
event.stopPropagation()
return
}
if (typeof local.onKeyDown === "function") local.onKeyDown(event)
}}
> >
<Show <Show
when={project()} when={project()}
@@ -237,42 +414,77 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
function ProjectItem(props: { function ProjectItem(props: {
project: PromptProject project: PromptProject
selected?: PromptProject controller: PromptProjectController
onSelect: (project: PromptProject) => void onSelect: (project: PromptProject) => void
}) { }) {
const key = () => props.controller.projectKey(props.project)
return ( return (
<button <DropdownMenu.RadioItem
type="button" id={key()}
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" value={key()}
onClick={() => props.onSelect(props.project)} data-option-key={key()}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
closeOnSelect
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.project)}
> >
<ProjectAvatar <ProjectAvatar
fallback={displayName(props.project)} fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)} src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)} variant={getProjectAvatarVariant(props.project.icon?.color)}
/> />
<span class="min-w-0 flex-1 truncate leading-5">{displayName(props.project)}</span> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">{displayName(props.project)}</DropdownMenu.ItemLabel>
<Show <DropdownMenu.ItemIndicator style={{ width: "14px", height: "14px", right: "12px" }}>
when={ <IconV2 name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
props.selected?.worktree === props.project.worktree && </DropdownMenu.ItemIndicator>
props.selected?.server?.key === props.project.server?.key </DropdownMenu.RadioItem>
}
>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
) )
} }
function ProjectAction(props: { label: string; onSelect: () => void }) { function ProjectAction(props: {
server?: string
controller: PromptProjectController
onSelect: (server?: string) => void
}) {
const key = () => props.controller.actionKey(props.server)
return ( return (
<button <DropdownMenu.Item
type="button" id={key()}
class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" data-option-key={key()}
onClick={props.onSelect} class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
classList={{ "!bg-v2-overlay-simple-overlay-hover": props.controller.active() === key() }}
style={{
"font-family": "var(--v2-font-family-sans)",
"font-size": "13px",
"font-weight": 440,
"line-height": "20px",
"letter-spacing": "-0.04px",
color: "var(--v2-text-text-base)",
padding: "0 12px",
}}
onMouseEnter={() => {
props.controller.setActive(key())
props.controller.focusSearch()
}}
onSelect={() => props.onSelect(props.server)}
> >
<Icon name="plus" size="small" /> <Icon name="plus" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{props.label}</span> <DropdownMenu.ItemLabel class="min-w-0 truncate leading-5">
</button> {props.controller.labels.add()}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
) )
} }
@@ -1,141 +0,0 @@
import { describe, expect, test } from "bun:test"
import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag"
import {
canOpenTabRename,
captureTabPointerDown,
canStartTabDrag,
createTabDragPreview,
forwardTabRef,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
describe("titlebar tab drag", () => {
const layout = {
listLeft: 100,
dividerWidth: 13,
tabWidthById: new Map([
["a", 40],
["b", 40],
["c", 40],
["d", 40],
]),
}
test("moves across multiple tabs from one pointer update", () => {
expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3)
expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0)
})
test("keeps the current index inside the left hysteresis deadband", () => {
expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1)
})
test("includes slot margins in captured divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
second.style.marginLeft = "6px"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
document.body.append(list)
firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect
second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect
list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13)
list.remove()
})
test("uses the list gap as the divider width", () => {
const list = document.createElement("div")
const first = document.createElement("div")
const second = document.createElement("div")
const firstTab = document.createElement("div")
const secondTab = document.createElement("div")
first.dataset.titlebarTabSlot = ""
first.dataset.tabKey = "a"
second.dataset.titlebarTabSlot = ""
second.dataset.tabKey = "b"
firstTab.dataset.titlebarTab = ""
secondTab.dataset.titlebarTab = ""
first.append(firstTab)
second.append(secondTab)
list.append(first, second)
list.style.columnGap = "13.5px"
document.body.append(list)
expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5)
list.remove()
})
})
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("keeps the rendered tab content in the drag preview", () => {
const tab = document.createElement("div")
tab.innerHTML = '<span data-slot="project-avatar-slot"></span><span data-slot="tab-title">Session</span>'
const preview = createTabDragPreview(tab)
expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull()
expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session")
})
test("captures the grab offset before navigation scrolls the tab", () => {
const tab = document.createElement("div")
tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect
expect(captureTabPointerDown(tab, 100, 20)).toEqual({
startX: 100,
startY: 20,
grabOffsetX: 20,
grabOffsetY: 10,
width: 120,
element: tab,
})
})
test("detects when the primary pointer button was released outside the window", () => {
expect(isPrimaryPointerPressed(1)).toBe(true)
expect(isPrimaryPointerPressed(3)).toBe(true)
expect(isPrimaryPointerPressed(0)).toBe(false)
expect(isPrimaryPointerPressed(2)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -1,137 +0,0 @@
export type TabDragLayout = {
tabWidthById: Map<string, number>
dividerWidth: number
listLeft: number
}
export const ACTIVATION_DISTANCE = 4
export const HYSTERESIS_DEADBAND = 8
export const AUTOSCROLL_EDGE = 24
export const AUTOSCROLL_MAX_SPEED = 8
export const FLOATER_OVERSHOOT_MAX = 8
export function pointerDistance(x1: number, y1: number, x2: number, y2: number) {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const tabWidthById = new Map<string, number>()
const slots = list.querySelectorAll<HTMLElement>("[data-titlebar-tab-slot]")
for (const slot of slots) {
const id = slot.dataset.tabKey
if (!id) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width)
}
let dividerWidth = 0
if (order.length >= 2) {
const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
const secondId = order[1]
for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue
const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break
const style = getComputedStyle(slot)
dividerWidth =
gap ||
slot.getBoundingClientRect().width -
tab.getBoundingClientRect().width +
(Number.parseFloat(style.marginLeft) || 0) +
(Number.parseFloat(style.marginRight) || 0)
break
}
}
return {
tabWidthById,
dividerWidth,
listLeft: list.getBoundingClientRect().left,
}
}
export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) {
layout.listLeft = list.getBoundingClientRect().left
}
function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) {
const id = order[index]
if (!id) return 0
const tabWidth = layout.tabWidthById.get(id) ?? 0
return index === 0 ? tabWidth : layout.dividerWidth + tabWidth
}
function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) {
let left = layout.listLeft
for (let i = 0; i < index; i++) {
left += slotWidthAt(order, i, layout)
}
return left
}
export function insertIndexFromVirtualLayout(
pointerX: number,
order: readonly string[],
draggedId: string,
currentIndex: number,
layout: TabDragLayout,
deadband = HYSTERESIS_DEADBAND,
) {
if (order.length === 0) return 0
const others = order.filter((id) => id !== draggedId)
let target = currentIndex
while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target--
while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++
return target
}
export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) {
const fromIndex = order.indexOf(draggedId)
if (fromIndex === -1 || fromIndex === toIndex) return [...order]
const next = [...order]
next.splice(toIndex, 0, ...next.splice(fromIndex, 1))
return next
}
export function draftOrderChanged(initial: readonly string[], final: readonly string[]) {
if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false
return final.some((key, index) => key !== initial[index])
}
function easeOvershoot(overshoot: number) {
return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX)
}
export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) {
const stripWidth = stripRight - stripLeft
if (width >= stripWidth) return stripLeft
const maxLeft = stripRight - width
if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft)
if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left)
return left
}
export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) {
const leftEdge = containerLeft + AUTOSCROLL_EDGE
const rightEdge = containerRight - AUTOSCROLL_EDGE
if (pointerX < leftEdge) {
const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE
return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
if (pointerX > rightEdge) {
const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE
return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1))
}
return 0
}
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture"
describe("titlebar tab gestures", () => {
test("excludes close controls from tab gestures", () => {
const close = document.createElement("div")
const button = document.createElement("button")
const link = document.createElement("a")
close.dataset.slot = "tab-close"
close.append(button)
expect(isTabCloseTarget(close)).toBe(true)
expect(isTabCloseTarget(button)).toBe(true)
expect(isTabCloseTarget(link)).toBe(false)
})
test("forwards component refs", () => {
const element = document.createElement("div")
let received: HTMLDivElement | undefined
forwardTabRef((value) => (received = value), element)
expect(received).toBe(element)
})
test("does not reopen rename while a save is pending", () => {
expect(canOpenTabRename(false, false, false)).toBe(true)
expect(canOpenTabRename(false, false, true)).toBe(false)
})
test("preserves native panning for touch pointers", () => {
expect(canStartTabDrag("mouse")).toBe(true)
expect(canStartTabDrag("pen")).toBe(true)
expect(canStartTabDrag("touch")).toBe(false)
})
})
@@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) {
return pointerType !== "touch" return pointerType !== "touch"
} }
export function isPrimaryPointerPressed(buttons: number) {
return (buttons & 1) !== 0
}
export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) {
const rect = element.getBoundingClientRect()
return {
startX: clientX,
startY: clientY,
grabOffsetX: clientX - rect.left,
grabOffsetY: clientY - rect.top,
width: rect.width,
element,
}
}
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) { export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
if (typeof ref === "function") ref(element) if (typeof ref === "function") ref(element)
} }
@@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing return !dragging && !editing && !committing
} }
export function createTabDragPreview(element: HTMLDivElement) {
return element.cloneNode(true) as HTMLDivElement
}
@@ -9,6 +9,10 @@
justify-content: center; justify-content: center;
} }
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: 13.5px; gap: 13.5px;
} }
+139 -159
View File
@@ -29,9 +29,6 @@ export function TabNavItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
@@ -173,112 +170,105 @@ export function TabNavItem(props: {
return ( return (
<div <div
data-titlebar-tab-slot ref={(el) => {
data-tab-key={props.tabKey} tabRoot = el
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" forwardTabRef(props.ref, el)
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }} }}
onPointerDown={props.onPointerDown} 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 <Show when={props.session()}>
ref={(el) => { {(session) => {
tabRoot = el return (
forwardTabRef(props.ref, el) <a
}} data-slot="tab-link"
data-titlebar-tab data-titlebar-tab-link
data-slot="titlebar-tab-item" href={props.href}
data-title-overflow={titleOverflowing()} draggable={false}
data-editing={editing()} onDragStart={(event) => {
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)]" event.preventDefault()
data-active={props.active} event.stopPropagation()
data-dragging={props.dragging} }}
data-pressed={props.pressed} onClick={(event) => {
onMouseDown={(event) => { event.preventDefault()
if (event.button !== 1) return if (editing()) return
closeTab(event) if (props.suppressNavigation?.()) return
}} props.onNavigate()
> }}
<Show when={props.session()}> 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]"
{(session) => { >
return ( <span data-slot="project-avatar-slot">
<a <SessionTabAvatar
data-slot="tab-link" project={project()}
data-titlebar-tab-link directory={session().directory}
href={props.href} sessionId={session().id}
draggable={false} activeServer={props.activeServer}
onDragStart={(event) => { />
</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() event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation() event.stopPropagation()
}} }}
onClick={(event) => { onClick={(event) => {
if (!editing()) return
event.preventDefault() 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]" />
> </a>
<span data-slot="project-avatar-slot"> )
<SessionTabAvatar }}
project={project()} </Show>
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>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]"> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2 <IconButtonV2
size="small" size="small"
variant="ghost-muted" 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" class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
onClick={closeTab} onClick={closeTab}
icon={<IconV2 name="xmark-small" />} icon={<IconV2 name="xmark-small" />}
/> />
</div>
</div> </div>
</div> </div>
) )
@@ -295,9 +285,6 @@ export function DraftTabItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@@ -306,69 +293,62 @@ export function DraftTabItem(props: {
} }
return ( return (
<div <div
data-titlebar-tab-slot ref={(el) => forwardTabRef(props.ref, el)}
data-tab-key={props.tabKey} data-titlebar-tab
class="relative flex w-56 min-w-7 max-w-56 flex-shrink" data-slot="titlebar-tab-item"
classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }} data-active={props.active}
onPointerDown={props.onPointerDown} 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 <a
ref={(el) => forwardTabRef(props.ref, el)} data-slot="tab-link"
data-titlebar-tab data-titlebar-tab-link
data-slot="titlebar-tab-item" href={props.href}
data-active={props.active} draggable={false}
data-dragging={props.dragging} onDragStart={(event) => {
data-pressed={props.pressed} event.preventDefault()
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)]" event.stopPropagation()
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}} }}
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 <span class="flex size-4 shrink-0 items-center justify-center">
data-slot="tab-link" <IconV2 name="edit" />
data-titlebar-tab-link </span>
href={props.href} <span
draggable={false} data-titlebar-tab-title
onDragStart={(event) => { 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.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
onClick={(event) => { onMouseDown={(event) => {
event.preventDefault() event.preventDefault()
if (props.suppressNavigation?.()) return event.stopPropagation()
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]" class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
> onClick={closeTab}
<span class="flex size-4 shrink-0 items-center justify-center"> icon={<IconV2 name="xmark-small" />}
<IconV2 name="edit" /> aria-label="Close tab"
</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>
</div> </div>
</div> </div>
) )
+159 -392
View File
@@ -1,18 +1,11 @@
import { import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js"
createEffect,
createMemo,
createResource,
createRoot,
createSignal,
For,
onCleanup,
onMount,
Show,
} from "solid-js"
import { Portal } from "solid-js/web"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
@@ -22,42 +15,30 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
captureTabPointerDown,
canStartTabDrag, const sortableTransition = { duration: 0 }
createTabDragPreview,
isPrimaryPointerPressed,
isTabCloseTarget,
} from "./titlebar-tab-gesture"
import {
ACTIVATION_DISTANCE,
autoscrollSpeed,
captureTabDragLayout,
clampFloaterLeft,
draftOrderChanged,
insertIndexFromVirtualLayout,
movePlaceholder,
pointerDistance,
syncLayoutScroll,
type TabDragLayout,
} from "@/components/titlebar-tab-drag"
function SessionTabSlot(props: { function SessionTabSlot(props: {
tab: SessionTab tab: SessionTab
id: string id: string
index: () => number
active: () => boolean active: () => boolean
activeServerKey: ServerConnection.Key activeServerKey: ServerConnection.Key
forceTruncate: boolean forceTruncate: boolean
dragActive: boolean
dragged: () => boolean
pressed: () => boolean
serverCtx: () => ServerCtx | undefined serverCtx: () => ServerCtx | undefined
suppressNavigation: () => boolean
onPointerDown: (event: PointerEvent) => void
onNavigate: (element: HTMLDivElement) => void onNavigate: (element: HTMLDivElement) => void
onClose: () => void onClose: () => void
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index()
},
})
let ref!: HTMLDivElement let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
@@ -100,33 +81,79 @@ function SessionTabSlot(props: {
}) })
return ( return (
<TabNavItem <div
tabKey={props.id} ref={sortable.ref}
dragActive={props.dragActive} data-titlebar-tab-slot
onPointerDown={props.onPointerDown} data-tab-key={props.id}
ref={ref} class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
href={tabHref(props.tab)} classList={{ hidden: !session() }}
server={props.tab.server} >
session={session} <TabNavItem
onTitleChange={(title) => { ref={(el) => {
const value = session() ref = el
const ctx = props.serverCtx() }}
if (value && ctx) ctx.sync.session.remember({ ...value, title }) href={tabHref(props.tab)}
}} server={props.tab.server}
onTitleChangeFailed={(title) => { session={session}
const value = session() onTitleChange={(title) => {
const ctx = props.serverCtx() const value = session()
if (value && ctx) ctx.sync.session.remember({ ...value, title }) const ctx = props.serverCtx()
}} if (value && ctx) ctx.sync.session.remember({ ...value, title })
onNavigate={() => props.onNavigate(ref)} }}
onClose={props.onClose} onTitleChangeFailed={(title) => {
active={props.active()} const value = session()
activeServer={props.tab.server === props.activeServerKey} const ctx = props.serverCtx()
forceTruncate={props.forceTruncate} if (value && ctx) ctx.sync.session.remember({ ...value, title })
suppressNavigation={props.suppressNavigation} }}
pressed={props.pressed()} onNavigate={() => props.onNavigate(ref)}
hidden={props.dragged() || !session()} 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 global = useGlobal()
const language = useLanguage() const language = useLanguage()
const [drag, setDrag] = createStore({
active: false,
draggedId: undefined as string | undefined,
placeholderIndex: 0,
draftOrder: [] as string[],
initialOrder: [] as string[],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
const [gesture, setGesture] = createStore({
pending: undefined as
| {
id: string
startX: number
startY: number
grabOffsetX: number
grabOffsetY: number
pointerId: number
width: number
element: HTMLDivElement
}
| undefined,
})
const [suppressNavigation, setSuppressNavigation] = createSignal(false)
const [pressedId, setPressedId] = createSignal<string | undefined>()
const [stripScrollLeft, setStripScrollLeft] = createSignal(0)
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement let listRef!: HTMLDivElement
let dragLayout: TabDragLayout | undefined
let dragPointerId: number | undefined
let autoscrollFrame: number | undefined
let resizeFrame: number | undefined let resizeFrame: number | undefined
let dragPreview: HTMLDivElement | undefined
const tabIds = () => props.tabs.map(tabKey) const tabIds = () => props.tabs.map(tabKey)
const displayTabs = createMemo(() => {
if (!drag.active || drag.draftOrder.length === 0) return props.tabs
const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab]))
return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab)
})
function refreshOverflow() { function refreshOverflow() {
if (!scrollRef) return if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
@@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: {
resizeFrame = requestAnimationFrame(() => { resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined resizeFrame = undefined
refreshOverflow() refreshOverflow()
if (!drag.active || !listRef) return
dragLayout = captureTabDragLayout(listRef, drag.draftOrder)
updateInsertIndex()
}) })
}, },
) )
function syncScroll() {
if (!scrollRef || !listRef || !dragLayout) return
syncLayoutScroll(listRef, dragLayout)
setStripScrollLeft(scrollRef.scrollLeft)
updateInsertIndex()
}
function stopAutoscroll() {
if (autoscrollFrame === undefined) return
cancelAnimationFrame(autoscrollFrame)
autoscrollFrame = undefined
}
function tickAutoscroll() {
if (!drag.active || !scrollRef) return
const strip = scrollRef.getBoundingClientRect()
const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right)
if (speed !== 0) {
scrollRef.scrollLeft += speed
syncScroll()
}
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function startAutoscroll() {
stopAutoscroll()
autoscrollFrame = requestAnimationFrame(tickAutoscroll)
}
function applyPlaceholderIndex(nextIndex: number) {
const id = drag.draggedId
if (!id) return
const next = movePlaceholder(drag.draftOrder, id, nextIndex)
setDrag({
draftOrder: next,
placeholderIndex: nextIndex,
})
}
function updateInsertIndex() {
if (!drag.active || !dragLayout) return
const draggedId = drag.draggedId
if (!draggedId) return
const nextIndex = insertIndexFromVirtualLayout(
drag.pointerX,
drag.draftOrder,
draggedId,
drag.placeholderIndex,
dragLayout,
)
if (nextIndex === drag.placeholderIndex) return
applyPlaceholderIndex(nextIndex)
}
function startDrag(id: string) {
const order = tabIds()
const index = order.indexOf(id)
const pending = gesture.pending
if (index === -1 || !pending || !listRef || !scrollRef) return
dragLayout = captureTabDragLayout(listRef, order)
dragPreview = createTabDragPreview(pending.element)
dragPointerId = pending.pointerId
setGesture("pending", undefined)
setDrag({
active: true,
draggedId: id,
placeholderIndex: index,
draftOrder: order,
initialOrder: order,
draggedWidth: pending.width,
pointerX: pending.startX,
grabOffsetX: pending.grabOffsetX,
floaterTop: pending.startY - pending.grabOffsetY,
})
setPressedId(undefined)
setStripScrollLeft(scrollRef.scrollLeft)
startAutoscroll()
}
function endDrag(commit: boolean) {
const initial = drag.initialOrder
const final = drag.draftOrder
const moved = drag.active
if (commit && moved && draftOrderChanged(initial, final)) {
props.onReorder(final)
}
if (moved) setSuppressNavigation(true)
setDrag({
active: false,
draggedId: undefined,
placeholderIndex: 0,
draftOrder: [],
initialOrder: [],
draggedWidth: 0,
pointerX: 0,
grabOffsetX: 0,
floaterTop: 0,
})
dragLayout = undefined
dragPreview = undefined
dragPointerId = undefined
setGesture("pending", undefined)
setPressedId(undefined)
stopAutoscroll()
refreshOverflow()
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return
const target = event.currentTarget as HTMLDivElement
const tabEl = target.matches("[data-titlebar-tab]")
? target
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id)
if (!tab) return
const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY)
setSuppressNavigation(true)
props.onNavigate(tab, tabEl)
setPressedId(id)
setGesture("pending", {
id,
pointerId: event.pointerId,
...pointer,
})
}
function onPointerMove(event: PointerEvent) {
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return
if (!isPrimaryPointerPressed(event.buttons)) {
if (drag.active) endDrag(true)
if (pending) {
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
return
}
if (pending && !drag.active) {
if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return
startDrag(pending.id)
}
if (!drag.active) return
setDrag("pointerX", event.clientX)
syncScroll()
}
function onPointerUp(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
setDrag("pointerX", event.clientX)
syncScroll()
endDrag(true)
return
}
const pending = gesture.pending
if (pending && event.pointerId !== pending.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
function onPointerCancel(event: PointerEvent) {
if (drag.active) {
if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return
endDrag(false)
return
}
if (!gesture.pending) return
if (gesture.pending.pointerId !== event.pointerId) return
setGesture("pending", undefined)
setPressedId(undefined)
requestAnimationFrame(() => setSuppressNavigation(false))
}
onMount(() => { onMount(() => {
const cleanups = [
makeEventListener(window, "pointermove", onPointerMove),
makeEventListener(window, "pointerup", onPointerUp),
makeEventListener(window, "pointercancel", onPointerCancel),
]
refreshOverflow() refreshOverflow()
onCleanup(() => cleanups.forEach((cleanup) => cleanup()))
}) })
onCleanup(stopAutoscroll)
onCleanup(() => { onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
}) })
@@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: {
refreshOverflow() refreshOverflow()
}) })
createEffect(() => {
if (!drag.active || !scrollRef) return
onCleanup(makeEventListener(scrollRef, "scroll", syncScroll))
})
const floaterStyle = () => {
stripScrollLeft()
const strip = scrollRef?.getBoundingClientRect()
const left = strip
? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right)
: drag.pointerX - drag.grabOffsetX
return {
position: "fixed" as const,
top: `${drag.floaterTop}px`,
left: `${left}px`,
width: `${drag.draggedWidth}px`,
"z-index": "10000",
"pointer-events": "none" as const,
}
}
const draggedTab = createMemo(() => {
const id = drag.draggedId
if (!id) return
return props.tabs.find((tab) => tabKey(tab) === id)
})
return ( return (
<> <div data-slot="titlebar-tabs" class="relative min-w-0">
<div data-slot="titlebar-tabs" class="relative min-w-0"> <div
<div data-slot="titlebar-tabs-scroll"
data-slot="titlebar-tabs-scroll" class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]" ref={scrollRef}
ref={scrollRef} >
<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}> <div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}> <For each={props.tabs}>
{(tab, index) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
let ref!: HTMLDivElement let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref)) useTabShortcut(index, () => props.onNavigate(tab, ref))
const dragged = () => drag.active && drag.draggedId === id
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
if (tab.type !== "session") return if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
@@ -479,68 +264,50 @@ export function TitlebarTabStrip(props: {
<SessionTabSlot <SessionTabSlot
tab={tab} tab={tab}
id={id} id={id}
index={index}
active={() => props.currentTab() === tab} active={() => props.currentTab() === tab}
activeServerKey={props.activeServerKey} activeServerKey={props.activeServerKey}
forceTruncate={props.forceTruncate} forceTruncate={props.forceTruncate}
dragActive={drag.active}
dragged={dragged}
pressed={() => pressedId() === id}
serverCtx={serverCtx} serverCtx={serverCtx}
suppressNavigation={() => suppressNavigation()} onNavigate={(element) => {
onPointerDown={(event) => { ref = element
if (dragged()) return props.onNavigate(tab, element)
onPointerDown(id, event)
}} }}
onNavigate={(element) => props.onNavigate(tab, element)}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
/> />
) )
} }
return ( return (
<DraftTabItem <DraftTabSlot
tabKey={id} tab={tab}
dragActive={drag.active} id={id}
onPointerDown={(event) => { index={index}
if (dragged()) return active={() => props.currentTab() === tab}
onPointerDown(id, event)
}}
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")} title={language.t("command.session.new")}
onNavigate={() => props.onNavigate(tab, ref)} onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)} onClose={() => props.onClose(tab)}
suppressNavigation={() => suppressNavigation()}
active={props.currentTab() === tab}
pressed={pressedId() === id}
hidden={dragged()}
/> />
) )
}} }}
</For> </For>
</div> </div>
</div> </DragDropProvider>
<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> </div>
<Show when={drag.active && draggedTab() && dragPreview}> <div
{(_) => ( data-slot="titlebar-tabs-fade-left"
<Portal> aria-hidden="true"
<div data-titlebar-tab-preview style={floaterStyle()}> 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)]"
{dragPreview} />
</div> <div
</Portal> data-slot="titlebar-tabs-fade-right"
)} aria-hidden="true"
</Show> 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>
) )
} }
+10 -2
View File
@@ -908,7 +908,13 @@ export default function Page() {
) )
const reviewPanel = () => ( const reviewPanel = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict"> <div
classList={{
"flex flex-col h-full overflow-hidden contain-strict": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
{reviewContent({ {reviewContent({
diffStyle: layout.review.diffStyle(), diffStyle: layout.review.diffStyle(),
@@ -1713,7 +1719,9 @@ export default function Page() {
> >
<div <div
classList={{ classList={{
"flex-1 min-h-0 flex flex-col bg-background-stronger": true, "flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(), "rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}} }}
@@ -1,5 +1,6 @@
import { Show, type JSX } from "solid-js" import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
@@ -13,6 +14,7 @@ export function SessionComposerRegion(props: {
}) { }) {
const language = useLanguage() const language = useLanguage()
const controller = props.controller const controller = props.controller
const settings = useSettings()
const rolled = () => { const rolled = () => {
const revert = controller.revert() const revert = controller.revert()
return revert?.items.length ? revert : undefined return revert?.items.length ? revert : undefined
@@ -22,7 +24,11 @@ export function SessionComposerRegion(props: {
<div <div
ref={controller.setDockRef} ref={controller.setDockRef}
data-component="session-prompt-dock" data-component="session-prompt-dock"
class="w-full shrink-0 flex flex-col justify-center items-center pb-3 bg-background-stronger pointer-events-none" classList={{
"w-full shrink-0 flex flex-col justify-center items-center pb-3 pointer-events-none": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
> >
<div <div
classList={{ classList={{
@@ -1019,7 +1019,13 @@ export function MessageTimeline(props: {
<div class="flex w-max min-w-full justify-end gap-2"> <div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}> <Index each={comments()}>
{(comment) => ( {(comment) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2"> <div
classList={{
"shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
"border-[0.5px]": settings.general.newLayoutDesigns(),
border: !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong"> <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" /> <FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment().path)}</span> <span class="truncate">{getFilename(comment().path)}</span>
@@ -1288,7 +1294,11 @@ export function MessageTimeline(props: {
<div <div
data-session-title data-session-title
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30": true,
"bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
settings.general.newLayoutDesigns(),
"bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
!settings.general.newLayoutDesigns(),
"w-full": true, "w-full": true,
"pb-4": true, "pb-4": true,
"pr-3": true, "pr-3": true,
@@ -1509,7 +1519,11 @@ export function MessageTimeline(props: {
<Button <Button
size="large" size="large"
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class={
settings.general.newLayoutDesigns()
? "w-full shadow-none border-[0.5px] border-border-weak-base"
: "w-full shadow-none border border-border-weak-base"
}
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={unshareMutation.isPending}
> >
+276
View File
@@ -0,0 +1,276 @@
// Branded HTML pages for local OAuth callback servers.
//
// These are served by the loopback HTTP servers that finish an OAuth exchange
// (MCP, Codex/ChatGPT, xAI, Snowflake, DigitalOcean, ...). The functions return
// a fully self-contained HTML string with no external assets, so they work
// offline and drop into any transport (`res.end(...)`, Effect `response.end`,
// etc.).
//
// The visual language mirrors the opencode app: the design tokens are a curated
// subset of the OC-2 semantic tokens in `packages/ui/src/styles/theme.css`, and
// the wordmark is the same geometry as `packages/ui/src/components/logo.tsx`.
// Keep this file in sync with those sources when the brand changes.
export interface CallbackPageOptions {
/** Friendly integration name shown as a subtitle, e.g. "xAI", "Snowflake", "MCP". */
provider?: string
/** Attempt to close the window shortly after success. Defaults to true. */
autoClose?: boolean
}
export function success(options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization successful",
body: renderCard({
status: "success",
headline: "Authorization successful",
message: provider ? `opencode is now connected to ${escapeHtml(provider)}.` : "opencode is now authorized.",
footnote: "You can close this window and return to opencode.",
}),
script: options?.autoClose === false ? undefined : AUTO_CLOSE_SCRIPT,
})
}
export function error(detail: string, options?: CallbackPageOptions) {
const provider = options?.provider
return renderDocument({
title: "Authorization failed",
body: renderCard({
status: "error",
headline: "Authorization failed",
message: provider
? `opencode couldn't finish connecting to ${escapeHtml(provider)}.`
: "opencode couldn't complete authorization.",
detail,
footnote: "Close this window and try again from opencode.",
}),
})
}
export interface BootstrapOptions {
/** Same-origin path the in-browser script POSTs the parsed callback to. */
tokenPath: string
provider?: string
}
// For flows where the credential arrives in the URL fragment (implicit grant),
// the browser must relay it back to the loopback server. This renders a pending
// page whose script reads the fragment, POSTs it to `tokenPath`, then resolves
// to the success or error state in place.
export function bootstrap(options: BootstrapOptions) {
return renderDocument({
title: "Finishing sign-in",
body: renderCard({
status: "pending",
headline: "Finishing sign-in",
message: options.provider
? `Completing your ${escapeHtml(options.provider)} authorization.`
: "Completing authorization.",
footnote: "You can close this window once sign-in finishes.",
}),
script: bootstrapScript(options),
})
}
export * as OauthCallbackPage from "./page"
type Status = "pending" | "success" | "error"
function renderCard(input: { status: Status; headline: string; message: string; detail?: string; footnote: string }) {
const detail = input.detail?.trim()
return `<main class="card" id="oc-card" data-status="${input.status}" role="status" aria-live="polite">
<div class="brand">${WORDMARK}</div>
<div class="status" aria-hidden="true">
<span class="icon icon-pending">${ICON_SPINNER}</span>
<span class="icon icon-success">${ICON_CHECK}</span>
<span class="icon icon-error">${ICON_CROSS}</span>
</div>
<h1 class="headline" id="oc-headline">${escapeHtml(input.headline)}</h1>
<p class="message" id="oc-message">${input.message}</p>
<pre class="detail" id="oc-detail"${detail ? "" : " hidden"}>${detail ? escapeHtml(detail) : ""}</pre>
<p class="footnote" id="oc-footnote">${escapeHtml(input.footnote)}</p>
</main>`
}
function renderDocument(input: { title: string; body: string; script?: string }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${escapeHtml(input.title)} · opencode</title>
<style>${STYLES}</style>
</head>
<body>
${input.body}${input.script ? `\n <script>${input.script}</script>` : ""}
</body>
</html>`
}
const AUTO_CLOSE_SCRIPT = `setTimeout(function(){try{window.close()}catch(e){}},2500)`
function bootstrapScript(options: BootstrapOptions) {
return `var PROVIDER=${scriptString(options.provider ?? "")};
var TOKEN_URL=new URL(${scriptString(options.tokenPath)},window.location.origin).href;
(function(){
var card=document.getElementById("oc-card"),headline=document.getElementById("oc-headline"),message=document.getElementById("oc-message"),detail=document.getElementById("oc-detail"),footnote=document.getElementById("oc-footnote");
function fail(text){card.dataset.status="error";headline.textContent="Authorization failed";message.textContent=PROVIDER?("opencode couldn't finish connecting to "+PROVIDER+"."):"opencode couldn't complete authorization.";if(text){detail.textContent=text;detail.hidden=false}footnote.textContent="Close this window and try again from opencode."}
function ok(){card.dataset.status="success";headline.textContent="Authorization successful";message.textContent=PROVIDER?("opencode is now connected to "+PROVIDER+"."):"opencode is now authorized.";detail.hidden=true;footnote.textContent="You can close this window and return to opencode.";setTimeout(function(){try{window.close()}catch(e){}},2500)}
try{
var hash=new URLSearchParams((window.location.hash||"").slice(1));
var search=new URLSearchParams(window.location.search||"");
var err=hash.get("error")||search.get("error");
var errDescription=hash.get("error_description")||search.get("error_description");
var body=err?{error:err,error_description:errDescription||""}:{access_token:hash.get("access_token")||"",expires_in:hash.get("expires_in")||"0",state:hash.get("state")||""};
fetch(TOKEN_URL,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(res){
if(!res.ok)return res.text().catch(function(){return""}).then(function(t){throw new Error(t||("callback failed ("+res.status+")"))});
if(err){fail(errDescription||err);return}
ok();
}).catch(function(e){fail(String(e&&e.message?e.message:e))});
}catch(e){fail(String(e&&e.message?e.message:e))}
})()`
}
function scriptString(value: string) {
return JSON.stringify(value).replaceAll("<", "\\u003c")
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
// Curated subset of OC-2 tokens (packages/ui/src/styles/theme.css). Default is
// light; dark applies via prefers-color-scheme. The [data-theme] selectors let a
// host force a scheme without changing the default.
const LIGHT_VARS = `
--oc-bg: #f8f8f8;
--oc-card: #fcfcfc;
--oc-text-strong: #171717;
--oc-text-base: #6f6f6f;
--oc-text-weak: #8f8f8f;
--oc-border-weak: #e5e5e5;
--oc-icon-strong: #171717;
--oc-icon-base: #8f8f8f;
--oc-icon-weak: #dbdbdb;
--oc-success: #2dba26;
--oc-error: #ed4831;
--oc-detail-bg: #fff8f6;
--oc-detail-border: #fdc3b7;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.10), 0 6px 12px -2px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.06);`
const DARK_VARS = `
--oc-bg: #101010;
--oc-card: #161616;
--oc-text-strong: rgba(255,255,255,.936);
--oc-text-base: rgba(255,255,255,.618);
--oc-text-weak: rgba(255,255,255,.422);
--oc-border-weak: #282828;
--oc-icon-strong: #ededed;
--oc-icon-base: #7e7e7e;
--oc-icon-weak: #343434;
--oc-success: #12c905;
--oc-error: #fc533a;
--oc-detail-bg: #28110c;
--oc-detail-border: #6a1206;
--oc-shadow: 0 16px 48px -6px rgba(0,0,0,.55), 0 6px 12px -2px rgba(0,0,0,.35), 0 1px 2px rgba(0,0,0,.4);`
const STYLES = `
:root { color-scheme: light dark;${LIGHT_VARS}
--oc-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--oc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {${DARK_VARS} } }
:root[data-theme="dark"] {${DARK_VARS} }
:root[data-theme="light"] {${LIGHT_VARS} }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: var(--oc-bg);
color: var(--oc-text-base);
font-family: var(--oc-font-sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.card {
width: min(100%, 25rem);
padding: 2.25rem 2rem 1.75rem;
background: var(--oc-card);
border: 1px solid var(--oc-border-weak);
border-radius: 14px;
box-shadow: var(--oc-shadow);
text-align: center;
}
.brand { display: flex; justify-content: center; margin-bottom: 1.75rem; }
.brand svg { height: 19px; width: auto; }
.status { display: flex; justify-content: center; margin-bottom: 1.125rem; }
.icon { display: none; line-height: 0; }
.icon svg { display: block; }
.card[data-status="pending"] .icon-pending,
.card[data-status="success"] .icon-success,
.card[data-status="error"] .icon-error { display: block; }
.icon-success { color: var(--oc-success); }
.icon-error { color: var(--oc-error); }
.icon-pending { color: var(--oc-text-weak); }
.headline { margin: 0; font-size: 1.1875rem; font-weight: 500; line-height: 1.3; letter-spacing: -0.012em; color: var(--oc-text-strong); }
.message { margin: 0.5rem 0 0; font-size: 0.9375rem; color: var(--oc-text-base); }
.detail {
margin: 1.25rem 0 0;
padding: 0.75rem 0.875rem;
text-align: left;
font-family: var(--oc-font-mono);
font-size: 0.8125rem;
line-height: 1.55;
color: var(--oc-text-strong);
background: var(--oc-detail-bg);
border: 1px solid var(--oc-detail-border);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 9.5rem;
overflow: auto;
}
.detail[hidden] { display: none; }
.footnote { margin: 1.5rem 0 0; font-size: 0.8125rem; color: var(--oc-text-weak); }
.spinner { animation: oc-spin 0.8s linear infinite; transform-origin: center; }
@keyframes oc-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
`
// opencode wordmark — same path geometry as packages/ui/src/components/logo.tsx (Logo).
const WORDMARK = `<svg class="wordmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 42" fill="none" aria-label="opencode" role="img">
<path d="M18 30H6V18H18V30Z" fill="var(--oc-icon-weak)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--oc-icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--oc-icon-weak)" />
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--oc-icon-base)" />
<path d="M84 24V30H66V24H84Z" fill="var(--oc-icon-weak)" />
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--oc-icon-base)" />
<path d="M108 36H96V18H108V36Z" fill="var(--oc-icon-weak)" />
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--oc-icon-base)" />
<path d="M144 30H126V18H144V30Z" fill="var(--oc-icon-weak)" />
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--oc-icon-strong)" />
<path d="M168 30H156V18H168V30Z" fill="var(--oc-icon-weak)" />
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--oc-icon-strong)" />
<path d="M198 30H186V18H198V30Z" fill="var(--oc-icon-weak)" />
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--oc-icon-strong)" />
<path d="M234 24V30H216V24H234Z" fill="var(--oc-icon-weak)" />
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--oc-icon-strong)" />
</svg>`
const ICON_CHECK = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m8.5 12.5 2.4 2.4 4.6-5.4" /></svg>`
const ICON_CROSS = `<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="m9 9 6 6m0-6-6 6" /></svg>`
const ICON_SPINNER = `<svg class="spinner" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9" opacity="0.2" /><path d="M21 12a9 9 0 0 0-9-9" /></svg>`
+8 -8
View File
@@ -7,6 +7,7 @@ import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration" import { Integration } from "../../integration"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider" import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal" import type { PluginInternal } from "../internal"
@@ -58,17 +59,21 @@ const browser = {
const value = url.searchParams.get("code") const value = url.searchParams.get("code")
if (error) { if (error) {
Effect.runFork(Deferred.fail(code, new Error(error))) Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
return return
} }
if (!value || url.searchParams.get("state") !== state) { if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code" const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message))) Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
return return
} }
Effect.runFork(Deferred.succeed(code, value)) Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" }))
}) })
yield* Effect.callback<void, Error>((resume) => { yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error))) server.once("error", (error) => resume(Effect.fail(error)))
@@ -285,8 +290,3 @@ function claim(token: string) {
return return
} }
} }
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { OauthCallbackPage } from "../src/oauth/page"
describe("OauthCallbackPage", () => {
test("escapes bootstrap options embedded in the inline script", () => {
const html = OauthCallbackPage.bootstrap({
provider: `xAI</script><script>alert("provider")</script>`,
tokenPath: `/token</script><script>alert("path")</script>`,
})
expect(html.match(/<\/script>/g)).toHaveLength(1)
expect(html).toContain(`xAI\\u003c/script>\\u003cscript>alert(\\\"provider\\\")\\u003c/script>`)
expect(html).toContain(`/token\\u003c/script>\\u003cscript>alert(\\\"path\\\")\\u003c/script>`)
})
})
+6 -47
View File
@@ -1,6 +1,6 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http" import { createServer } from "http"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const OAUTH_CALLBACK_HOST = "127.0.0.1" const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -9,47 +9,6 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
let currentPort = OAUTH_CALLBACK_PORT let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
interface PendingAuth { interface PendingAuth {
resolve: (code: string) => void resolve: (code: string) => void
reject: (error: Error) => void reject: (error: Error) => void
@@ -98,7 +57,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!state) { if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack" const errorMsg = "Missing required state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return return
} }
@@ -112,14 +71,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.reject(new Error(errorMsg)) pending.reject(new Error(errorMsg))
} }
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
stopIfIdle() stopIfIdle()
return return
} }
if (!code) { if (!code) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR("No authorization code provided")) res.end(OauthCallbackPage.error("No authorization code provided", { provider: "MCP" }))
return return
} }
@@ -127,7 +86,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
if (!pendingAuths.has(state)) { if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack" const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" }))
return return
} }
@@ -139,7 +98,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
pending.resolve(code) pending.resolve(code)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "MCP" }))
stopIfIdle() stopIfIdle()
} }
+2 -60
View File
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http" import { createServer } from "http"
import open from "open" import open from "open"
@@ -58,65 +59,6 @@ function buildAuthorizeUrl(state: string): string {
return `${DO_AUTHORIZE_URL}?${params.toString()}` return `${DO_AUTHORIZE_URL}?${params.toString()}`
} }
const HTML_CALLBACK = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>OpenCode - DigitalOcean Authorization</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
.container { text-align: center; padding: 2rem; max-width: 32rem; }
h1 { color: #e8eef9; margin-bottom: 1rem; }
p { color: #9aa9c0; }
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Finishing sign-in...</h1>
<p id="msg">You can close this window once it says you're signed in.</p>
</div>
<script>
(async function() {
const params = new URLSearchParams((window.location.hash || "").slice(1))
const search = new URLSearchParams(window.location.search)
const error = params.get("error") || search.get("error")
const errorDescription = params.get("error_description") || search.get("error_description")
const titleEl = document.getElementById("title")
const msgEl = document.getElementById("msg")
const tokenUrl = new URL(${JSON.stringify(OAUTH_TOKEN_PATH)}, window.location.origin).href
try {
const body = error
? { error, error_description: errorDescription || "" }
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const detail = await res.text().catch(function () { return "" })
throw new Error(detail || ("callback failed (" + res.status + ")"))
}
if (error) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = errorDescription || error
msgEl.className = "error"
return
}
titleEl.textContent = "Authorization Successful"
msgEl.textContent = "You can close this window and return to OpenCode."
setTimeout(function () { window.close() }, 2000)
} catch (e) {
titleEl.textContent = "Authorization Failed"
msgEl.textContent = String(e && e.message ? e.message : e)
msgEl.className = "error"
}
})()
</script>
</body>
</html>`
async function startOAuthServer(): Promise<void> { async function startOAuthServer(): Promise<void> {
if (oauthServer) return if (oauthServer) return
oauthServer = createServer((req, res) => { oauthServer = createServer((req, res) => {
@@ -124,7 +66,7 @@ async function startOAuthServer(): Promise<void> {
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) { if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_CALLBACK) res.end(OauthCallbackPage.bootstrap({ tokenPath: OAUTH_TOKEN_PATH, provider: "DigitalOcean" }))
return return
} }
+4 -91
View File
@@ -5,7 +5,7 @@ import os from "os"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http" import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool" import { OpenAIWebSocketPool } from "./ws-pool"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com" const ISSUER = "https://auth.openai.com"
@@ -138,95 +138,8 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
return response.json() return response.json()
} }
const HTML_SUCCESS = `<!doctype html> // Kept as a named export for plugin.codex tests; delegates to the shared branded page.
<html> export const renderOAuthError = (error: string) => OauthCallbackPage.error(error, { provider: "ChatGPT" })
<head>
<title>OpenCode - Codex Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
export const renderOAuthError = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - Codex Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
interface PendingOAuth { interface PendingOAuth {
pkce: PkceCodes pkce: PkceCodes
@@ -287,7 +200,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err)) .catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
return return
} }
@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth" import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http" import { createServer } from "http"
import open from "open" import open from "open"
@@ -156,29 +157,6 @@ async function refreshAccessToken(account: string, refreshToken: string) {
return token return token
} }
const HTML_SUCCESS = `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Successful</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:36rem; padding:2rem;">
<h1 style="color:#7ee787;">Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 1500)</script>
</body>
</html>`
const htmlError = (message: string) => `<!doctype html>
<html>
<head><title>OpenCode - Snowflake Authorization Failed</title></head>
<body style="font-family: system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; background:#111; color:#eee;">
<div style="text-align:center; max-width:48rem; padding:2rem;">
<h1 style="color:#ff7b72;">Authorization Failed</h1>
<pre style="white-space:pre-wrap; color:#ffb3ad; background:#2a1210; padding:1rem; border-radius:.5rem;">${message}</pre>
</div>
</body>
</html>`
async function startOAuthServer() { async function startOAuthServer() {
if (oauthServer) return if (oauthServer) return
@@ -203,7 +181,7 @@ async function startOAuthServer() {
pendingOAuth?.reject(new Error(message)) pendingOAuth?.reject(new Error(message))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -214,7 +192,7 @@ async function startOAuthServer() {
const message = errorDescription || error const message = errorDescription || error
current.reject(new Error(message)) current.reject(new Error(message))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -222,7 +200,7 @@ async function startOAuthServer() {
const message = "Missing authorization code" const message = "Missing authorization code"
current.reject(new Error(message)) current.reject(new Error(message))
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(htmlError(message)) res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
return return
} }
@@ -231,7 +209,7 @@ async function startOAuthServer() {
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err)))) .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
}) })
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
+5 -95
View File
@@ -2,7 +2,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { OAUTH_DUMMY_KEY } from "../auth" import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http" import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { escapeHtml } from "@/util/html" import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
@@ -285,96 +285,6 @@ export async function pollDeviceCodeToken(
throw new Error("xAI device authorization timed out") throw new Error("xAI device authorization timed out")
} }
const HTML_SUCCESS = `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Successful</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #f1ecec;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>
setTimeout(() => window.close(), 2000)
</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!doctype html>
<html>
<head>
<title>OpenCode - xAI Authorization Failed</title>
<style>
body {
font-family:
system-ui,
-apple-system,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: #131010;
color: #f1ecec;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
color: #fc533a;
margin-bottom: 1rem;
}
p {
color: #b7b1b1;
}
.error {
color: #ff917b;
font-family: monospace;
margin-top: 1rem;
padding: 1rem;
background: #3c140d;
border-radius: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
// CORS allowlist for the loopback callback. The redirect_uri itself is // CORS allowlist for the loopback callback. The redirect_uri itself is
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
// xAI's own auth origins for additional defense-in-depth on the OPTIONS // xAI's own auth origins for additional defense-in-depth on the OPTIONS
@@ -425,7 +335,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -434,7 +344,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -443,7 +353,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth?.reject(new Error(errorMsg))
pendingOAuth = undefined pendingOAuth = undefined
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
return return
} }
@@ -455,7 +365,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
.catch((err) => current.reject(err)) .catch((err) => current.reject(err))
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(HTML_SUCCESS) res.end(OauthCallbackPage.success({ provider: "xAI" }))
return return
} }
@@ -101,7 +101,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
`${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`,
) )
expect(await response.text()).toContain('<div class="error">The user denied access</div>') expect(await response.text()).toContain('<pre class="detail" id="oc-detail">The user denied access</pre>')
}) })
test("binds the callback server to IPv4 loopback", async () => { test("binds the callback server to IPv4 loopback", async () => {
@@ -43,7 +43,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-weak); color: var(--v2-text-text-faint);
[data-component="spinner"] { [data-component="spinner"] {
width: 16px; width: 16px;
@@ -89,14 +89,14 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
&.capitalize { &.capitalize {
text-transform: capitalize; text-transform: capitalize;
} }
&.agent-title { &.agent-title {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
} }
@@ -114,7 +114,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
&.clickable { &.clickable {
cursor: pointer; cursor: pointer;
@@ -122,7 +122,7 @@
transition: color 0.15s ease; transition: color 0.15s ease;
&:hover { &:hover {
color: var(--text-base); color: var(--v2-text-text-muted);
} }
} }
@@ -160,7 +160,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="basic-tool-tool-action"] { [data-slot="basic-tool-tool-action"] {
@@ -178,7 +178,7 @@
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 6px;
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); border: 0.5px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-base) 92%, transparent); background: color-mix(in srgb, var(--background-base) 92%, transparent);
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
@@ -213,7 +213,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--icon-weak); color: var(--v2-text-text-faint);
margin-left: auto; margin-left: auto;
opacity: 0; opacity: 0;
transition: transition:
@@ -233,7 +233,7 @@
} }
[data-slot="basic-tool-tool-subtitle"] { [data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
&:hover, &:hover,
@@ -246,3 +246,34 @@
} }
} }
} }
body:not([data-new-layout]) {
[data-component="tool-trigger"] {
[data-slot="basic-tool-tool-spinner"] {
color: var(--text-weak);
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-title"].agent-title {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-subtitle"].clickable:hover,
[data-slot="basic-tool-tool-arg"] {
color: var(--text-base);
}
}
[data-component="task-tool-card"] {
border-width: 1px;
[data-component="task-tool-action"] {
color: var(--icon-weak);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
}
}
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { inlineCodeKind } from "./markdown-inline-code-kind"
describe("inlineCodeKind", () => {
test("leaves code expressions as normal inline code", () => {
expect(
inlineCodeKind(
`case "question.asked": ... input.setStore("question", question.sessionID, [question]) / splice/insert`,
),
).toBeUndefined()
expect(inlineCodeKind(`<SessionQuestionDock request={request} ... />`)).toBeUndefined()
expect(inlineCodeKind(`from sync.data.question + sync.data.session.`)).toBeUndefined()
expect(inlineCodeKind(`@opencode-ai/app <StatusPopover />)`)).toBeUndefined()
expect(inlineCodeKind(`sync.data.session`)).toBeUndefined()
expect(inlineCodeKind(`window.api`)).toBeUndefined()
})
test("detects file and directory paths", () => {
expect(inlineCodeKind(`app.tsx`)).toBe("path")
expect(inlineCodeKind(`packages/desktop-electron`)).toBe("path")
expect(inlineCodeKind(`~/.config/opencode`)).toBe("path")
expect(inlineCodeKind(`@opencode-ai/app`)).toBe("path")
expect(inlineCodeKind(`session/status`)).toBe("path")
})
test("detects urls", () => {
expect(inlineCodeKind(`https://opencode.ai/docs`)).toBe("url")
expect(inlineCodeKind(`http://localhost:4444`)).toBe("url")
expect(inlineCodeKind(`file:///tmp/opencode`)).toBeUndefined()
expect(inlineCodeKind(`ftp://opencode.ai/docs`)).toBeUndefined()
})
})
@@ -0,0 +1,15 @@
export function inlineCodeKind(text: string): "path" | "url" | undefined {
if (/^https?:\/\//i.test(text)) return "url"
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return
if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return
if (/\s/.test(text)) return
if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return
if (
/[/\\]/.test(text) ||
/^\.\.?[/\\]/.test(text) ||
/\.(tsx?|jsx?|json|py|go|rs|rb|php|css|html|md|sh|ya?ml|toml|sql|c|cpp|h|java|kt|swift|scala|xml|png|jpe?g|gif|svg|ico)$/i.test(
text,
)
)
return "path"
}
+137 -24
View File
@@ -1,9 +1,17 @@
[data-component="markdown"] { [data-component="markdown"] {
--markdown-inline-code-path-color: var(--v2-text-text-code-accent);
--markdown-inline-code-color: var(--v2-text-text-code-accent);
--markdown-inline-code-bg-mix: 8%;
--markdown-shell-code-background: var(--v2-background-bg-layer-02);
--markdown-shell-code-border: var(--v2-border-border-muted);
--markdown-shell-code-color: var(--v2-text-text-base);
--markdown-shell-code-token-color: currentColor;
/* Reset & Base Typography */ /* Reset & Base Typography */
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
overflow-wrap: break-word; overflow-wrap: break-word;
color: var(--text-strong); color: var(--v2-text-text-base);
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */ font-size: var(--font-size-base); /* 14px */
line-height: 160%; line-height: 160%;
@@ -22,15 +30,31 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Headings: Same size, distinguished by color and spacing */ /* Headings: Sized by level, distinguished by color and spacing */
h1, h1 {
h2, font-size: 17px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h2 {
font-size: 15px;
color: var(--v2-text-text-base);
font-weight: 600;
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
font-size: 14px; font-size: 13px;
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-top: 0px; margin-top: 0px;
margin-bottom: 24px; margin-bottom: 24px;
@@ -40,7 +64,7 @@
/* Emphasis & Strong: Neutral strong color */ /* Emphasis & Strong: Neutral strong color */
strong, strong,
b { b {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
@@ -95,7 +119,7 @@
} }
li::marker { li::marker {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
/* Nested lists spacing */ /* Nested lists spacing */
@@ -112,10 +136,10 @@
/* Blockquotes */ /* Blockquotes */
blockquote { blockquote {
border-left: 2px solid var(--border-weak-base); border-left: 0.5px solid var(--v2-border-border-base);
margin: 1.5rem 0; margin: 1.5rem 0;
padding-left: 0.5rem; padding-left: 0.5rem;
color: var(--text-weak); color: var(--v2-text-text-muted);
font-style: normal; font-style: normal;
} }
@@ -132,13 +156,27 @@
font-size: 13px; font-size: 13px;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
border: 0.5px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-component="markdown-code"] { [data-component="markdown-code"] {
position: relative; position: relative;
} }
[data-component="markdown-code"][data-code-kind="shell"] .shiki {
background: var(--markdown-shell-code-background);
border-color: var(--markdown-shell-code-border);
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code {
color: var(--markdown-shell-code-color);
}
[data-component="markdown-code"][data-code-kind="shell"] code span {
color: var(--markdown-shell-code-token-color) !important;
}
[data-slot="markdown-copy-button"] { [data-slot="markdown-copy-button"] {
position: absolute; position: absolute;
top: 4px; top: 4px;
@@ -160,7 +198,7 @@
background: var(--surface-float-base); background: var(--surface-float-base);
color: var(--text-invert-strong); color: var(--text-invert-strong);
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
pointer-events: none; pointer-events: none;
@@ -185,11 +223,11 @@
[data-slot="markdown-copy-button"][data-variant="secondary"] { [data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none; box-shadow: none;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base); color: var(--v2-icon-icon-base);
} }
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
@@ -222,15 +260,28 @@
:not(pre) > code { :not(pre) > code {
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings); font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--syntax-string); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
/* font-size: 13px; */ padding: 2px 4px;
border-radius: 4px;
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
}
/* padding: 2px 2px; */ :not(pre) > code[data-inline-code-kind="path"] {
/* margin: 0 1.5px; */ color: var(--markdown-inline-code-path-color);
/* border-radius: 2px; */ background: color-mix(
/* background: var(--surface-base); */ in oklch,
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ var(--markdown-inline-code-path-color) var(--markdown-inline-code-bg-mix),
transparent
);
}
a.external-link > code[data-inline-code-kind="url"] {
color: var(--markdown-inline-code-color);
background: none;
text-decoration: underline;
text-decoration-color: color-mix(in oklch, var(--markdown-inline-code-color) 25%, transparent);
text-underline-offset: 2px;
} }
/* Tables */ /* Tables */
@@ -246,16 +297,16 @@
th, th,
td { td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 1px solid var(--border-weaker-base); border-bottom: 0.5px solid var(--v2-border-border-muted);
padding: 12px; padding: 12px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
} }
th { th {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--border-weak-base); border-bottom: 0.5px solid var(--v2-border-border-base);
} }
/* Images */ /* Images */
@@ -268,6 +319,68 @@
} }
} }
body:not([data-new-layout]) [data-component="markdown"] {
color: var(--text-strong);
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
strong,
b {
color: var(--text-strong);
}
li::marker {
color: var(--text-weak);
}
blockquote {
border-left: 2px solid var(--border-weak-base);
color: var(--text-weak);
}
.shiki {
border-color: var(--border-weak-base);
}
:not(pre) > code {
color: var(--syntax-string);
padding: 0;
border-radius: 0;
background: none;
}
[data-slot="markdown-copy-button"]::after {
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
th,
td {
border-bottom: 1px solid var(--border-weaker-base);
}
th {
color: var(--text-strong);
border-bottom-color: var(--border-weak-base);
}
}
[data-component="markdown"] a.external-link:hover > code { [data-component="markdown"] a.external-link:hover > code {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
@@ -25,6 +25,7 @@ import {
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
type RenderedBlock = type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> }) | (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -140,6 +141,35 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
button.setAttribute("data-tooltip", labels.copy) button.setAttribute("data-tooltip", labels.copy)
} }
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
function codeKind(language: string | undefined) {
const value = language?.toLowerCase()
if (!value) return
if (shellLanguages.has(value)) return "shell"
}
function codeLanguage(block: HTMLPreElement) {
const code = block.querySelector("code")
if (!(code instanceof HTMLElement)) return
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
}
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
if (!document.body.hasAttribute("data-new-layout")) {
delete wrapper.dataset.language
delete wrapper.dataset.codeKind
return
}
if (language) wrapper.dataset.language = language
else delete wrapper.dataset.language
const kind = codeKind(language)
if (kind) wrapper.dataset.codeKind = kind
else delete wrapper.dataset.codeKind
}
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement const parent = block.parentElement
if (!parent) return if (!parent) return
@@ -147,12 +177,15 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
if (!wrapped) { if (!wrapped) {
const wrapper = document.createElement("div") const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code") wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, codeLanguage(block))
parent.replaceChild(wrapper, block) parent.replaceChild(wrapper, block)
wrapper.appendChild(block) wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels)) wrapper.appendChild(createCopyButton(labels))
return return
} }
applyCodeMetadata(parent, codeLanguage(block))
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter( const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement, (el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
) )
@@ -196,11 +229,23 @@ function markCodeLinks(root: HTMLDivElement) {
} }
} }
function markInlineCode(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
if (!(code instanceof HTMLElement)) continue
delete code.dataset.inlineCodeKind
const kind = inlineCodeKind(code.textContent ?? "")
if (kind) code.dataset.inlineCodeKind = kind
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) { function decorate(root: HTMLDivElement, labels: CopyLabels) {
const blocks = Array.from(root.querySelectorAll("pre")) const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) { for (const block of blocks) {
ensureCodeWrapper(block, labels) ensureCodeWrapper(block, labels)
} }
if (!document.body.hasAttribute("data-new-layout")) return
markInlineCode(root)
markCodeLinks(root) markCodeLinks(root)
} }
@@ -522,6 +567,8 @@ function updateCodeBlock(
const code = existing?.querySelector("code") const code = existing?.querySelector("code")
if (code instanceof HTMLElement) { if (code instanceof HTMLElement) {
const wrapper = code.closest('[data-component="markdown-code"]')
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
code.className = `language-${block.language}` code.className = `language-${block.language}`
const previous = renderedCodeTokens.get(next) const previous = renderedCodeTokens.get(next)
const reset = shouldResetCodeTokens(previous, { const reset = shouldResetCodeTokens(previous, {
@@ -552,6 +599,7 @@ function updateCodeBlock(
const wrapper = document.createElement("div") const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code") wrapper.setAttribute("data-component", "markdown-code")
applyCodeMetadata(wrapper, block.language)
const pre = document.createElement("pre") const pre = document.createElement("pre")
pre.className = "shiki OpenCode" pre.className = "shiki OpenCode"
const codeElement = document.createElement("code") const codeElement = document.createElement("code")
@@ -14,7 +14,7 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
@@ -41,15 +41,15 @@
min-width: 0; min-width: 0;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--surface-weak); background: var(--v2-background-bg-layer-02);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
cursor: default; cursor: default;
transition: transition:
border-color 0.15s ease, border-color 0.15s ease,
opacity 0.3s ease; opacity 0.3s ease;
&:hover { &:hover {
border-color: var(--border-strong-base); border-color: var(--v2-border-border-strong);
} }
&[data-clickable] { &[data-clickable] {
@@ -107,7 +107,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--text-base); color: var(--v2-text-text-muted);
font-size: var(--font-size-small); font-size: var(--font-size-small);
line-height: var(--line-height-large); line-height: var(--line-height-large);
} }
@@ -130,10 +130,10 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
overflow: hidden; overflow: hidden;
background: var(--surface-base); background: var(--v2-background-bg-layer-02);
border: 1px solid var(--border-weak-base); border: none;
padding: 8px 12px; padding: 8px 12px;
border-radius: 6px; border-radius: 10px;
[data-highlight="file"] { [data-highlight="file"] {
color: var(--syntax-property); color: var(--syntax-property);
@@ -199,7 +199,7 @@
} }
.text-text-strong { .text-text-strong {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
.font-medium { .font-medium {
@@ -267,7 +267,7 @@
[data-slot="compaction-part-line"] { [data-slot="compaction-part-line"] {
flex: 1 1 auto; flex: 1 1 auto;
height: 1px; height: 1px;
background: var(--border-weak-base); background: var(--v2-border-border-base);
} }
[data-slot="compaction-part-label"] { [data-slot="compaction-part-label"] {
@@ -279,18 +279,18 @@
[data-component="reasoning-part"] { [data-component="reasoning-part"] {
width: 100%; width: 100%;
color: var(--text-base); color: var(--v2-text-text-muted);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
[data-component="markdown"] { [data-component="markdown"] {
margin-top: 16px; margin-top: 16px;
font-style: normal; font-style: normal;
font-size: 13px; font-size: 13px;
color: var(--text-weak); color: var(--v2-text-text-muted);
strong, strong,
b { b {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
p:has(strong) { p:has(strong) {
@@ -357,7 +357,7 @@
[data-component="bash-output"] { [data-component="bash-output"] {
width: 100%; width: 100%;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
position: relative; position: relative;
@@ -380,7 +380,7 @@
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none; box-shadow: none;
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
} }
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] { [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
@@ -442,7 +442,7 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="message-part-title-spinner"] { [data-slot="message-part-title-spinner"] {
@@ -453,7 +453,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
color: var(--text-weak); color: var(--v2-text-text-muted);
[data-component="spinner"] { [data-component="spinner"] {
width: 16px; width: 16px;
@@ -464,7 +464,7 @@
[data-slot="message-part-title-text"] { [data-slot="message-part-title-text"] {
flex-shrink: 0; flex-shrink: 0;
text-transform: capitalize; text-transform: capitalize;
color: var(--text-strong); color: var(--v2-text-text-base);
} }
[data-slot="message-part-title-filename"] { [data-slot="message-part-title-filename"] {
@@ -484,7 +484,7 @@
} }
[data-slot="message-part-directory"] { [data-slot="message-part-directory"] {
color: var(--text-weak); color: var(--v2-text-text-muted);
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -493,7 +493,7 @@
} }
[data-slot="message-part-filename"] { [data-slot="message-part-filename"] {
color: var(--text-strong); color: var(--v2-text-text-base);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -508,7 +508,7 @@
[data-component="edit-content"] { [data-component="edit-content"] {
border-radius: inherit; border-radius: inherit;
border-top: 1px solid var(--border-weaker-base); border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible; overflow-y: visible;
@@ -527,7 +527,7 @@
[data-component="write-content"] { [data-component="write-content"] {
border-radius: inherit; border-radius: inherit;
border-top: 1px solid var(--border-weaker-base); border-top: 0.5px solid var(--v2-border-border-muted);
overflow-x: hidden; overflow-x: hidden;
overflow-y: visible; overflow-y: visible;
@@ -561,7 +561,7 @@
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-base);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
} }
[data-slot="basic-tool-tool-subtitle"].exa-tool-query { [data-slot="basic-tool-tool-subtitle"].exa-tool-query {
@@ -583,7 +583,7 @@
max-width: 100%; max-width: 100%;
font: inherit; font: inherit;
line-height: inherit; line-height: inherit;
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
overflow: hidden; overflow: hidden;
@@ -591,11 +591,11 @@
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
} }
&:visited { &:visited {
color: var(--text-interactive-base); color: var(--v2-text-text-accent);
} }
} }
@@ -615,7 +615,7 @@
&[data-completed="completed"] { &[data-completed="completed"] {
text-decoration: line-through; text-decoration: line-through;
color: var(--text-weaker); color: var(--v2-text-text-faint);
} }
} }
} }
@@ -660,8 +660,8 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
padding: 8px 12px; padding: 8px 12px;
background-color: var(--surface-critical-weak); background-color: var(--v2-state-bg-danger);
border-top: 1px solid var(--border-critical-base); border-top: 0.5px solid var(--v2-state-border-danger);
[data-slot="diagnostic"] { [data-slot="diagnostic"] {
display: flex; display: flex;
@@ -673,7 +673,7 @@
} }
[data-slot="diagnostic-label"] { [data-slot="diagnostic-label"] {
color: var(--text-on-critical-base); color: var(--v2-state-fg-danger);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: -0.5px; letter-spacing: -0.5px;
@@ -681,12 +681,12 @@
} }
[data-slot="diagnostic-location"] { [data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak); color: var(--v2-state-fg-danger);
flex-shrink: 0; flex-shrink: 0;
} }
[data-slot="diagnostic-message"] { [data-slot="diagnostic-message"] {
color: var(--text-on-critical-base); color: var(--v2-state-fg-danger);
word-break: break-word; word-break: break-word;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -761,7 +761,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
} }
@@ -778,7 +778,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
padding: 0; padding: 0;
} }
@@ -856,7 +856,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
} }
@@ -916,7 +916,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
padding: 0 10px; padding: 0 10px;
-webkit-user-select: text; -webkit-user-select: text;
user-select: text; user-select: text;
@@ -927,7 +927,7 @@
font-size: 13px; font-size: 13px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
padding: 0 10px; padding: 0 10px;
} }
@@ -953,8 +953,8 @@
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 8px 8px 8px 10px; padding: 8px 8px 8px 10px;
background-color: var(--surface-raised-stronger-non-alpha); background-color: var(--v2-background-bg-layer-01);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px; border-radius: 6px;
box-shadow: none; box-shadow: none;
text-align: left; text-align: left;
@@ -966,11 +966,11 @@
box-shadow 0.15s ease; box-shadow 0.15s ease;
&:hover:not([data-picked="true"]) { &:hover:not([data-picked="true"]) {
background-color: var(--background-base); background-color: var(--v2-background-bg-base);
} }
&[data-picked="true"] { &[data-picked="true"] {
background-color: var(--surface-interactive-weak); background-color: var(--v2-state-bg-info);
border-color: transparent; border-color: transparent;
box-shadow: var(--shadow-xs-border-hover); box-shadow: var(--shadow-xs-border-hover);
} }
@@ -991,7 +991,7 @@
height: 16px; height: 16px;
padding: 2px; padding: 2px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1014,7 +1014,7 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 999px; border-radius: 999px;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-layer-01);
opacity: 0; opacity: 0;
} }
@@ -1052,7 +1052,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-strong); color: var(--v2-text-text-base);
} }
[data-slot="option-description"] { [data-slot="option-description"] {
@@ -1060,7 +1060,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: normal; white-space: normal;
@@ -1088,7 +1088,7 @@
font-size: 14px; font-size: 14px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-base); color: var(--v2-text-text-muted);
min-width: 0; min-width: 0;
cursor: text; cursor: text;
resize: none; resize: none;
@@ -1096,11 +1096,11 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
&::placeholder { &::placeholder {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
&:focus-visible { &:focus-visible {
outline: 1px solid var(--border-interactive-base); outline: 0.5px solid var(--v2-border-border-focus);
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius-xs); border-radius: var(--radius-xs);
} }
@@ -1139,11 +1139,11 @@
font-size: 13px; font-size: 13px;
[data-slot="question-text"] { [data-slot="question-text"] {
color: var(--text-weak); color: var(--v2-text-text-muted);
} }
[data-slot="answer-text"] { [data-slot="answer-text"] {
color: var(--text-strong); color: var(--v2-text-text-base);
} }
} }
} }
@@ -1166,13 +1166,13 @@
z-index: 20; z-index: 20;
height: calc(32px + var(--tool-content-gap)); height: calc(32px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap); padding-bottom: var(--tool-content-gap);
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
} }
} }
[data-component="accordion"][data-scope="apply-patch"] { [data-component="accordion"][data-scope="apply-patch"] {
[data-slot="accordion-trigger"] { [data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important; background-color: var(--v2-background-bg-base) !important;
} }
[data-slot="apply-patch-trigger-content"] { [data-slot="apply-patch-trigger-content"] {
@@ -1199,7 +1199,7 @@
} }
[data-slot="apply-patch-directory"] { [data-slot="apply-patch-directory"] {
color: var(--text-base); color: var(--v2-text-text-muted);
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -1208,7 +1208,7 @@
} }
[data-slot="apply-patch-filename"] { [data-slot="apply-patch-filename"] {
color: var(--text-strong); color: var(--v2-text-text-base);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1268,10 +1268,199 @@
font-size: var(--font-size-small); font-size: var(--font-size-small);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
color: var(--text-weak); color: var(--v2-text-text-muted);
[data-component="icon"] { [data-component="icon"] {
flex-shrink: 0; flex-shrink: 0;
color: var(--icon-weak); color: var(--icon-weak);
} }
} }
body:not([data-new-layout]) {
[data-component="user-message"] {
color: var(--text-strong);
[data-slot="user-message-attachment"] {
background: var(--surface-weak);
border: 1px solid var(--border-weak-base);
&:hover {
border-color: var(--border-strong-base);
}
}
[data-slot="user-message-attachment-name"] {
color: var(--text-base);
}
[data-slot="user-message-text"] {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: 6px;
}
.text-text-strong {
color: var(--text-strong);
}
}
[data-slot="compaction-part-line"] {
background: var(--border-weak-base);
}
[data-component="reasoning-part"] {
color: var(--text-base);
[data-component="markdown"],
[data-component="markdown"] :is(strong, b) {
color: var(--text-weak);
}
}
[data-component="bash-output"] {
border: 1px solid var(--border-weak-base);
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
border: 1px solid var(--border-weak-base);
}
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
[data-slot="message-part-title"] {
color: var(--text-base);
}
[data-slot="message-part-title-spinner"],
[data-slot="message-part-directory"] {
color: var(--text-weak);
}
[data-slot="message-part-title-text"],
[data-slot="message-part-filename"] {
color: var(--text-strong);
}
}
[data-component="edit-content"],
[data-component="write-content"] {
border-top: 1px solid var(--border-weaker-base);
}
[data-component="exa-tool-output"] {
color: var(--text-base);
}
[data-slot="exa-tool-link"],
[data-slot="exa-tool-link"]:hover,
[data-slot="exa-tool-link"]:visited {
color: var(--text-interactive-base);
}
[data-slot="message-part-todo-content"][data-completed="completed"] {
color: var(--text-weaker);
}
[data-component="diagnostics"] {
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);
[data-slot="diagnostic-label"],
[data-slot="diagnostic-message"] {
color: var(--text-on-critical-base);
}
[data-slot="diagnostic-location"] {
color: var(--text-on-critical-weak);
}
}
[data-component="dock-prompt"][data-kind="permission"] {
[data-slot="permission-header-title"] {
color: var(--text-strong);
}
[data-slot="permission-hint"] {
color: var(--text-weak);
}
}
[data-component="dock-prompt"][data-kind="question"] {
[data-slot="question-header-title"],
[data-slot="question-text"],
[data-slot="option-label"] {
color: var(--text-strong);
}
[data-slot="question-hint"] {
color: var(--text-weak);
}
[data-slot="question-option"] {
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
}
&[data-picked="true"] {
background-color: var(--surface-interactive-weak);
}
}
[data-slot="question-option-box"] {
border: 1px solid var(--border-weak-base);
[data-slot="question-option-radio-dot"] {
background-color: var(--background-stronger);
}
}
[data-slot="option-description"],
[data-slot="question-custom-input"] {
color: var(--text-base);
}
[data-slot="question-custom-input"] {
&::placeholder {
color: var(--text-weak);
}
&:focus-visible {
outline: 1px solid var(--border-interactive-base);
}
}
}
[data-component="question-answers"] {
[data-slot="question-text"] {
color: var(--text-weak);
}
[data-slot="answer-text"] {
color: var(--text-strong);
}
}
:is([data-component="edit-tool"], [data-component="write-tool"], [data-component="apply-patch-tool"])
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"][aria-expanded="true"],
[data-component="accordion"][data-scope="apply-patch"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="apply-patch-directory"] {
color: var(--text-base);
}
[data-slot="apply-patch-filename"] {
color: var(--text-strong);
}
}
[data-component="tool-loaded-file"] {
color: var(--text-weak);
}
}
@@ -21,7 +21,7 @@
[data-slot="session-review-header"] { [data-slot="session-review-header"] {
z-index: 120; z-index: 120;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
height: 40px; height: 40px;
padding-bottom: 8px; padding-bottom: 8px;
flex-shrink: 0; flex-shrink: 0;
@@ -211,7 +211,7 @@
[data-slot="session-review-large-diff"] { [data-slot="session-review-large-diff"] {
padding: 12px; padding: 12px;
background: var(--background-stronger); background: var(--v2-background-bg-base);
} }
[data-slot="session-review-large-diff-title"] { [data-slot="session-review-large-diff-title"] {
@@ -235,3 +235,13 @@
margin-top: 10px; margin-top: 10px;
} }
} }
body:not([data-new-layout]) [data-component="session-review"] {
[data-slot="session-review-header"] {
background-color: var(--background-stronger);
}
[data-slot="session-review-large-diff"] {
background: var(--background-stronger);
}
}
@@ -71,7 +71,7 @@
} }
.error-card { .error-card {
color: var(--text-on-critical-base); color: var(--v2-text-text-base);
max-height: 240px; max-height: 240px;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -102,7 +102,7 @@
position: sticky; position: sticky;
top: var(--sticky-accordion-top, 0px); top: var(--sticky-accordion-top, 0px);
z-index: 20; z-index: 20;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
height: 44px; height: 44px;
} }
@@ -226,6 +226,16 @@
} }
} }
body:not([data-new-layout]) [data-component="session-turn"] {
.error-card {
color: var(--text-on-critical-base);
}
[data-slot="session-turn-diffs-header"] {
background-color: var(--background-stronger);
}
}
[data-slot="session-turn-list"] { [data-slot="session-turn-list"] {
gap: 24px; gap: 24px;
} }
@@ -2,6 +2,19 @@
--card-pad-y: 8px; --card-pad-y: 8px;
--card-line-pad: 12px; --card-line-pad: 12px;
[data-slot="basic-tool-tool-title"] {
color: var(--v2-text-text-base);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--v2-text-text-muted);
}
[data-slot="collapsible-arrow"],
[data-slot="collapsible-arrow-icon"] {
color: var(--v2-text-text-faint);
}
> [data-component="collapsible"].tool-collapsible { > [data-component="collapsible"].tool-collapsible {
gap: 0px; gap: 0px;
} }
@@ -43,10 +56,34 @@
} }
[data-slot="tool-error-card-content"] :where(*)::selection { [data-slot="tool-error-card-content"] :where(*)::selection {
background: var(--surface-critical-base); background: var(--v2-state-bg-danger);
color: var(--text-on-critical-base); color: var(--v2-text-text-base);
} }
[data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--v2-state-bg-danger);
color: var(--v2-text-text-base);
}
}
body:not([data-new-layout]) [data-component="card"][data-kind="tool-error-card"] {
[data-slot="basic-tool-tool-title"] {
color: var(--text-strong);
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow"] {
color: var(--text-base);
}
[data-slot="collapsible-arrow-icon"] {
color: var(--icon-weaker);
}
[data-slot="tool-error-card-content"] :where(*)::selection,
[data-slot="tool-error-card-content"] :where(*)::-moz-selection { [data-slot="tool-error-card-content"] :where(*)::-moz-selection {
background: var(--surface-critical-base); background: var(--surface-critical-base);
color: var(--text-on-critical-base); color: var(--text-on-critical-base);
+2
View File
@@ -15,6 +15,7 @@
"**/*.css" "**/*.css"
], ],
"files": [ "files": [
"dist/**/*.d.ts",
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"!src/**/*.test.ts", "!src/**/*.test.ts",
@@ -55,6 +56,7 @@
"./v2/styles/*": "./src/v2/styles/*" "./v2/styles/*": "./src/v2/styles/*"
}, },
"scripts": { "scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test src --only-failures", "test": "bun test src --only-failures",
"dev": "vite", "dev": "vite",
+1
View File
@@ -246,6 +246,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
export async function pack() {
const original = await Bun.file("package.json").text()
const pkg = JSON.parse(original) as {
name: string
version: string
exports: Record<string, string | { types: string; import: string }>
}
const tarball = path.resolve(`${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`)
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string" || (!value.endsWith(".ts") && !value.endsWith(".tsx"))) return [key, value]
return [
key,
{
types: value.replace("./src/", "./dist/").replace(/\.tsx?$/, ".d.ts"),
import: value,
},
]
}),
)
await rm(tarball, { force: true })
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
try {
await $`bun pm pack`
return tarball
} finally {
await Bun.write("package.json", original)
}
}
if (import.meta.main) console.log(await pack())
+2 -2
View File
@@ -4,6 +4,7 @@ import { Script } from "@opencode-ai/script"
import { $ } from "bun" import { $ } from "bun"
import { rm } from "node:fs/promises" import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url" import { fileURLToPath } from "node:url"
import { pack } from "./pack"
process.chdir(fileURLToPath(new URL("..", import.meta.url))) process.chdir(fileURLToPath(new URL("..", import.meta.url)))
@@ -18,8 +19,7 @@ if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode ==
try { try {
await $`bun run typecheck` await $`bun run typecheck`
await $`bun run test` await $`bun run test`
await rm(tarball, { force: true }) await pack()
await $`bun pm pack`
await $`npm publish ${tarball} --access public --tag ${Script.channel}` await $`npm publish ${tarball} --access public --tag ${Script.channel}`
} finally { } finally {
await rm(tarball, { force: true }) await rm(tarball, { force: true })
+31 -7
View File
@@ -35,12 +35,12 @@
cursor: default; cursor: default;
user-select: none; user-select: none;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-base);
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
overflow: clip; overflow: clip;
color: var(--text-strong); color: var(--v2-text-text-base);
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
/* text-12-regular */ /* text-12-regular */
@@ -52,10 +52,10 @@
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
&:hover:not([data-disabled]) { &:hover:not([data-disabled]) {
background-color: var(--surface-base-hover); background-color: var(--v2-overlay-simple-overlay-hover);
} }
&:active:not([data-disabled]) { &:active:not([data-disabled]) {
background-color: var(--surface-base-active); background-color: var(--v2-overlay-simple-overlay-pressed);
} }
&:focus-visible { &:focus-visible {
outline: none; outline: none;
@@ -82,9 +82,9 @@
&[data-expanded] { &[data-expanded] {
[data-slot="accordion-content"] { [data-slot="accordion-content"] {
border: 1px solid var(--border-weak-base); border: 0.5px solid var(--v2-border-border-base);
border-top: 0; border-top: 0;
background-color: var(--background-stronger); background-color: var(--v2-background-bg-layer-01);
} }
} }
@@ -104,6 +104,30 @@
} }
} }
body:not([data-new-layout]) [data-component="accordion"] {
[data-slot="accordion-item"] [data-slot="accordion-header"] [data-slot="accordion-trigger"] {
background-color: var(--background-stronger);
border-width: 1px;
border-color: var(--border-weak-base);
color: var(--text-strong);
&:hover:not([data-disabled]) {
background-color: var(--surface-base-hover);
}
&:active:not([data-disabled]) {
background-color: var(--surface-base-active);
}
}
[data-slot="accordion-item"][data-expanded] [data-slot="accordion-content"] {
border-width: 1px;
border-color: var(--border-weak-base);
border-top: 0;
background-color: var(--background-stronger);
}
}
@keyframes slideDown { @keyframes slideDown {
from { from {
height: 0; height: 0;
+26 -5
View File
@@ -19,14 +19,14 @@
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal); letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong); color: var(--v2-text-text-base);
--card-gap: 8px; --card-gap: 8px;
--card-icon: 16px; --card-icon: 16px;
--card-indent: 0px; --card-indent: 0px;
--card-line-pad: 8px; --card-line-pad: 8px;
--card-accent: var(--icon-active); --card-accent: var(--v2-icon-icon-base);
&:has([data-slot="card-title"]) { &:has([data-slot="card-title"]) {
gap: 8px; gap: 8px;
@@ -48,7 +48,7 @@
} }
:where([data-card="title"], [data-slot="card-title"]) { :where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong); color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
@@ -72,7 +72,7 @@
} }
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak); color: var(--v2-text-text-faint);
} }
:where([data-slot="card-title-icon"]) :where([data-slot="card-title-icon"])
@@ -82,7 +82,7 @@
} }
:where([data-card="description"], [data-slot="card-description"]) { :where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base); color: var(--v2-text-text-muted);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
@@ -92,3 +92,24 @@
padding-left: var(--card-indent); padding-left: var(--card-indent);
} }
} }
body:not([data-new-layout]) [data-component="card"] {
color: var(--text-strong);
--card-accent: var(--icon-active);
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
}
&[data-variant="error"] {
--card-accent: var(--icon-critical-base) !important;
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Card(props: CardProps) {
const variant = () => split.variant ?? "normal" const variant = () => split.variant ?? "normal"
const accent = () => { const accent = () => {
const v = variant() const v = variant()
if (v === "error") return "var(--icon-critical-base)" if (v === "error") return "var(--v2-state-fg-danger)"
if (v === "warning") return "var(--icon-warning-active)" if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)" if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)" if (v === "info") return "var(--icon-info-active)"
@@ -249,6 +249,7 @@
--color-v2-text-text-contrast: var(--v2-text-text-contrast); --color-v2-text-text-contrast: var(--v2-text-text-contrast);
--color-v2-text-text-accent: var(--v2-text-text-accent); --color-v2-text-text-accent: var(--v2-text-text-accent);
--color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover); --color-v2-text-text-accent-hover: var(--v2-text-text-accent-hover);
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base); --color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted); --color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse); --color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
+20 -14
View File
@@ -20,6 +20,8 @@
"text-base": "#6F6F6F", "text-base": "#6F6F6F",
"text-weak": "#8F8F8F", "text-weak": "#8F8F8F",
"text-weaker": "#C7C7C7", "text-weaker": "#C7C7C7",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#DBDBDB", "border-weak-base": "#DBDBDB",
"border-weaker-base": "#E8E8E8", "border-weaker-base": "#E8E8E8",
"icon-base": "#8F8F8F", "icon-base": "#8F8F8F",
@@ -31,14 +33,14 @@
"surface-interactive-weak": "#F5FAFF", "surface-interactive-weak": "#F5FAFF",
"icon-success-base": "#0ABE00", "icon-success-base": "#0ABE00",
"surface-success-base": "#E6FFE5", "surface-success-base": "#E6FFE5",
"syntax-comment": "#7a7a7a", "syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "#a753ae", "syntax-keyword": "var(--v2-pink-800)",
"syntax-string": "#00ceb9", "syntax-string": "var(--v2-green-800)",
"syntax-primitive": "#034cff", "syntax-primitive": "var(--v2-pink-800)",
"syntax-property": "#a753ae", "syntax-property": "var(--v2-orange-800)",
"syntax-type": "#8a6f00", "syntax-type": "var(--v2-purple-800)",
"syntax-constant": "#007b80", "syntax-constant": "#007b80",
"syntax-critical": "#ff8c00", "syntax-critical": "var(--v2-red-800)",
"syntax-diff-delete": "#ff8c00", "syntax-diff-delete": "#ff8c00",
"syntax-diff-unknown": "#a753ae", "syntax-diff-unknown": "#a753ae",
"surface-critical-base": "#FFF2F0" "surface-critical-base": "#FFF2F0"
@@ -170,6 +172,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)", "v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-600)", "v2-text-text-accent": "var(--v2-blue-600)",
"v2-text-text-accent-hover": "var(--v2-blue-700)", "v2-text-text-accent-hover": "var(--v2-blue-700)",
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)", "v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)", "v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-50)", "v2-icon-icon-inverse": "var(--v2-grey-50)",
@@ -252,6 +255,8 @@
"text-base": "#A0A0A0", "text-base": "#A0A0A0",
"text-weak": "#707070", "text-weak": "#707070",
"text-weaker": "#505050", "text-weaker": "#505050",
"text-diff-add-base": "var(--v2-state-fg-success)",
"text-diff-delete-base": "var(--v2-state-fg-danger)",
"border-weak-base": "#282828", "border-weak-base": "#282828",
"border-weaker-base": "#232323", "border-weaker-base": "#232323",
"icon-base": "#7E7E7E", "icon-base": "#7E7E7E",
@@ -262,14 +267,14 @@
"surface-base-hover": "#FFFFFF0D", "surface-base-hover": "#FFFFFF0D",
"surface-interactive-weak": "#0D172B", "surface-interactive-weak": "#0D172B",
"surface-success-base": "#022B00", "surface-success-base": "#022B00",
"syntax-comment": "#8f8f8f", "syntax-comment": "var(--v2-text-text-muted)",
"syntax-keyword": "#edb2f1", "syntax-keyword": "var(--v2-pink-400)",
"syntax-string": "#00ceb9", "syntax-string": "var(--v2-green-400)",
"syntax-primitive": "#8cb0ff", "syntax-primitive": "var(--v2-pink-400)",
"syntax-property": "#fab283", "syntax-property": "var(--v2-orange-400)",
"syntax-type": "#fcd53a", "syntax-type": "var(--v2-purple-400)",
"syntax-constant": "#93e9f6", "syntax-constant": "#93e9f6",
"syntax-critical": "#fab283", "syntax-critical": "var(--v2-red-400)",
"syntax-diff-delete": "#fab283", "syntax-diff-delete": "#fab283",
"syntax-diff-unknown": "#edb2f1", "syntax-diff-unknown": "#edb2f1",
"surface-critical-base": "#1F0603" "surface-critical-base": "#1F0603"
@@ -401,6 +406,7 @@
"v2-text-text-contrast": "var(--v2-grey-50)", "v2-text-text-contrast": "var(--v2-grey-50)",
"v2-text-text-accent": "var(--v2-blue-400)", "v2-text-text-accent": "var(--v2-blue-400)",
"v2-text-text-accent-hover": "var(--v2-blue-300)", "v2-text-text-accent-hover": "var(--v2-blue-300)",
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)", "v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)", "v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)", "v2-icon-icon-inverse": "var(--v2-grey-1100)",
+2
View File
@@ -18,6 +18,7 @@ const light: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"), "v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-600"), "v2-text-text-accent": ref("v2-blue-600"),
"v2-text-text-accent-hover": ref("v2-blue-700"), "v2-text-text-accent-hover": ref("v2-blue-700"),
"v2-text-text-code-accent": ref("v2-blue-900"),
"v2-border-border-muted": ref("v2-alpha-dark-8"), "v2-border-border-muted": ref("v2-alpha-dark-8"),
"v2-border-border-base": ref("v2-alpha-dark-10"), "v2-border-border-base": ref("v2-alpha-dark-10"),
"v2-border-border-strong": ref("v2-alpha-dark-20"), "v2-border-border-strong": ref("v2-alpha-dark-20"),
@@ -81,6 +82,7 @@ const dark: Record<string, V2ColorValue> = {
"v2-text-text-contrast": ref("v2-grey-100"), "v2-text-text-contrast": ref("v2-grey-100"),
"v2-text-text-accent": ref("v2-blue-400"), "v2-text-text-accent": ref("v2-blue-400"),
"v2-text-text-accent-hover": ref("v2-blue-300"), "v2-text-text-accent-hover": ref("v2-blue-300"),
"v2-text-text-code-accent": ref("v2-blue-400"),
"v2-border-border-muted": ref("v2-alpha-light-8"), "v2-border-border-muted": ref("v2-alpha-light-8"),
"v2-border-border-base": ref("v2-alpha-light-10"), "v2-border-border-base": ref("v2-alpha-light-10"),
"v2-border-border-strong": ref("v2-alpha-light-20"), "v2-border-border-strong": ref("v2-alpha-light-20"),
+4
View File
@@ -49,6 +49,10 @@ const icons = {
viewBox: "0 0 16 16", viewBox: "0 0 16 16",
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`, body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
}, },
check: {
viewBox: "0 0 16 16",
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
},
close: { close: {
viewBox: "0 0 20 20", viewBox: "0 0 20 20",
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`, body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
+16
View File
@@ -1,4 +1,16 @@
@layer theme { @layer theme {
html[data-theme="oc-2"] body:not([data-new-layout]) {
--text-diff-add-base: light-dark(#167517, #c4ffc0);
--text-diff-delete-base: light-dark(#fa3012, #ec2f14);
--syntax-comment: light-dark(#7a7a7a, #8f8f8f);
--syntax-keyword: light-dark(#a753ae, #edb2f1);
--syntax-string: #00ceb9;
--syntax-primitive: light-dark(#034cff, #8cb0ff);
--syntax-property: light-dark(#a753ae, #fab283);
--syntax-type: light-dark(#8a6f00, #fcd53a);
--syntax-critical: light-dark(#ff8c00, #fab283);
}
:root { :root {
color-scheme: light; color-scheme: light;
@@ -22,6 +34,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
/* ── Icon ── */ /* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
@@ -141,6 +154,7 @@
--v2-text-text-contrast: var(--v2-grey-100); --v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-400); --v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300); --v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400); --v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
@@ -243,6 +257,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-600); --v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700); --v2-text-text-accent-hover: var(--v2-blue-700);
--v2-text-text-code-accent: var(--v2-blue-900);
--v2-icon-icon-base: var(--v2-grey-800); --v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
@@ -352,6 +367,7 @@
--v2-text-text-contrast: var(--v2-grey-50); --v2-text-text-contrast: var(--v2-grey-50);
--v2-text-text-accent: var(--v2-blue-400); --v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300); --v2-text-text-accent-hover: var(--v2-blue-300);
--v2-text-text-code-accent: var(--v2-blue-400);
--v2-icon-icon-base: var(--v2-grey-400); --v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600); --v2-icon-icon-muted: var(--v2-grey-600);
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
// Publish declarations so consumer compiler options do not typecheck our TSX implementation.
"declaration": true,
"emitDeclarationOnly": true
},
"include": ["src"],
"exclude": ["**/*.stories.*", "**/*.test.*", "**/*.mdx"]
}