Compare commits

..

6 Commits

Author SHA1 Message Date
Brendan Allan 4742b5aca2 fix(session-ui): cache code highlights 2026-06-27 13:43:18 +08:00
Aarav Sareen 9c38b91e91 feat(app): updates to design system (#34066) 2026-06-26 13:22:35 +00:00
Shoubhit Dash 6e8e772582 fix(acp): surface prompt errors (#34061) 2026-06-26 12:20:38 +00:00
Shoubhit Dash 971518c6d9 fix(core): refresh cached remote skills (#34059) 2026-06-26 12:12:50 +00:00
Shoubhit Dash ae853561cd fix(opencode): preserve skill resource paths (#34052) 2026-06-26 16:48:30 +05:30
Luke Parker 753d312c28 fix(tui): preserve renderer initialization errors (#33996)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-26 15:14:28 +05:30
28 changed files with 800 additions and 218 deletions
@@ -1,6 +1,6 @@
import "@pierre/trees/web-components"
import { FileTree } from "@pierre/trees"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,6 +27,7 @@ import {
pickerRoot,
} from "./directory-picker-domain"
import "./dialog-select-directory-v2.css"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
interface DialogSelectDirectoryV2Props {
title?: string
@@ -266,8 +267,12 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
onCleanup(() => tree?.cleanUp())
return (
<Dialog title={props.title ?? language.t("command.project.open")} size="large" class="directory-picker-v2">
<div class="directory-picker-v2-body">
<Dialog size="large" class="directory-picker-v2">
<DialogHeader>
<DialogTitle>{props.title ?? language.t("command.project.open")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="directory-picker-v2-body pt-4!">
<div class="directory-picker-v2-path" ref={pathArea}>
<TextInputV2
value={input()}
@@ -349,7 +354,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
</Show>
</div>
<div class="directory-picker-v2-selection">{policy.result(root(), selected(), rootValid())}</div>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
+17 -42
View File
@@ -66,7 +66,6 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
export type PromptInputState = ReturnType<typeof usePrompt>
@@ -340,7 +339,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.capture(),
Math.floor(Math.random() * EXAMPLES.length),
)
const [submissionState, setSubmissionState] = createStore({ interrupting: false })
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
const motion = (value: number) => ({
opacity: value,
@@ -1182,22 +1180,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit: props.onSubmit,
})
const interrupt = () => {
if (submissionState.interrupting) return
if (platform.platform !== "desktop" || !props.controls.newLayoutDesigns) return abort()
setSubmissionState("interrupting", true)
return Promise.resolve()
.then(() => abort())
.finally(() => setSubmissionState("interrupting", false))
}
const handleV2Submit = (event: Event) => {
if (!stopping() || platform.platform !== "desktop") return handleSubmit(event)
event.preventDefault()
return interrupt()
}
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
@@ -1250,7 +1232,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (working()) {
void interrupt()
void abort()
event.preventDefault()
event.stopPropagation()
return
@@ -1316,7 +1298,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (working()) {
void interrupt()
void abort()
event.preventDefault()
}
return
@@ -1428,7 +1410,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleV2Submit}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
@@ -1572,27 +1554,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</div>
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<span class="relative flex size-7">
<IconButton
data-action="prompt-submit"
type="submit"
disabled={submissionState.interrupting || (!working() && blank())}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
classList={{ "[&_[data-slot=icon-svg]]:invisible": submissionState.interrupting }}
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
aria-busy={submissionState.interrupting}
/>
<Show when={submissionState.interrupting}>
<SessionProgressIndicatorV2 class="pointer-events-none absolute inset-[6px] size-4 opacity-50" />
</Show>
</span>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</DockShellForm>
@@ -1,5 +1,6 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
@@ -52,8 +53,12 @@ export const DialogServerV2: Component<{
}
return (
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<Dialog fit class="settings-v2-server-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
@@ -115,7 +120,7 @@ export const DialogServerV2: Component<{
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
@@ -633,7 +633,7 @@
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 0;
padding: 24px 24px 16px;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
+10 -3
View File
@@ -1,7 +1,8 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -30,8 +31,14 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
<Dialog size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
</Dialog>
))
}
+60 -12
View File
@@ -52,6 +52,7 @@ function isSafeRelativePath(value: string) {
class IndexSkill extends Schema.Class<IndexSkill>("SkillDiscovery.IndexSkill")({
name: Schema.String,
version: Schema.optional(Schema.String),
files: Schema.Array(Schema.String),
}) {}
@@ -80,12 +81,15 @@ export const layer = Layer.effect(
)
const download = Effect.fn("SkillDiscovery.download")(function* (url: string, destination: string) {
if (yield* fs.exists(destination).pipe(Effect.orDie)) return
yield* HttpClientRequest.get(url).pipe(
if (yield* fs.exists(destination).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
Effect.as(true),
Effect.catch((error) =>
Effect.logError("failed to download skill file", { url, error }).pipe(Effect.as(false)),
),
)
})
@@ -120,6 +124,7 @@ export const layer = Layer.effect(
}
const skillUrl = new URL(`${encodeURIComponent(skill.name)}/`, source)
const versionFile = path.join(root, ".opencode-version")
const files = skill.files.map((file) => {
if (!isSafeRelativePath(file)) return undefined
let resource: URL
@@ -135,23 +140,66 @@ export const layer = Layer.effect(
return {
url: resource.href,
destination,
file,
}
})
if (files.some((file) => file === undefined)) {
return []
}
return [{ skill, root, files: files as { url: string; destination: string }[] }]
return [{ skill, root, versionFile, files: files as { url: string; destination: string; file: string }[] }]
}),
({ skill, root, files }) =>
({ skill, root, versionFile, files }) =>
Effect.gen(function* () {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (version === undefined || current === version) {
yield* Effect.forEach(files, (file) => download(file.url, file.destination), {
concurrency: fileConcurrency,
discard: true,
})
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
files,
(file) => download(file.url, path.resolve(staging, file.file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
const exists =
(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(staging, `${skill.name}.md`)).pipe(Effect.orDie))
if (!exists) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
const exists =
(yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ||
(yield* fs.exists(path.join(root, `${skill.name}.md`)).pipe(Effect.orDie))
? [AbsolutePath.make(root)]
: []
return exists ? [AbsolutePath.make(root)] : []
}),
{ concurrency: skillConcurrency },
).pipe(Effect.map((directories) => directories.flat()))
+62 -2
View File
@@ -10,8 +10,8 @@ import { tmpdir } from "./fixture/tmpdir"
const base = "https://skills.example.test/catalog/"
async function pull(skills: unknown[], files: Record<string, string> = {}) {
const tmp = await tmpdir()
async function pull(skills: unknown[], files: Record<string, string> = {}, cache?: Awaited<ReturnType<typeof tmpdir>>) {
const tmp = cache ?? (await tmpdir())
const requests: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
@@ -101,4 +101,64 @@ describe("SkillDiscovery.pull", () => {
await result.tmp[Symbol.asyncDispose]()
}
})
test("refreshes cached files when the version changes", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
},
tmp,
)
const second = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{
[`${base}deploy/SKILL.md`]: "# New",
},
tmp,
)
expect(await fs.readFile(path.join(first.directories[0], "SKILL.md"), "utf8")).toBe("# New")
expect(second.requests).toContain(`${base}deploy/SKILL.md`)
const third = await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Ignored" },
tmp,
)
expect(third.requests).toEqual([`${base}index.json`])
} finally {
await tmp[Symbol.asyncDispose]()
}
})
test("publishes complete updates and removes stale files", async () => {
const tmp = await tmpdir()
try {
const first = await pull(
[{ name: "deploy", version: "1", files: ["SKILL.md", "old.md"] }],
{
[`${base}deploy/SKILL.md`]: "# Old",
[`${base}deploy/old.md`]: "old reference",
},
tmp,
)
const root = first.directories[0]
await pull(
[{ name: "deploy", version: "2", files: ["SKILL.md", "missing.md"] }],
{ [`${base}deploy/SKILL.md`]: "# Partial" },
tmp,
)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# Old")
expect(await fs.readFile(path.join(root, "old.md"), "utf8")).toBe("old reference")
await pull([{ name: "deploy", version: "3", files: ["SKILL.md"] }], { [`${base}deploy/SKILL.md`]: "# New" }, tmp)
expect(await fs.readFile(path.join(root, "SKILL.md"), "utf8")).toBe("# New")
expect(await Bun.file(path.join(root, "old.md")).exists()).toBe(false)
} finally {
await tmp[Symbol.asyncDispose]()
}
})
})
+8 -1
View File
@@ -46,6 +46,7 @@ export class UnsupportedOperationError extends Schema.TaggedErrorClass<Unsupport
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
errorName: Schema.optional(Schema.String),
}) {}
export type Error =
@@ -81,7 +82,13 @@ export function toRequestError(error: Error) {
case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage)
return RequestError.internalError(
{
...(error.service ? { service: error.service } : {}),
...(error.errorName ? { errorName: error.errorName } : {}),
},
error.safeMessage,
)
}
}
+57 -9
View File
@@ -30,7 +30,7 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
@@ -521,7 +521,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
return yield* promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
@@ -543,7 +543,7 @@ export function make(input: {
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
return yield* promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
@@ -563,7 +563,7 @@ export function make(input: {
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
return yield* promptResponse(undefined, params.messageId)
}),
cancel,
}
@@ -695,7 +695,8 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
type AssistantError = NonNullable<AssistantMessage["error"]>
type AssistantInfo = (UsageService.AssistantTokenCost & Pick<AssistantMessage, "error">) | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
@@ -811,13 +812,60 @@ function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
const promptResponse = Effect.fn("ACP.promptResponse")(function* (
info: AssistantInfo,
messageId: string | null | undefined,
) {
if (!info?.error) {
return {
stopReason: "end_turn" as const,
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
const base = {
usage: UsageService.buildUsage(info),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
if (info.error.name === "MessageAbortedError") {
return {
stopReason: "cancelled" as const,
...base,
}
}
if (info.error.name === "MessageOutputLengthError") {
return {
stopReason: "max_tokens" as const,
...base,
}
}
if (info.error.name === "ContentFilterError") {
return {
stopReason: "refusal" as const,
...base,
}
}
if (info.error.name === "ProviderAuthError") {
return yield* new ACPError.AuthRequiredError({ providerId: info.error.data.providerID })
}
return yield* new ACPError.ServiceFailureError({
service: "session",
safeMessage: promptErrorMessage(info.error),
errorName: info.error.name,
})
})
function promptErrorMessage(error: AssistantError) {
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
return "OpenCode prompt failed"
}
function sendUsageUpdate(
+9 -1
View File
@@ -1,4 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context"
@@ -132,12 +133,19 @@ export const layer = Layer.effect(
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
const dir = item.location === "<built-in>" ? undefined : path.dirname(item.location)
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
if (!dir) return item.content
return [
item.content,
"",
`Base directory for this skill: ${dir}`,
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
].join("\n")
},
hints: [],
}
+47 -10
View File
@@ -13,6 +13,7 @@ const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String,
files: Schema.Array(Schema.String),
version: Schema.optional(Schema.String),
}) {}
class Index extends Schema.Class<Index>("Index")({
@@ -76,17 +77,53 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
const versionFile = path.join(root, ".opencode-version")
const version = skill.version
const current =
version === undefined
? undefined
: yield* fs.readFileStringSafe(versionFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
if (version === undefined || current === version) {
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency, discard: true },
)
} else {
const token = crypto.randomUUID()
const staging = `${root}.tmp-${token}`
const backup = `${root}.old-${token}`
yield* Effect.gen(function* () {
const downloaded = yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(staging, file)),
{ concurrency: fileConcurrency },
)
if (!downloaded.every(Boolean)) return
if (!(yield* fs.exists(path.join(staging, "SKILL.md")).pipe(Effect.orDie))) return
yield* fs.writeFileString(path.join(staging, ".opencode-version"), version)
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
if (cached) yield* fs.remove(backup, { recursive: true, force: true }).pipe(Effect.ignore)
}),
)
}).pipe(
Effect.catch((error) => Effect.logError("failed to refresh skill", { skill: skill.name, error })),
Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
)
}
return (yield* fs.exists(path.join(root, "SKILL.md")).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
+2 -2
View File
@@ -1,6 +1,5 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import type { Agent } from "@/agent/agent"
@@ -17,6 +16,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob"
import { Discovery } from "./discovery"
import { isRecord } from "@/util/record"
import { escapeHtml } from "@/util/html"
const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents"
@@ -339,7 +339,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` <location>${escapeHtml(skill.location)}</location>`,
" </skill>",
]),
"</available_skills>",
@@ -10,7 +10,7 @@ import type {
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Effect } from "effect"
@@ -144,7 +144,10 @@ const provider: Provider.Info = {
describe("ACP service sessions", () => {
const makeService = (
messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> },
options?: {
abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
},
) => {
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
@@ -193,19 +196,21 @@ describe("ACP service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
prompt:
options?.prompt ??
((input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
}),
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
@@ -994,6 +999,52 @@ describe("ACP service sessions", () => {
expect(usageUpdates).toEqual([session.sessionId])
})
it("maps assistant prompt errors to request errors instead of end turn", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
service
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32603)
expect(error.message).toBe("Internal error: Provider request failed")
expect(error.data).toEqual({ service: "session", errorName: "APIError" })
})
it("maps aborted assistant prompt errors to cancelled", async () => {
const { service } = makeService([], {
prompt: () =>
Promise.resolve({
data: {
info: assistantInfo(
{ input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
{ name: "MessageAbortedError", data: { message: "Aborted" } },
),
},
}),
})
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
)
expect(result.stopReason).toBe("cancelled")
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -1145,13 +1196,17 @@ describe("ACP service sessions", () => {
})
})
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
function assistantInfo(
tokens: UsageService.AssistantTokenCost["tokens"],
error?: AssistantMessage["error"],
): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
...(error ? { error } : {}),
}
}
@@ -11,6 +11,10 @@ import { testEffect } from "../lib/effect"
let CLOUDFLARE_SKILLS_URL: string
let server: ReturnType<typeof Bun.serve>
let downloadCount = 0
let mutableVersion = "1"
let mutableContent = "# Old"
let mutableDownloadCount = 0
let mutableFiles = ["SKILL.md"]
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
const cacheDir = path.join(Global.Path.cache, "skills")
@@ -24,6 +28,15 @@ beforeAll(async () => {
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/mutable/index.json") {
return Response.json({ skills: [{ name: "mutable", version: mutableVersion, files: mutableFiles }] })
}
if (url.pathname === "/mutable/mutable/SKILL.md") {
mutableDownloadCount++
return new Response(mutableContent)
}
if (url.pathname === "/mutable/mutable/old.md") return new Response("old reference")
// route /.well-known/skills/* to the fixture directory
if (url.pathname.startsWith("/.well-known/skills/")) {
const filePath = url.pathname.replace("/.well-known/skills/", "")
@@ -136,4 +149,37 @@ describe("Discovery.pull", () => {
expect(downloadCount).toBe(firstCount)
}),
)
it.live("refreshes a remote skill when its version changes", () =>
Effect.gen(function* () {
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
mutableVersion = "1"
mutableContent = "# Old"
mutableDownloadCount = 0
mutableFiles = ["SKILL.md", "old.md"]
const discovery = yield* Discovery.Service
const url = `http://localhost:${server.port}/mutable/`
const first = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(first[0], "SKILL.md")).text())).toBe("# Old")
mutableVersion = "2"
mutableContent = "# Partial"
mutableFiles = ["SKILL.md", "missing.md"]
const second = yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# Old")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).text())).toBe("old reference")
mutableVersion = "3"
mutableContent = "# New"
mutableFiles = ["SKILL.md"]
yield* discovery.pull(url)
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "SKILL.md")).text())).toBe("# New")
expect(yield* Effect.promise(() => Bun.file(path.join(second[0], "old.md")).exists())).toBe(false)
expect(mutableDownloadCount).toBe(3)
yield* discovery.pull(url)
expect(mutableDownloadCount).toBe(3)
}),
)
})
@@ -77,6 +77,33 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
)
describe("skill", () => {
it.effect("formats verbose locations as XML-safe filesystem paths", () =>
Effect.sync(() => {
const output = Skill.fmt(
[
{
name: "tagged-skill",
description: "A tagged skill.",
location: "/tmp/plugin.git#v1.3.0/SKILL.md",
content: "",
},
{
name: "built-in-skill",
description: "A built-in skill.",
location: "<built-in>",
content: "",
},
],
{ verbose: true },
)
expect(output).toContain("<location>/tmp/plugin.git#v1.3.0/SKILL.md</location>")
expect(output).toContain("<location>&lt;built-in&gt;</location>")
expect(output).not.toContain("file://")
expect(output).not.toContain("%23")
}),
)
it.live("discovers skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
@@ -1,5 +1,6 @@
import { checksum } from "@opencode-ai/core/util/encode"
import DOMPurify from "dompurify"
import type { MarkdownToken } from "./markdown-worker-protocol"
import { project } from "./markdown-stream"
export type MarkdownCacheEntry = {
@@ -8,8 +9,18 @@ export type MarkdownCacheEntry = {
html: string
}
export type MarkdownCodeCacheEntry = {
raw: string
hash: string
language: string
generation: number
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
const max = 200
const cache = new Map<string, MarkdownCacheEntry>()
const codeCache = new Map<string, MarkdownCodeCacheEntry>()
const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
@@ -52,6 +63,21 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
cache.delete(first)
}
export function getCachedMarkdownCode(key: string) {
return codeCache.get(key)
}
export function touchCachedMarkdownCode(key: string, value: MarkdownCodeCacheEntry) {
codeCache.delete(key)
codeCache.set(key, value)
if (codeCache.size <= max) return
const first = codeCache.keys().next().value
if (!first) return
codeCache.delete(first)
}
export async function preloadMarkdown(
text: string,
cacheKey: string,
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { preloadMarkdown } from "./markdown-cache"
import { getCachedMarkdownCode, preloadMarkdown, touchCachedMarkdownCode } from "./markdown-cache"
test("preloads completed markdown into the render cache", async () => {
const parsed: string[] = []
@@ -16,3 +16,24 @@ test("preloads completed markdown into the render cache", async () => {
expect(parsed).toEqual(["prepared response"])
})
test("keeps completed code highlights by stable block key", () => {
const key = `markdown-code-${crypto.randomUUID()}:0:code`
touchCachedMarkdownCode(key, {
raw: "```ts\nconst value = 1\n```",
hash: "23",
language: "ts",
generation: 1,
stable: [["const", "color: red"]],
unstable: [],
})
expect(getCachedMarkdownCode(key)).toEqual({
raw: "```ts\nconst value = 1\n```",
hash: "23",
language: "ts",
generation: 1,
stable: [["const", "color: red"]],
unstable: [],
})
})
@@ -24,7 +24,14 @@ import {
} from "./markdown-worker"
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import {
getCachedMarkdown,
getCachedMarkdownCode,
sanitizeMarkdown,
touchCachedMarkdown,
touchCachedMarkdownCode,
type MarkdownCacheEntry,
} from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
type RenderedBlock =
@@ -66,8 +73,12 @@ function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}
function highlightLanguage(language: string | undefined) {
return language && language in bundledLanguages ? language : "text"
}
async function code(text: string, language: string | undefined, key: string, complete = false) {
const name = language && language in bundledLanguages ? language : "text"
const name = highlightLanguage(language)
try {
const result = await highlightStreamingCode(key, text, name, complete)
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
@@ -297,9 +308,15 @@ function initialResult(text: string, key: string | undefined, projection: Projec
if (!text) return { text, blocks: [] }
const base = key ?? checksum(text)
if (base) {
const blocks = projection.blocks.flatMap((block, index) => {
if (block.mode === "code") return []
const blocks = projection.blocks.flatMap((block, index): RenderedBlock[] => {
const cacheKey = `${base}:${index}:${block.mode}`
if (block.mode === "code") {
if (!block.complete) return []
const cached = getCachedMarkdownCode(cacheKey)
if (cached?.raw !== block.raw) return []
touchCachedMarkdownCode(cacheKey, cached)
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, complete: true, ...cached }]
}
const cached = getCachedMarkdown(cacheKey)
if (cached?.raw !== block.raw) return []
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
@@ -370,9 +387,17 @@ export function Markdown(
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
if (block.mode === "code") {
const language = highlightLanguage(block.language)
if (block.complete && key) {
const shared = getCachedMarkdownCode(key)
if (shared?.raw === block.raw && shared.language === language) {
touchCachedMarkdownCode(key, shared)
return { key: blockKey, mode: block.mode, complete: true, ...shared }
}
}
const cached = completedCode.get(blockKey)
if (block.complete && cached?.raw === block.raw) return cached
const result = await code(block.src, block.language, blockKey, block.complete)
const result = await code(block.src, language, blockKey, block.complete)
const rendered = {
key: blockKey,
mode: block.mode,
@@ -381,7 +406,18 @@ export function Markdown(
complete: !!block.complete,
...result,
}
if (block.complete) completedCode.set(blockKey, rendered)
if (block.complete) {
completedCode.set(blockKey, rendered)
if (key && rendered.generation > 0)
touchCachedMarkdownCode(key, {
raw: rendered.raw,
hash: rendered.hash,
language: rendered.language,
generation: rendered.generation,
stable: rendered.stable,
unstable: rendered.unstable,
})
}
return rendered
}
+18 -16
View File
@@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
import { createCliRenderer, MouseButton } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@@ -183,21 +183,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const result = yield* Effect.scoped(
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}),
),
Effect.tryPromise({
try: () =>
createCliRenderer({
externalOutputMode: "passthrough",
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
(renderer) =>
Effect.sync(() => {
destroyRenderer(renderer)
@@ -89,6 +89,30 @@
cursor: not-allowed;
}
/* Danger */
[data-component="button-v2"][data-variant="danger"] {
background-color: var(--v2-background-bg-button-neutral);
color: var(--v2-state-fg-danger);
box-shadow: var(--v2-elevation-button-neutral);
}
[data-component="button-v2"][data-variant="danger"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="danger"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="danger"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}
/* Contrast */
[data-component="button-v2"][data-variant="contrast"] {
background-image:
@@ -4,7 +4,7 @@ const docs = `### Overview
Button v2 with three visual variants and two sizes.
### API
- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted".
- \`variant\`: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted".
- \`size\`: "normal" | "large".
- \`icon\`: Optional icon name.
- Inherits Kobalte Button props and native button attributes.
@@ -39,7 +39,7 @@ export default {
},
variant: {
control: "select",
options: ["neutral", "contrast", "ghost", "ghost-muted"],
options: ["neutral", "danger", "contrast", "ghost", "ghost-muted"],
},
size: {
control: "select",
@@ -61,6 +61,7 @@ export const Variants = {
}}
>
<ButtonV2 variant="neutral">Neutral</ButtonV2>
<ButtonV2 variant="danger">Danger</ButtonV2>
<ButtonV2 variant="contrast">Contrast</ButtonV2>
<ButtonV2 variant="ghost">Ghost</ButtonV2>
<ButtonV2 variant="ghost-muted" icon="edit">
@@ -115,7 +116,7 @@ export const Icon = {
export const AllStates = {
render: () => {
const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const
const variants = ["neutral", "danger", "contrast", "ghost", "ghost-muted"] as const
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
return (
+1 -1
View File
@@ -7,7 +7,7 @@ export interface ButtonV2Props
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "small" | "normal" | "large"
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted"
icon?: IconProps["name"]
}
+32 -8
View File
@@ -49,34 +49,51 @@
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 16px;
gap: 8px;
flex-shrink: 0;
align-self: stretch;
width: 100%;
gap: 0;
padding: 16px;
flex-shrink: 0;
[data-slot="dialog-header-title"] {
margin: 0;
flex: 1;
min-width: 0;
font-weight: 530;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="dialog-title-group"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 4px;
flex: 1;
min-width: 0;
}
[data-slot="dialog-title"] {
margin: 0;
flex: none;
flex-grow: 0;
font-weight: 530;
font-size: 15px;
line-height: 100%;
line-height: 20px;
letter-spacing: -0.13px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
}
[data-slot="dialog-description"] {
flex: none;
flex-grow: 0;
font-weight: 440;
font-size: 13px;
line-height: 100%;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
font-variation-settings: "slnt" 0;
@@ -86,8 +103,11 @@
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
width: 20px;
height: 20px;
margin-top: -2px;
margin-right: -2px;
margin-left: auto;
border-radius: 4px;
flex-shrink: 0;
cursor: pointer;
@@ -97,6 +117,10 @@
background: var(--v2-overlay-simple-overlay-hover);
}
}
&[data-hide-close] [data-slot="dialog-close-button"] {
display: none;
}
}
[data-slot="dialog-footer"] {
@@ -1,17 +1,17 @@
import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
import { Dialog, DialogFooter } from "./dialog-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "./dialog-v2"
import { ButtonV2 } from "./button-v2"
const docs = `### Overview
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
### API
- Optional: \`title\`, \`description\`, \`action\`.
- \`size\`: normal | large | x-large.
- \`fit\` and \`transition\` control layout and animation.
Compose with \`DialogHeader\`, \`DialogTitle\`, \`DialogTitleGroup\`, \`DialogBody\`, and \`DialogFooter\`.
### Variants and states
- Sizes and optional header/action controls.
### API
- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`.
- \`DialogHeader\`: row container with optional \`closeLabel\` and \`hideClose\`.
- \`DialogTitle\`: accessible single-line header title.
- \`DialogTitleGroup\`: column with \`title\` and required \`description\`.
### Accessibility
- Focus trapping and aria attributes provided by Kobalte Dialog.
@@ -34,6 +34,14 @@ export default {
},
}
function dialogHeader(title: string, description: string) {
return (
<DialogHeader>
<DialogTitleGroup title={title} description={description} />
</DialogHeader>
)
}
export const Basic = {
render: () => (
<KobalteDialog defaultOpen>
@@ -42,8 +50,9 @@ export const Basic = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Dialog" description="Description">
Dialog body content.
<Dialog>
{dialogHeader("Dialog", "Description")}
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -59,8 +68,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Normal" description="Normal size">
Normal dialog content.
<Dialog>
{dialogHeader("Normal", "Normal size")}
<DialogBody>Normal dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -71,8 +81,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="large" title="Large" description="Large size">
Large dialog content.
<Dialog size="large">
{dialogHeader("Large", "Large size")}
<DialogBody>Large dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -83,8 +94,9 @@ export const Sizes = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="x-large" title="Extra large" description="X-large size">
X-large dialog content.
<Dialog size="x-large">
{dialogHeader("Extra large", "X-large size")}
<DialogBody>X-large dialog content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -92,24 +104,41 @@ export const Sizes = {
),
}
export const CustomAction = {
export const TitleOnly = {
render: () => (
<KobalteDialog>
<KobalteDialog defaultOpen>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open action dialog
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog
title="Custom action"
description="Dialog with a custom header action"
action={
<Dialog>
<DialogHeader>
<DialogTitle>Open project</DialogTitle>
</DialogHeader>
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const HeaderControls = {
render: () => (
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog>
<DialogHeader>
<DialogTitleGroup title="Custom header" description="Dialog with an extra header control" />
<ButtonV2 variant="neutral" size="small">
Help
</ButtonV2>
}
>
Dialog body content.
</DialogHeader>
<DialogBody>Dialog body content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
@@ -124,7 +153,8 @@ export const WithFooter = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Save changes" description="Your changes will be lost if you don't save them." fit>
<Dialog fit>
{dialogHeader("Save changes", "Your changes will be lost if you don't save them.")}
<DialogFooter>
<ButtonV2 variant="neutral">Cancel</ButtonV2>
<ButtonV2 variant="contrast">Save</ButtonV2>
@@ -143,7 +173,8 @@ export const WithFooterThreeButtons = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Unsaved changes" description="You have unsaved changes. What would you like to do?" fit>
<Dialog fit>
{dialogHeader("Unsaved changes", "You have unsaved changes. What would you like to do?")}
<DialogFooter>
<span style={{ "margin-right": "auto" }}>
<ButtonV2 variant="ghost">Remind me later</ButtonV2>
@@ -165,8 +196,9 @@ export const Fit = {
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Fit content" fit>
Dialog fits its content.
<Dialog fit>
{dialogHeader("Fit content", "Dialog fits its content.")}
<DialogBody>Dialog fits its content.</DialogBody>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
+66 -47
View File
@@ -3,9 +3,6 @@ import { type ComponentProps, type JSXElement, type ParentProps, Show, children,
import "./dialog-v2.css"
export interface DialogProps extends ParentProps {
title?: JSXElement
description?: JSXElement
action?: JSXElement
size?: "normal" | "large" | "x-large"
variant?: "default" | "settings"
class?: ComponentProps<"div">["class"]
@@ -13,26 +10,76 @@ export interface DialogProps extends ParentProps {
fit?: boolean
}
export interface DialogHeaderProps extends ParentProps {
closeLabel?: string
hideClose?: boolean
}
export interface DialogTitleGroupProps {
title?: JSXElement
description: JSXElement
}
export function DialogFooter(props: ParentProps) {
return <div data-slot="dialog-footer">{props.children}</div>
}
export function DialogBody(props: ParentProps & { class?: ComponentProps<"div">["class"] }) {
const [local] = splitProps(props, ["class", "children"])
return (
<div data-slot="dialog-body" class={local.class}>
{local.children}
</div>
)
}
export function DialogTitle(props: ParentProps) {
return <Kobalte.Title data-slot="dialog-header-title">{props.children}</Kobalte.Title>
}
export function DialogTitleGroup(props: DialogTitleGroupProps) {
const title = children(() => props.title)
const description = children(() => props.description)
return (
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
<Kobalte.Description data-slot="dialog-description">{description()}</Kobalte.Description>
</div>
)
}
export function DialogHeader(props: DialogHeaderProps) {
const [local] = splitProps(props, ["closeLabel", "hideClose", "children"])
const hideClose = () => local.hideClose === true
return (
<div data-slot="dialog-header" data-hide-close={hideClose() ? "" : undefined}>
{local.children}
{!hideClose() && (
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label={local.closeLabel ?? "Close"}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Kobalte.CloseButton>
)}
</div>
)
}
export function Dialog(props: DialogProps) {
const [local] = splitProps(props, [
"title",
"description",
"action",
"size",
"variant",
"class",
"classList",
"fit",
"children",
])
const title = children(() => local.title)
const description = children(() => local.description)
const action = children(() => local.action)
const hasHeader = () => title() || action()
const [local] = splitProps(props, ["size", "variant", "class", "classList", "fit", "children"])
return (
<div
@@ -44,7 +91,6 @@ export function Dialog(props: DialogProps) {
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
data-no-header={!hasHeader() ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
@@ -58,34 +104,7 @@ export function Dialog(props: DialogProps) {
}
}}
>
<Show when={hasHeader()}>
<div data-slot="dialog-header">
<div data-slot="dialog-title-group">
<Show when={title()}>{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}</Show>
<Show when={description()}>
{(d) => <Kobalte.Description data-slot="dialog-description">{d()}</Kobalte.Description>}
</Show>
</div>
<Show when={action()}>{(a) => a()}</Show>
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label="Close">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Kobalte.CloseButton>
</div>
</Show>
<div data-slot="dialog-body">{local.children}</div>
{local.children}
</Kobalte.Content>
</div>
</div>
@@ -0,0 +1,11 @@
[data-component="divider-v2"] {
box-sizing: border-box;
width: 100%;
height: 0.5px;
margin-block: 0.25px;
border: none;
background: var(--v2-border-border-strong);
flex: none;
align-self: stretch;
flex-grow: 0;
}
@@ -0,0 +1,38 @@
import { DividerV2 } from "./divider-v2"
const docs = `### Overview
Horizontal hairline divider for v2 layouts.
### API
- Inherits native div attributes.
- Stretches to full width of its flex parent.
### Theming/tokens
- Uses \`data-component="divider-v2"\`.
- Border color: \`--v2-border-border-strong\`.
`
export default {
title: "UI V2/Divider",
id: "components-divider-v2",
component: DividerV2,
tags: ["autodocs"],
parameters: {
frameWidth: "320px",
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<div style={{ display: "flex", "flex-direction": "column", gap: "16px", padding: "16px" }}>
<span>Above</span>
<DividerV2 />
<span>Below</span>
</div>
),
}
@@ -0,0 +1,20 @@
import { type ComponentProps, splitProps } from "solid-js"
import "./divider-v2.css"
export interface DividerV2Props extends ComponentProps<"div"> {}
export function DividerV2(props: DividerV2Props) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<div
{...rest}
role="separator"
aria-orientation="horizontal"
data-component="divider-v2"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}