Merge branch 'dev' into update-design-subscriptions

This commit is contained in:
Aaron Iker 2026-01-15 21:28:10 +01:00
commit e258662178
24 changed files with 542 additions and 175 deletions

View file

@ -411,7 +411,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"dompurify": "catalog:", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27", "katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
@ -506,7 +506,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.6", "@types/bun": "1.3.5",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
@ -1774,7 +1774,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@ -2076,7 +2076,7 @@
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],

View file

@ -1,6 +1,6 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-hB6PWxkvRgb7o8vQO88SDHArG/FcdnLD7FR/BHvkYik=", "x86_64-linux": "sha256-4ndHIlS9t1ynRdFszJ1nvcu3YhunhuOc7jcuHI1FbnM=",
"aarch64-darwin": "sha256-P63bPPVm5F/YQ6DTaIQNB7SqDmQHQBhVsOh3Kd/c8Jw=" "aarch64-darwin": "sha256-C0E9KAEj3GI83HwirIL2zlXYIe92T+7Iv6F51BB6slY="
} }
} }

View file

@ -4,7 +4,7 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.6", "packageManager": "bun@1.3.5",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
@ -21,7 +21,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@types/bun": "1.3.6", "@types/bun": "1.3.5",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",

View file

@ -54,14 +54,17 @@ export function SessionHeader() {
<Portal mount={mount()}> <Portal mount={mount()}>
<button <button
type="button" type="button"
class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" class="hidden md:flex w-[320px] h-8 p-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
> >
<Icon name="magnifying-glass" size="small" class="text-text-weak" /> <div class="flex items-center gap-2">
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span> <Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
</div>
<Show when={hotkey()}> <Show when={hotkey()}>
{(keybind) => ( {(keybind) => (
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak"> <span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
{keybind()} {keybind()}
</span> </span>
)} )}

View file

@ -88,7 +88,7 @@ export function Titlebar() {
onClick={layout.mobileSidebar.toggle} onClick={layout.mobileSidebar.toggle}
/> />
<TooltipKeybind <TooltipKeybind
class="hidden xl:flex shrink-0" class="hidden xl:flex shrink-0 ml-14"
placement="bottom" placement="bottom"
title="Toggle sidebar" title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")} keybind={command.keybind("sidebar.toggle")}

View file

@ -161,53 +161,64 @@ export default function Layout(props: ParentProps) {
}) })
onMount(() => { onMount(() => {
const alerts = {
"permission.asked": {
title: "Permission required",
icon: "checklist" as const,
description: (sessionTitle: string, projectName: string) =>
`${sessionTitle} in ${projectName} needs permission`,
},
"question.asked": {
title: "Question",
icon: "bubble-5" as const,
description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`,
},
}
const toastBySession = new Map<string, number>() const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>() const alertedAtBySession = new Map<string, number>()
const permissionAlertCooldownMs = 5000 const cooldownMs = 5000
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.asked") return if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const config = alerts[e.details.type]
const directory = e.name const directory = e.name
const perm = e.details.properties const props = e.details.properties
if (permission.autoResponds(perm, directory)) return if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = globalSync.child(directory) const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID) const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${perm.sessionID}` const sessionKey = `${directory}:${props.sessionID}`
const sessionTitle = session?.title ?? "New session" const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory) const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission` const description = config.description(sessionTitle, projectName)
const href = `/${base64Encode(directory)}/session/${perm.sessionID}` const href = `/${base64Encode(directory)}/session/${props.sessionID}`
const now = Date.now() const now = Date.now()
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < permissionAlertCooldownMs) return if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now) alertedAtBySession.set(sessionKey, now)
void platform.notify("Permission required", description, href) void platform.notify(config.title, description, href)
const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id const currentSession = params.id
if (directory === currentDir && perm.sessionID === currentSession) return if (directory === currentDir && props.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return
const existingToastId = toastBySession.get(sessionKey) const existingToastId = toastBySession.get(sessionKey)
if (existingToastId !== undefined) { if (existingToastId !== undefined) toaster.dismiss(existingToastId)
toaster.dismiss(existingToastId)
}
const toastId = showToast({ const toastId = showToast({
persistent: true, persistent: true,
icon: "checklist", icon: config.icon,
title: "Permission required", title: config.title,
description, description,
actions: [ actions: [
{ {
label: "Go to session", label: "Go to session",
onClick: () => { onClick: () => navigate(href),
navigate(href)
},
}, },
{ {
label: "Dismiss", label: "Dismiss",
@ -848,7 +859,6 @@ export default function Layout(props: ParentProps) {
return false return false
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
if (hasPermissions()) return false if (hasPermissions()) return false
const status = sessionStore.session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return status?.type === "busy" || status?.type === "retry"
@ -871,9 +881,9 @@ export default function Layout(props: ParentProps) {
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors px-3 class="group/session relative w-full rounded-md cursor-default transition-colors px-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
@ -903,7 +913,13 @@ export default function Layout(props: ParentProps) {
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title} {props.session.title}
</span> </span>
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> <Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div> </div>
</A> </A>
</Tooltip> </Tooltip>
@ -914,6 +930,7 @@ export default function Layout(props: ParentProps) {
placement={props.mobile ? "bottom" : "right"} placement={props.mobile ? "bottom" : "right"}
title="Archive session" title="Archive session"
keybind={command.keybind("session.archive")} keybind={command.keybind("session.archive")}
gutter={8}
> >
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind> </TooltipKeybind>
@ -961,7 +978,7 @@ export default function Layout(props: ParentProps) {
type="button" type="button"
classList={{ classList={{
"flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true, "flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true,
"bg-surface-base-hover border-icon-strong-base": selected(), "bg-transparent border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(), "bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(),
}} }}
onClick={() => navigateToProject(props.project.worktree)} onClick={() => navigateToProject(props.project.worktree)}
@ -973,9 +990,9 @@ export default function Layout(props: ParentProps) {
return ( return (
// @ts-ignore // @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={10} trigger={trigger}> <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}>
<div class="-m-3 flex flex-col w-72"> <div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-strong">Recent sessions</div> <div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2"> <div class="px-2 pb-2 flex flex-col gap-2">
<Show <Show
when={workspaceEnabled()} when={workspaceEnabled()}
@ -999,7 +1016,7 @@ export default function Layout(props: ParentProps) {
<div class="shrink-0 size-6 flex items-center justify-center"> <div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" /> <Icon name="branch" size="small" class="text-icon-base" />
</div> </div>
<span class="truncate text-14-medium text-text-strong">{label(directory)}</span> <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div> </div>
<For each={sessions(directory)}> <For each={sessions(directory)}>
{(session) => ( {(session) => (
@ -1011,18 +1028,20 @@ export default function Layout(props: ParentProps) {
</For> </For>
</Show> </Show>
</div> </div>
<div class="px-2 py-2 border-t border-border-weak-base"> <Show when={!selected()}>
<Button <div class="px-2 py-2 border-t border-border-weak-base">
variant="ghost" <Button
class="flex w-full text-left justify-start text-text-base px-2" variant="ghost"
onClick={() => { class="flex w-full text-left justify-start text-text-base px-2"
layout.sidebar.open() onClick={() => {
navigateToProject(props.project.worktree) layout.sidebar.open()
}} navigateToProject(props.project.worktree)
> }}
View all sessions >
</Button> View all sessions
</div> </Button>
</div>
</Show>
</div> </div>
</HoverCard> </HoverCard>
</div> </div>
@ -1104,7 +1123,7 @@ export default function Layout(props: ParentProps) {
<div class="flex items-center justify-center shrink-0 size-6"> <div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" /> <Icon name="branch" size="small" />
</div> </div>
<span class="truncate text-14-medium text-text-strong">{title()}</span> <span class="truncate text-14-medium text-text-base">{title()}</span>
<Icon <Icon
name={open() ? "chevron-down" : "chevron-right"} name={open() ? "chevron-down" : "chevron-right"}
size="small" size="small"
@ -1113,17 +1132,20 @@ export default function Layout(props: ParentProps) {
</div> </div>
</Collapsible.Trigger> </Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex"> <div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<Tooltip class="pointer-events-auto" value="More options" placement="top"> <IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md" /> <TooltipKeybind
</Tooltip> class="pointer-events-auto"
<Tooltip class="pointer-events-auto" value="New session" placement="top"> placement="right"
title="New session"
keybind={command.keybind("session.new")}
>
<IconButton <IconButton
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
class="size-6 rounded-md" class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)} onClick={() => navigate(`/${slug()}/session`)}
/> />
</Tooltip> </TooltipKeybind>
</div> </div>
</div> </div>
</div> </div>
@ -1146,9 +1168,12 @@ export default function Layout(props: ParentProps) {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10" class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
size="large" size="large"
onClick={loadMore} onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
> >
Load more Load more
</Button> </Button>
@ -1191,9 +1216,12 @@ export default function Layout(props: ParentProps) {
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10" class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
size="large" size="large"
onClick={loadMore} onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
> >
Load more Load more
</Button> </Button>
@ -1312,7 +1340,7 @@ export default function Layout(props: ParentProps) {
{(p) => ( {(p) => (
<> <>
<div class="shrink-0 px-2 py-1"> <div class="shrink-0 px-2 py-1">
<div class="flex items-start justify-between gap-2 p-2"> <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<span class="text-16-medium text-text-strong truncate">{projectName()}</span> <span class="text-16-medium text-text-strong truncate">{projectName()}</span>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0"> <Tooltip placement="right" value={project()?.worktree} class="shrink-0">
@ -1326,22 +1354,22 @@ export default function Layout(props: ParentProps) {
as={IconButton} as={IconButton}
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
class="shrink-0 size-6 rounded-md" class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}> <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>Edit</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}> <DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"} {layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
<DropdownMenu.ItemLabel>Close</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>

View file

@ -223,7 +223,7 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() { pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
#[cfg(target_os = "macos")] #[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall") let _ = std::process::Command::new("killall")
.arg("opencode-cli") .arg("opencode-cli")
.output(); .output();

View file

@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event" import { TuiEvent } from "../../event"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { formatDuration } from "@/util/format"
import { createColors, createFrames } from "../../ui/spinner.ts" import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@ -1037,7 +1038,8 @@ export function Prompt(props: PromptProps) {
if (!r) return "" if (!r) return ""
const baseMessage = message() const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : "" const truncatedHint = isTruncated() ? " (click to expand)" : ""
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]` const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo return baseMessage + truncatedHint + retryInfo
} }

View file

@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.` return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
} }
if (ConfigMarkdown.FrontmatterError.isInstance(input)) { if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}` return input.data.message
} }
if (Config.InvalidError.isInstance(input)) if (Config.InvalidError.isInstance(input))
return [ return [

View file

@ -19,6 +19,8 @@ import { BunProc } from "@/bun"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs" import { existsSync } from "fs"
import { Bus } from "@/bus"
import { Session } from "@/session"
export namespace Config { export namespace Config {
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@ -231,8 +233,15 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item) const md = await ConfigMarkdown.parse(item).catch((err) => {
if (!md.data) continue const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"] const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const file = rel(item, patterns) ?? path.basename(item) const file = rel(item, patterns) ?? path.basename(item)
@ -263,8 +272,15 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item) const md = await ConfigMarkdown.parse(item).catch((err) => {
if (!md.data) continue const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const file = rel(item, patterns) ?? path.basename(item) const file = rel(item, patterns) ?? path.basename(item)
@ -294,8 +310,15 @@ export namespace Config {
dot: true, dot: true,
cwd: dir, cwd: dir,
})) { })) {
const md = await ConfigMarkdown.parse(item) const md = await ConfigMarkdown.parse(item).catch((err) => {
if (!md.data) continue const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
const config = { const config = {
name: path.basename(item, ".md"), name: path.basename(item, ".md"),

View file

@ -14,8 +14,60 @@ export namespace ConfigMarkdown {
return Array.from(template.matchAll(SHELL_REGEX)) return Array.from(template.matchAll(SHELL_REGEX))
} }
export function preprocessFrontmatter(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!match) return content
const frontmatter = match[1]
const lines = frontmatter.split("\n")
const result: string[] = []
for (const line of lines) {
// skip comments and empty lines
if (line.trim().startsWith("#") || line.trim() === "") {
result.push(line)
continue
}
// skip lines that are continuations (indented)
if (line.match(/^\s+/)) {
result.push(line)
continue
}
// match key: value pattern
const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
if (!kvMatch) {
result.push(line)
continue
}
const key = kvMatch[1]
const value = kvMatch[2].trim()
// skip if value is empty, already quoted, or uses block scalar
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
result.push(line)
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |`)
result.push(` ${value}`)
continue
}
result.push(line)
}
const processed = result.join("\n")
return content.replace(frontmatter, () => processed)
}
export async function parse(filePath: string) { export async function parse(filePath: string) {
const template = await Bun.file(filePath).text() const raw = await Bun.file(filePath).text()
const template = preprocessFrontmatter(raw)
try { try {
const md = matter(template) const md = matter(template)
@ -24,7 +76,7 @@ export namespace ConfigMarkdown {
throw new FrontmatterError( throw new FrontmatterError(
{ {
path: filePath, path: filePath,
message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`, message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
}, },
{ cause: err }, { cause: err },
) )

View file

@ -272,7 +272,11 @@ export namespace Project {
export async function list() { export async function list() {
const keys = await Storage.list(["project"]) const keys = await Storage.list(["project"])
return await Promise.all(keys.map((x) => Storage.read<Info>(x))) const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
return projects.map((project) => ({
...project,
sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
}))
} }
export const update = fn( export const update = fn(

View file

@ -81,7 +81,11 @@ export namespace ModelsDev {
const file = Bun.file(filepath) const file = Bun.file(filepath)
const result = await file.json().catch(() => {}) const result = await file.json().catch(() => {})
if (result) return result as Record<string, Provider> if (result) return result as Record<string, Provider>
const json = await data() if (typeof data === "function") {
const json = await data()
return JSON.parse(json) as Record<string, Provider>
}
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return JSON.parse(json) as Record<string, Provider> return JSON.parse(json) as Record<string, Provider>
} }

View file

@ -1,4 +1,5 @@
import z from "zod" import z from "zod"
import path from "path"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@ -7,6 +8,9 @@ import { Log } from "../util/log"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session"
export namespace Skill { export namespace Skill {
const log = Log.create({ service: "skill" }) const log = Log.create({ service: "skill" })
@ -42,10 +46,16 @@ export namespace Skill {
const skills: Record<string, Info> = {} const skills: Record<string, Info> = {}
const addSkill = async (match: string) => { const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match) const md = await ConfigMarkdown.parse(match).catch((err) => {
if (!md) { const message = ConfigMarkdown.FrontmatterError.isInstance(err)
return ? err.data.message
} : `Failed to parse skill ${match}`
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return if (!parsed.success) return

View file

@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", {
await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath() const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern] const args = [
"-nH",
"--hidden",
"--follow",
"--no-messages",
"--field-match-separator=|",
"--regexp",
params.pattern,
]
if (params.include) { if (params.include) {
args.push("--glob", params.include) args.push("--glob", params.include)
} }
@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", {
const errorOutput = await new Response(proc.stderr).text() const errorOutput = await new Response(proc.stderr).text()
const exitCode = await proc.exited const exitCode = await proc.exited
if (exitCode === 1) { // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return { return {
title: params.pattern, title: params.pattern,
metadata: { matches: 0, truncated: false }, metadata: { matches: 0, truncated: false },
@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", {
} }
} }
if (exitCode !== 0) { if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`) throw new Error(`ripgrep failed: ${errorOutput}`)
} }
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings // Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/) const lines = output.trim().split(/\r?\n/)
const matches = [] const matches = []
@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", {
outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)") outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
} }
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return { return {
title: params.pattern, title: params.pattern,
metadata: { metadata: {

View file

@ -0,0 +1,20 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}

View file

@ -0,0 +1,4 @@
---
---
Content

View file

@ -0,0 +1,28 @@
---
description: "This is a description wrapped in quotes"
# field: this is a commented out field that should be ignored
occupation: This man has the following occupation: Software Engineer
title: 'Hello World'
name: John "Doe"
family: He has no 'family'
summary: >
This is a summary
url: https://example.com:8080/path?query=value
time: The time is 12:30:00 PM
nested: First: Second: Third: Fourth
quoted_colon: "Already quoted: no change needed"
single_quoted_colon: 'Single quoted: also fine'
mixed: He said "hello: world" and then left
empty:
dollar: Use $' and $& for special patterns
---
Content that should not be parsed:
fake_field: this is not yaml
another: neither is this
time: 10:30:00 AM
url: https://should-not-be-parsed.com:3000
The above lines look like YAML but are just content.

View file

@ -0,0 +1 @@
Content

View file

@ -1,89 +1,192 @@
import { expect, test } from "bun:test" import { expect, test, describe } from "bun:test"
import { ConfigMarkdown } from "../../src/config/markdown" import { ConfigMarkdown } from "../../src/config/markdown"
const template = `This is a @valid/path/to/a/file and it should also match at describe("ConfigMarkdown: normal template", () => {
the beginning of a line: const template = `This is a @valid/path/to/a/file and it should also match at
the beginning of a line:
@another-valid/path/to/a/file @another-valid/path/to/a/file
but this is not: but this is not:
- Adds a "Co-authored-by:" footer which clarifies which AI agent - Adds a "Co-authored-by:" footer which clarifies which AI agent
helped create this commit, using an appropriate \`noreply@...\` helped create this commit, using an appropriate \`noreply@...\`
or \`noreply@anthropic.com\` email address. or \`noreply@anthropic.com\` email address.
We also need to deal with files followed by @commas, ones We also need to deal with files followed by @commas, ones
with @file-extensions.md, even @multiple.extensions.bak, with @file-extensions.md, even @multiple.extensions.bak,
hidden directories like @.config/ or files like @.bashrc hidden directories like @.config/ or files like @.bashrc
and ones at the end of a sentence like @foo.md. and ones at the end of a sentence like @foo.md.
Also shouldn't forget @/absolute/paths.txt with and @/without/extensions, Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
as well as @~/home-files and @~/paths/under/home.txt. as well as @~/home-files and @~/paths/under/home.txt.
If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.` If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
const matches = ConfigMarkdown.files(template) const matches = ConfigMarkdown.files(template)
test("should extract exactly 12 file references", () => { test("should extract exactly 12 file references", () => {
expect(matches.length).toBe(12) expect(matches.length).toBe(12)
})
test("should extract valid/path/to/a/file", () => {
expect(matches[0][1]).toBe("valid/path/to/a/file")
})
test("should extract another-valid/path/to/a/file", () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file")
})
test("should extract paths ignoring comma after", () => {
expect(matches[2][1]).toBe("commas")
})
test("should extract a path with a file extension and comma after", () => {
expect(matches[3][1]).toBe("file-extensions.md")
})
test("should extract a path with multiple dots and comma after", () => {
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
})
}) })
test("should extract valid/path/to/a/file", () => { describe("ConfigMarkdown: frontmatter parsing", async () => {
expect(matches[0][1]).toBe("valid/path/to/a/file") const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
test("should parse without throwing", () => {
expect(parsed).toBeDefined()
expect(parsed.data).toBeDefined()
expect(parsed.content).toBeDefined()
})
test("should extract description field", () => {
expect(parsed.data.description).toBe("This is a description wrapped in quotes")
})
test("should extract occupation field with colon in value", () => {
expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
})
test("should extract title field with single quotes", () => {
expect(parsed.data.title).toBe("Hello World")
})
test("should extract name field with embedded quotes", () => {
expect(parsed.data.name).toBe('John "Doe"')
})
test("should extract family field with embedded single quotes", () => {
expect(parsed.data.family).toBe("He has no 'family'")
})
test("should extract multiline summary field", () => {
expect(parsed.data.summary).toBe("This is a summary\n")
})
test("should not include commented fields in data", () => {
expect(parsed.data.field).toBeUndefined()
})
test("should extract URL with port", () => {
expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
})
test("should extract time with colons", () => {
expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
})
test("should extract value with multiple colons", () => {
expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
})
test("should preserve already double-quoted values with colons", () => {
expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
})
test("should preserve already single-quoted values with colons", () => {
expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
})
test("should extract value with quotes and colons mixed", () => {
expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
})
test("should handle empty values", () => {
expect(parsed.data.empty).toBeNull()
})
test("should handle dollar sign replacement patterns literally", () => {
expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
})
test("should not parse fake yaml from content", () => {
expect(parsed.data.fake_field).toBeUndefined()
expect(parsed.data.another).toBeUndefined()
})
test("should extract content after frontmatter without modification", () => {
expect(parsed.content).toContain("Content that should not be parsed:")
expect(parsed.content).toContain("fake_field: this is not yaml")
expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
})
}) })
test("should extract another-valid/path/to/a/file", () => { describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
expect(matches[1][1]).toBe("another-valid/path/to/a/file") const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
test("should parse without throwing", () => {
expect(result).toBeDefined()
expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
})
}) })
test("should extract paths ignoring comma after", () => { describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
expect(matches[2][1]).toBe("commas") const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
})
test("should extract a path with a file extension and comma after", () => { test("should parse without throwing", () => {
expect(matches[3][1]).toBe("file-extensions.md") expect(result).toBeDefined()
}) expect(result.data).toEqual({})
expect(result.content.trim()).toBe("Content")
test("should extract a path with multiple dots and comma after", () => { })
expect(matches[4][1]).toBe("multiple.extensions.bak")
})
test("should extract hidden directory", () => {
expect(matches[5][1]).toBe(".config/")
})
test("should extract hidden file", () => {
expect(matches[6][1]).toBe(".bashrc")
})
test("should extract a file ignoring period at end of sentence", () => {
expect(matches[7][1]).toBe("foo.md")
})
test("should extract an absolute path with an extension", () => {
expect(matches[8][1]).toBe("/absolute/paths.txt")
})
test("should extract an absolute path without an extension", () => {
expect(matches[9][1]).toBe("/without/extensions")
})
test("should extract an absolute path in home directory", () => {
expect(matches[10][1]).toBe("~/home-files")
})
test("should extract an absolute path under home directory", () => {
expect(matches[11][1]).toBe("~/paths/under/home.txt")
})
test("should not match when preceded by backtick", () => {
const backtickTest = "This `@should/not/match` should be ignored"
const backtickMatches = ConfigMarkdown.files(backtickTest)
expect(backtickMatches.length).toBe(0)
})
test("should not match email addresses", () => {
const emailTest = "Contact user@example.com for help"
const emailMatches = ConfigMarkdown.files(emailTest)
expect(emailMatches.length).toBe(0)
}) })

View file

@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import { formatDuration } from "../../src/util/format"
describe("util.format", () => {
describe("formatDuration", () => {
test("returns empty string for zero or negative values", () => {
expect(formatDuration(0)).toBe("")
expect(formatDuration(-1)).toBe("")
expect(formatDuration(-100)).toBe("")
})
test("formats seconds under a minute", () => {
expect(formatDuration(1)).toBe("1s")
expect(formatDuration(30)).toBe("30s")
expect(formatDuration(59)).toBe("59s")
})
test("formats minutes under an hour", () => {
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(61)).toBe("1m 1s")
expect(formatDuration(90)).toBe("1m 30s")
expect(formatDuration(120)).toBe("2m")
expect(formatDuration(330)).toBe("5m 30s")
expect(formatDuration(3599)).toBe("59m 59s")
})
test("formats hours under a day", () => {
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(3660)).toBe("1h 1m")
expect(formatDuration(7200)).toBe("2h")
expect(formatDuration(8100)).toBe("2h 15m")
expect(formatDuration(86399)).toBe("23h 59m")
})
test("formats days under a week", () => {
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(172800)).toBe("~2 days")
expect(formatDuration(259200)).toBe("~3 days")
expect(formatDuration(604799)).toBe("~6 days")
})
test("formats weeks", () => {
expect(formatDuration(604800)).toBe("~1 week")
expect(formatDuration(1209600)).toBe("~2 weeks")
expect(formatDuration(1609200)).toBe("~2 weeks")
})
test("handles boundary values correctly", () => {
expect(formatDuration(59)).toBe("59s")
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(3599)).toBe("59m 59s")
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(86399)).toBe("23h 59m")
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(604799)).toBe("~6 days")
expect(formatDuration(604800)).toBe("~1 week")
})
})
})

View file

@ -48,10 +48,10 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27", "katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
"dompurify": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-katex-extension": "5.1.6", "marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",

View file

@ -33,7 +33,7 @@
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--surface-base); background: var(--surface-base);
border: 1px solid var(--border-base); border: 1px solid var(--border-weak-base);
transition: border-color 0.15s ease; transition: border-color 0.15s ease;
&:hover { &:hover {
@ -416,7 +416,30 @@
box-shadow: var(--shadow-xs-border-base); box-shadow: var(--shadow-xs-border-base);
background-color: var(--surface-raised-base); background-color: var(--surface-raised-base);
overflow: visible; overflow: visible;
overflow-anchor: none;
& > *:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
}
& > *:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
}
[data-component="collapsible"] {
border: none;
}
[data-component="card"] {
border: none;
}
}
&[data-permission="true"] {
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -438,26 +461,11 @@
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
} }
}
& > *:first-child { &[data-question="true"] {
border-top-left-radius: 6px; background: var(--background-base);
border-top-right-radius: 6px; border: 1px solid var(--border-weak-base);
overflow: hidden;
}
& > *:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: hidden;
}
[data-component="collapsible"] {
border: none;
}
[data-component="card"] {
border: none;
}
} }
} }

View file

@ -5,7 +5,7 @@
[data-slot="tooltip-keybind"] { [data-slot="tooltip-keybind"] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
} }
[data-slot="tooltip-keybind-key"] { [data-slot="tooltip-keybind-key"] {
@ -18,11 +18,11 @@
[data-component="tooltip"] { [data-component="tooltip"] {
z-index: 1000; z-index: 1000;
max-width: 320px; max-width: 320px;
border-radius: var(--radius-md); border-radius: var(--radius-sm);
background-color: var(--surface-float-base); background-color: var(--surface-float-base);
color: var(--text-invert-strong); color: var(--text-invert-strong);
background: var(--surface-float-base); background: var(--surface-float-base);
padding: 6px 12px; padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);