feat(app): session timeline/turn rework (#13196)

Co-authored-by: David Hill <iamdavidhill@gmail.com>
pull/13978/head
Adam 2026-02-17 07:16:23 -06:00 committed by GitHub
parent 3dfbb70593
commit 10985671ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 3158 additions and 2477 deletions

View File

@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
const model = key.split(":").slice(1).join(":")
await input.fill(model)
@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await expect(dialog).toHaveCount(0)
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
})

View File

@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
}}
modal={false}
placement="top-start"
gutter={8}
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}

View File

@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@ -94,7 +93,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout()
const comments = useComments()
const params = useParams()
@ -105,7 +103,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const language = useLanguage()
const platform = usePlatform()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
@ -223,14 +221,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal",
applyingHistory: false,
})
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: language.t(EXAMPLES[store.placeholder]),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const contextItems = createMemo(() => {
const items = prompt.context.items()
if (store.mode !== "shell") return items
return items.filter((item) => !item.comment?.trim())
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
@ -250,6 +259,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
const suggest = createMemo(() => !hasUserPrompt())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
@ -282,6 +303,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => editorRef?.focus())
}
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: store.mode !== "normal",
onSelect: pick,
},
])
const closePopover = () => setStore("popover", null)
const resetHistoryNavigation = (force = false) => {
@ -326,6 +366,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => {
params.id
if (params.id) return
if (!suggest()) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
}, 6500)
@ -816,6 +857,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
if (store.mode !== "normal") return
pick()
return
}
if (event.key === "Backspace") {
const selection = window.getSelection()
if (selection && selection.isCollapsed) {
@ -956,8 +1004,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const variants = createMemo(() => ["default", ...local.model.variant.list()])
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover
popover={store.popover}
setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@ -977,8 +1027,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
@ -988,7 +1038,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/>
<PromptContextItems
items={prompt.context.items()}
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@ -1008,7 +1058,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
class="relative max-h-[240px] overflow-y-auto"
ref={(el) => (scrollRef = el)}
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (
target.closest(
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
)
) {
return
}
editorRef?.focus()
}}
>
<div
data-component="prompt-input"
ref={(el) => {
@ -1029,141 +1094,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
>
{placeholder()}
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
<Icon name="console" size="small" class="text-icon-primary" />
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span>
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span>
</div>
</Match>
<Match when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
class="px-2 min-w-0 max-w-[240px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="model-variant-cycle"
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? language.t("common.default")}
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</Show>
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
@ -1175,22 +1121,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<div
aria-hidden={store.mode !== "normal"}
class="flex items-center gap-1 transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-4.5" />
<Icon name="plus" class="size-4.5" />
</Button>
</Tooltip>
</Show>
</div>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
@ -1212,17 +1170,213 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!prompt.dirty() && !working() && commentCount() === 0}
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
</div>
</form>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
<div class="shrink-0">
<div
data-component="prompt-mode-toggle"
class="relative h-8 w-[68px] rounded-[4px] bg-surface-inset-base shadow-[var(--shadow-xs-border-base)] p-0 flex items-center gap-1 overflow-visible"
>
<div
class="absolute inset-y-0 left-0 w-[calc((100%-4px)/2)] rounded-[4px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-xs-border)] transition-transform duration-200 ease-out will-change-transform"
style={{
transform: store.mode === "shell" ? "translateX(0px)" : "translateX(calc(100% + 4px))",
}}
/>
<button
type="button"
class="relative z-10 flex-1 h-full flex items-center justify-center rounded-[4px]"
aria-pressed={store.mode === "shell"}
onClick={() => setMode("shell")}
>
<Icon
name="console"
size="normal"
classList={{
"text-icon-strong-base": store.mode === "shell",
"text-icon-weak": store.mode !== "shell",
}}
/>
</button>
<button
type="button"
class="relative z-10 flex-1 h-full flex items-center justify-center rounded-[4px]"
aria-pressed={store.mode === "normal"}
onClick={() => setMode("normal")}
>
<Icon
name="prompt"
size="normal"
classList={{
"text-icon-interactive-base": store.mode === "normal",
"text-icon-weak": store.mode !== "normal",
}}
/>
</button>
</div>
</div>
</div>
</div>
</Show>
</div>
)
}

View File

@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
selected,
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
"bg-background-stronger": !selected,
}}
onClick={() => props.openComment(item)}

View File

@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
mode: "shell",
commentCount: 0,
example: "example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.shell")
})
test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComment",
)
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComments",
)
})
test("returns default placeholder with example", () => {
test("returns default placeholder with example when suggestions enabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: true,
t,
})
expect(value).toBe("prompt.placeholder.normal:translated-example")
})
test("returns simple placeholder when suggestions disabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: false,
t,
})
expect(value).toBe("prompt.placeholder.simple")
})
})

View File

@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
mode: "normal" | "shell"
commentCount: number
example: string
suggest: boolean
t: (key: string, params?: Record<string, string>) => string
}
@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example })
}

View File

@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el)
}}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
onMouseDown={(e) => e.preventDefault()}
>
<Switch>

View File

@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
globalSync.todo.set(sessionID, undefined)
return Promise.resolve()
}
return sdk.client.session
@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID,
})
.catch(() => {})
.finally(() => {
globalSync.todo.set(sessionID, undefined)
})
}
const restoreCommentItems = (items: CommentItem[]) => {

View File

@ -1,4 +1,4 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@ -12,25 +12,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const language = useLanguage()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const total = createMemo(() => questions().length)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
customOn: [] as boolean[],
editing: false,
sending: false,
})
let root: HTMLDivElement | undefined
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
return `${n} of ${total()} questions`
})
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
setStore("custom", store.tab, value)
if (!selected) return
if (multi()) {
setStore("answers", store.tab, (current = []) => {
const removed = prev ? current.filter((item) => item.trim() !== prev) : current
if (!next) return removed
if (removed.some((item) => item.trim() === next)) return removed
return [...removed, next]
})
return
}
setStore("answers", store.tab, next ? [next] : [])
}
const measure = () => {
if (!root) return
const scroller = document.querySelector(".session-scroller")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
if (!top) {
root.style.removeProperty("--question-prompt-max-height")
return
}
const dock = root.closest('[data-component="session-prompt-dock"]')
if (!(dock instanceof HTMLElement)) return
const dockBottom = dock.getBoundingClientRect().bottom
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
const gap = 8
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
root.style.setProperty("--question-prompt-max-height", `${max}px`)
}
onMount(() => {
let raf: number | undefined
const update = () => {
if (raf !== undefined) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = undefined
measure()
})
}
update()
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf)
})
})
const fail = (err: unknown) => {
@ -64,23 +137,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
}
const submit = () => {
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) {
setStore("custom", store.tab, answer)
}
if (single()) {
void reply([[answer]])
return
}
setStore("tab", store.tab + 1)
if (custom) setStore("custom", store.tab, answer)
if (!custom) setStore("customOn", store.tab, false)
setStore("editing", false)
}
const toggle = (answer: string) => {
@ -90,16 +153,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
})
}
const selectTab = (index: number) => {
setStore("tab", index)
const customToggle = () => {
if (store.sending) return
if (!multi()) {
setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
return
}
const next = !on()
setStore("customOn", store.tab, next)
if (next) {
setStore("editing", true)
customUpdate(input(), true)
return
}
const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false)
}
const customOpen = () => {
if (store.sending) return
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
const selectOption = (optIndex: number) => {
if (store.sending) return
if (optIndex === options().length) {
setStore("editing", true)
customOpen()
return
}
@ -112,67 +200,67 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
pick(opt.label)
}
const handleCustomSubmit = (e: Event) => {
e.preventDefault()
const commitCustom = () => {
setStore("editing", false)
customUpdate(input())
}
const next = () => {
if (store.sending) return
if (store.editing) commitCustom()
const value = input().trim()
if (!value) {
setStore("editing", false)
if (store.tab >= total() - 1) {
submit()
return
}
if (multi()) {
setStore("answers", store.tab, (current = []) => {
if (current.includes(value)) return current
return [...current, value]
})
setStore("tab", store.tab + 1)
setStore("editing", false)
return
}
pick(value, true)
const back = () => {
if (store.sending) return
if (store.tab <= 0) return
setStore("tab", store.tab - 1)
setStore("editing", false)
}
const jump = (tab: number) => {
if (store.sending) return
setStore("tab", tab)
setStore("editing", false)
}
return (
<div data-component="question-prompt">
<Show when={!single()}>
<div data-slot="question-tabs">
<div data-component="question-prompt" ref={(el) => (root = el)}>
<div data-slot="question-body">
<div data-slot="question-header">
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-progress">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
{(_, i) => (
<button
data-slot="question-tab"
data-active={active()}
data-answered={answered()}
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending}
onClick={() => selectTab(index())}
>
{q.header}
</button>
)
}}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
</div>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
@ -181,106 +269,156 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-picked={customPicked()}
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<Show when={!store.editing && input()}>
<span data-slot="option-description">{input()}</span>
</Show>
<Show when={customPicked()}>
<Icon name="check-small" size="normal" />
</Show>
<span data-slot="option-description">
{input() || language.t("ui.question.custom.placeholder")}
</span>
</span>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (store.sending) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
setStore("custom", store.tab, e.currentTarget.value)
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</span>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
<div data-slot="question-footer">
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
{language.t("ui.common.dismiss")}
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
>
{language.t("ui.common.next")}
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</Show>
</Show>
</div>
</div>
</div>
)

View File

@ -1,5 +1,5 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
interface SessionContextUsageProps {
variant?: "button" | "indicator"
placement?: TooltipProps["placement"]
}
function openSessionContext(args: {
@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (tabs().active() === "context") {
tabs().close("context")
return
}
openSessionContext({
view: view(),
layout,
@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return (
<Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top">
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}>

View File

@ -0,0 +1,208 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="block"
>
<circle
cx="6"
cy="6"
r="3"
style={{
animation: "var(--animate-pulse-scale)",
"transform-origin": "center",
"transform-box": "fill-box",
}}
/>
</svg>
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const active = createMemo(
() =>
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
props.todos[0],
)
const preview = createMemo(() => active()?.content ?? "")
return (
<div
classList={{
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
"h-[78px]": store.collapsed,
}}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
)
}
function TodoList(props: { todos: Todo[]; open: boolean }) {
const [stuck, setStuck] = createSignal(false)
const [scrolling, setScrolling] = createSignal(false)
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (scrolling()) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStuck(scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStuck(e.currentTarget.scrollTop > 0)
setScrolling(true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setScrolling(false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<For each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
>
<span
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</Checkbox>
)}
</For>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
style={{
background: "linear-gradient(to bottom, var(--background-base), transparent)",
opacity: stuck() ? 1 : 0,
}}
/>
</div>
)
}

View File

@ -372,7 +372,7 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
@ -552,14 +552,14 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="flex items-center gap-3 ml-2 shrink-0">
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
@ -568,7 +568,7 @@ export function SessionHeader() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
@ -578,18 +578,18 @@ export function SessionHeader() {
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<div class="hidden lg:block shrink-0">
<div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
@ -598,7 +598,7 @@ export function SessionHeader() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
@ -608,30 +608,47 @@ export function SessionHeader() {
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<div class="hidden lg:block shrink-0">
<div class="hidden md:block shrink-0">
<div
aria-hidden={!view().reviewPanel.opened()}
class="overflow-hidden transition-[width,margin-left] duration-200 ease-out motion-reduce:transition-none"
classList={{
"w-8 ml-0": view().reviewPanel.opened(),
"w-0 -ml-1": !view().reviewPanel.opened(),
}}
>
<div
class="transition-[opacity,transform] duration-200 ease-out origin-center motion-reduce:transition-none"
classList={{
"opacity-100 scale-100": view().reviewPanel.opened(),
"opacity-0 scale-90": !view().reviewPanel.opened(),
}}
>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="group/file-tree-toggle size-6 p-0"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
disabled={!view().reviewPanel.opened()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
tabIndex={view().reviewPanel.opened() ? undefined : -1}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
@ -642,6 +659,8 @@ export function SessionHeader() {
</TooltipKeybind>
</div>
</div>
</div>
</div>
</Portal>
)}
</Show>

View File

@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
interface NewSessionViewProps {
worktree: string

View File

@ -196,11 +196,12 @@ export function StatusPopover() {
triggerProps={{
variant: "ghost",
class:
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
style: { scale: 1 },
}}
trigger={
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-0.5">
<div class="size-4 flex items-center justify-center">
<div
classList={{
"size-1.5 rounded-full": true,
@ -209,6 +210,7 @@ export function StatusPopover() {
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
</div>
}

View File

@ -1,6 +1,6 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
@ -43,6 +43,7 @@ export function Titlebar() {
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@ -171,9 +172,10 @@ export function Titlebar() {
<IconButton
icon="menu"
variant="ghost"
class="size-8 rounded-md"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
@ -182,13 +184,14 @@ export function Titlebar() {
<IconButton
icon="menu"
variant="ghost"
class="size-8 rounded-md"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-3 shrink-0">
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
@ -197,7 +200,7 @@ export function Titlebar() {
>
<Button
variant="ghost"
class="group/sidebar-toggle size-6 p-0"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
@ -205,24 +208,44 @@ export function Titlebar() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"}
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
class="group-hover/sidebar-toggle:hidden"
/>
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon
size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"}
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center gap-1 shrink-0">
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon="new-session"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
/>
</TooltipKeybind>
</Show>
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-left"
class="size-6 p-0"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
@ -231,8 +254,8 @@ export function Titlebar() {
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-right"
class="size-6 p-0"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
@ -240,6 +263,7 @@ export function Titlebar() {
</Tooltip>
</div>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>

View File

@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id

View File

@ -4,6 +4,7 @@ import {
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@ -41,6 +42,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@ -87,12 +91,27 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
@ -288,6 +307,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@ -358,6 +378,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
todo: {
set: setSessionTodo,
},
}
}

View File

@ -6,6 +6,7 @@ import {
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@ -20,6 +21,9 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config

View File

@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {

View File

@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},

View File

@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
@ -447,6 +448,9 @@ export const dict = {
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",

View File

@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell",
@ -450,6 +451,9 @@ export const dict = {
"session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",

View File

@ -224,6 +224,7 @@ export const dict = {
"prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
"prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
"prompt.mode.shell": "Shell",
@ -505,6 +506,9 @@ export const dict = {
"session.messages.jumpToLatest": "Idi na najnovije",
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",

View File

@ -222,6 +222,7 @@ export const dict = {
"prompt.placeholder.shell": "Indtast shell-kommando...",
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
"prompt.placeholder.simple": "Spørg om hvad som helst...",
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
"prompt.mode.shell": "Shell",
@ -500,6 +501,9 @@ export const dict = {
"session.messages.jumpToLatest": "Gå til seneste",
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",

View File

@ -211,6 +211,7 @@ export const dict = {
"common.attachment": "Anhang",
"prompt.placeholder.shell": "Shell-Befehl eingeben...",
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
"prompt.placeholder.simple": "Fragen Sie alles...",
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
"prompt.mode.shell": "Shell",
@ -458,6 +459,9 @@ export const dict = {
"session.messages.loading": "Lade Nachrichten...",
"session.messages.jumpToLatest": "Zum neuesten springen",
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen",

View File

@ -224,6 +224,7 @@ export const dict = {
"prompt.placeholder.shell": "Enter shell command...",
"prompt.placeholder.normal": 'Ask anything... "{{example}}"',
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.summarizeComments": "Summarize comments…",
"prompt.placeholder.summarizeComment": "Summarize comment…",
"prompt.mode.shell": "Shell",
@ -266,7 +267,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Attach file",
"prompt.action.attachFile": "Add file",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
@ -504,6 +505,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",

View File

@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "Introduce comando de shell...",
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
"prompt.placeholder.simple": "Pregunta cualquier cosa...",
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
"prompt.placeholder.summarizeComment": "Resumir comentario…",
"prompt.mode.shell": "Shell",
@ -506,6 +507,9 @@ export const dict = {
"session.messages.jumpToLatest": "Ir al último",
"session.context.addToContext": "Añadir {{selection}} al contexto",
"session.todo.title": "Tareas",
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",

View File

@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "pièce jointe",
"prompt.placeholder.shell": "Entrez une commande shell...",
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
"prompt.placeholder.simple": "Demandez n'importe quoi...",
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
"prompt.mode.shell": "Shell",
@ -456,6 +457,9 @@ export const dict = {
"session.messages.loading": "Chargement des messages...",
"session.messages.jumpToLatest": "Aller au dernier",
"session.context.addToContext": "Ajouter {{selection}} au contexte",
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail",

View File

@ -205,6 +205,7 @@ export const dict = {
"common.attachment": "添付ファイル",
"prompt.placeholder.shell": "シェルコマンドを入力...",
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
"prompt.placeholder.simple": "何でも聞いてください...",
"prompt.placeholder.summarizeComments": "コメントを要約…",
"prompt.placeholder.summarizeComment": "コメントを要約…",
"prompt.mode.shell": "シェル",
@ -448,6 +449,9 @@ export const dict = {
"session.messages.loading": "メッセージを読み込み中...",
"session.messages.jumpToLatest": "最新へジャンプ",
"session.context.addToContext": "{{selection}}をコンテキストに追加",
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成",

View File

@ -209,6 +209,7 @@ export const dict = {
"common.attachment": "첨부 파일",
"prompt.placeholder.shell": "셸 명령어 입력...",
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
"prompt.placeholder.simple": "무엇이든 물어보세요...",
"prompt.placeholder.summarizeComments": "댓글 요약…",
"prompt.placeholder.summarizeComment": "댓글 요약…",
"prompt.mode.shell": "셸",
@ -450,6 +451,9 @@ export const dict = {
"session.messages.loading": "메시지 로드 중...",
"session.messages.jumpToLatest": "최신으로 이동",
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
"session.new.worktree.create": "새 작업 트리 생성",

View File

@ -226,6 +226,7 @@ export const dict = {
"prompt.placeholder.shell": "Skriv inn shell-kommando...",
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
"prompt.placeholder.simple": "Spør om hva som helst...",
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
"prompt.mode.shell": "Shell",
@ -506,6 +507,9 @@ export const dict = {
"session.messages.jumpToLatest": "Hopp til nyeste",
"session.context.addToContext": "Legg til {{selection}} i kontekst",
"session.todo.title": "Oppgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",

View File

@ -207,6 +207,7 @@ export const dict = {
"common.attachment": "załącznik",
"prompt.placeholder.shell": "Wpisz polecenie terminala...",
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
"prompt.placeholder.simple": "Zapytaj o cokolwiek...",
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
"prompt.mode.shell": "Terminal",
@ -449,6 +450,9 @@ export const dict = {
"session.messages.loading": "Ładowanie wiadomości...",
"session.messages.jumpToLatest": "Przejdź do najnowszych",
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze",

View File

@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "Введите команду оболочки...",
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
"prompt.placeholder.simple": "Спросите что угодно...",
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
"prompt.mode.shell": "Оболочка",
@ -504,6 +505,9 @@ export const dict = {
"session.messages.jumpToLatest": "Перейти к последнему",
"session.context.addToContext": "Добавить {{selection}} в контекст",
"session.todo.title": "Задачи",
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",

View File

@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
"prompt.placeholder.simple": "ถามอะไรก็ได้...",
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
"prompt.mode.shell": "เชลล์",
@ -501,6 +502,9 @@ export const dict = {
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
"session.todo.title": "สิ่งที่ต้องทำ",
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",

View File

@ -244,6 +244,7 @@ export const dict = {
"prompt.placeholder.shell": "输入 shell 命令...",
"prompt.placeholder.normal": '随便问点什么... "{{example}}"',
"prompt.placeholder.simple": "随便问点什么...",
"prompt.placeholder.summarizeComments": "总结评论…",
"prompt.placeholder.summarizeComment": "总结该评论…",
"prompt.mode.shell": "Shell",
@ -500,6 +501,9 @@ export const dict = {
"session.messages.loading": "正在加载消息...",
"session.messages.jumpToLatest": "跳转到最新",
"session.context.addToContext": "将 {{selection}} 添加到上下文",
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}}",
"session.new.worktree.create": "创建新的 worktree",

View File

@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "輸入 shell 命令...",
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
"prompt.placeholder.simple": "隨便問點什麼...",
"prompt.placeholder.summarizeComments": "摘要評論…",
"prompt.placeholder.summarizeComment": "摘要這則評論…",
"prompt.mode.shell": "Shell",
@ -497,6 +498,9 @@ export const dict = {
"session.messages.jumpToLatest": "跳到最新",
"session.context.addToContext": "將 {{selection}} 新增到上下文",
"session.todo.title": "待辦事項",
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",

View File

@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
"flex-1 min-w-0": panelProps.mobile,
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
@ -1725,8 +1725,8 @@ export default function Layout(props: ParentProps) {
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate"
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
@ -2042,7 +2042,7 @@ export default function Layout(props: ParentProps) {
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>

View File

@ -51,7 +51,7 @@ export const SidebarContent = (props: {
>
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider>
@ -78,7 +78,7 @@ export const SidebarContent = (props: {
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"

View File

@ -5,7 +5,6 @@ import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
@ -20,9 +19,11 @@ import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
@ -34,6 +35,7 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
@ -89,6 +91,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
@ -99,6 +102,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
@ -229,7 +233,7 @@ export default function Page() {
})
}
const isDesktop = createMediaQuery("(min-width: 1024px)")
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@ -269,7 +273,6 @@ export default function Page() {
if (!path) return
file.load(path)
openReviewPanel()
tabs().setActive(next)
}
createEffect(() => {
@ -554,13 +557,11 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
promptHeight: 0,
})
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
@ -651,6 +652,7 @@ export default function Page() {
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
@ -673,7 +675,8 @@ export default function Page() {
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
void sync.session.sync(id)
void sync.session.todo(id)
})
createEffect(() => {
@ -726,13 +729,17 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@ -751,12 +758,6 @@ export default function Page() {
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@ -767,6 +768,11 @@ export default function Page() {
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@ -806,8 +812,8 @@ export default function Page() {
return
}
// Don't autofocus chat if terminal panel is open
if (view().terminal.opened()) return
// Don't autofocus chat if desktop terminal panel is open
if (isDesktop() && view().terminal.opened()) return
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@ -905,11 +911,29 @@ export default function Page() {
const focusInput = () => inputRef?.focus()
useSessionCommands({
activeMessage,
command,
dialog,
file,
language,
local,
permission,
prompt,
sdk,
sync,
terminal,
layout,
params,
navigate,
tabs,
view,
info,
status,
userMessages,
visibleUserMessages,
showAllFiles,
navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
addSelectionToContext,
focusInput,
})
@ -933,7 +957,6 @@ export default function Page() {
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="large"
triggerStyle={{ "font-size": "var(--font-size-large)" }}
/>
)
@ -1421,12 +1444,12 @@ export default function Page() {
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
if (next === dockHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
dockHeight = next
if (stick && el) {
requestAnimationFrame(() => {
@ -1524,13 +1547,7 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
<div
class="flex-1 min-h-0 flex"
classList={{
"flex-col": !isDesktop(),
"flex-row": isDesktop(),
}}
>
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs
open={!isDesktop() && !!params.id}
mobileTab={store.mobileTab}
@ -1545,12 +1562,11 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 pt-2 md:pt-3": true,
"flex-1": true,
"md:flex-none": desktopSidePanelOpen(),
}}
style={{
width: sessionPanelWidth(),
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
@ -1562,7 +1578,7 @@ export default function Page() {
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
root: "pb-8",
header: "px-4",
container: "px-4",
},
@ -1627,8 +1643,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>
@ -1659,6 +1673,7 @@ export default function Page() {
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
@ -1731,7 +1746,7 @@ export default function Page() {
</div>
<TerminalPanel
open={view().terminal.opened()}
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}

View File

@ -4,10 +4,10 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@ -88,8 +88,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@ -100,7 +98,7 @@ export function MessageTimeline(props: {
>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
@ -164,14 +162,15 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"px-4 md:px-6": true,
"pb-4": true,
"pl-2 pr-4 md:pl-4 md:pr-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-10 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={props.parentID}>
<IconButton
tabIndex={-1}
@ -185,7 +184,10 @@ export function MessageTimeline(props: {
<Show
when={props.titleState.editing}
fallback={
<h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={props.openTitleEditor}
>
{props.title}
</h1>
}
@ -194,7 +196,8 @@ export function MessageTimeline(props: {
ref={props.titleRef}
value={props.titleState.draft}
disabled={props.titleState.saving}
class="text-16-medium text-text-strong grow-1 min-w-0"
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
@ -215,9 +218,14 @@ export function MessageTimeline(props: {
</div>
<Show when={props.sessionID}>
{(id) => (
<div class="shrink-0 flex items-center">
<DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
<Tooltip value={props.t("common.moreOptions")} placement="top">
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={props.titleState.menuOpen}
onOpenChange={props.onTitleMenuOpen}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
@ -225,9 +233,9 @@ export function MessageTimeline(props: {
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={props.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!props.titleState.pendingRename) return
event.preventDefault()
@ -263,7 +271,7 @@ export function MessageTimeline(props: {
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@ -316,8 +324,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@ -1,16 +1,17 @@
import { For, Show } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
import { SessionTodoDock } from "@/components/session-todo-dock"
export function SessionPromptDock(props: {
centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
@ -22,10 +23,88 @@ export function SessionPromptDock(props: {
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const done = createMemo(
() =>
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(props.todos.length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
let raf: number | undefined
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setDock(false)
setClosing(false)
timer = undefined
}, 400)
}
createEffect(
on(
() => [props.todos.length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setDock(false)
setClosing(false)
setOpening(false)
return
}
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const wasHidden = !dock() || closing()
setDock(true)
setClosing(false)
if (wasHidden) {
setOpening(true)
raf = requestAnimationFrame(() => {
setOpening(false)
raf = undefined
})
return
}
setOpening(false)
return
}
if (prev && prev[1]) {
if (closing() && !timer) scheduleClose()
return
}
setDock(true)
setOpening(false)
setClosing(true)
scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return (
<div
ref={props.setPromptDockRef}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
data-component="session-prompt-dock"
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
@ -35,18 +114,8 @@ export function SessionPromptDock(props: {
>
<Show when={props.questionRequest()} keyed>
{(req) => {
const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
return (
<div data-component="tool-part-wrapper" data-question="true" class="mb-3">
<BasicTool
icon="bubble-5"
locked
defaultOpen
trigger={{
title: props.t("ui.tool.questions"),
subtitle,
}}
/>
<div>
<QuestionDock request={req} />
</div>
)
@ -121,6 +190,32 @@ export function SessionPromptDock(props: {
{props.handoffPrompt || props.t("prompt.loading")}
</div>
}
>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !closing(),
"max-h-0 pointer-events-none": closing(),
"opacity-0 translate-y-9": closing() || opening(),
"opacity-100 translate-y-0": !closing() && !opening(),
}}
>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": dock() && !closing(),
"mt-0": !dock() || closing(),
}}
>
<PromptInput
ref={props.inputRef}
@ -128,6 +223,7 @@ export function SessionPromptDock(props: {
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>

View File

@ -22,11 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
activeMessage: () => UserMessage | undefined
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@ -37,88 +55,45 @@ const withCategory = (category: string) => {
})
}
export const useSessionCommands = (args: SessionCommandContext) => {
const command = useCommand()
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
export const useSessionCommands = (input: SessionCommandContext) => {
const sessionCommand = withCategory(input.language.t("command.category.session"))
const fileCommand = withCategory(input.language.t("command.category.file"))
const contextCommand = withCategory(input.language.t("command.category.context"))
const viewCommand = withCategory(input.language.t("command.category.view"))
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const modelCommand = withCategory(input.language.t("command.category.model"))
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const agentCommand = withCategory(input.language.t("command.category.agent"))
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
title: input.language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
onSelect: () => input.navigate(`/${input.params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
title: input.language.t("command.tab.close"),
keybind: "mod+w",
disabled: !tabs().active(),
disabled: !input.tabs().active(),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
tabs().close(active)
input.tabs().close(active)
},
}),
])
@ -126,30 +101,30 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
active: input.tabs().active(),
pathFromTab: input.file.pathFromTab,
selectedLines: input.file.selectedLines,
}),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
const path = file.pathFromTab(active)
const path = input.file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
title: input.language.t("toast.context.noLineSelection.title"),
description: input.language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
input.addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@ -157,50 +132,37 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
title: input.language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
onSelect: () => input.view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
title: input.language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
onSelect: () => input.view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
title: input.language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
onSelect: () => input.layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
title: input.language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => args.focusInput(),
onSelect: () => input.focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
viewCommand({
id: "steps.toggle",
title: language.t("command.steps.toggle"),
description: language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = args.activeMessage()
if (!msg) return
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
},
}),
])
@ -208,61 +170,61 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(-1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
onSelect: () => input.local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
onSelect: () => input.local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
input.local.model.variant.cycle()
},
}),
])
@ -271,22 +233,22 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.title")
: input.language.t("toast.permissions.autoaccept.off.title"),
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.description")
: input.language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@ -295,71 +257,71 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
if (input.status()?.type !== "idle") {
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
const revert = input.info()?.revert?.messageID
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = input.sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
input.prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
args.setActiveMessage(priorMessage)
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
const revertMessageID = input.info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
args.setActiveMessage(lastMsg)
await input.sdk.client.session.unrevert({ sessionID })
input.prompt.reset()
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
input.setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
args.setActiveMessage(priorMsg)
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const model = local.model.current()
const model = input.local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
title: input.language.t("toast.model.none.title"),
description: input.language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
await input.sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@ -368,27 +330,29 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (sync.data.config.share === "disabled") return []
if (input.sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
disabled: !input.params.id,
onSelect: async () => {
if (!params.id) return
if (!input.params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@ -418,7 +382,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@ -426,27 +390,27 @@ export const useSessionCommands = (args: SessionCommandContext) => {
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = info()?.share?.url
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@ -457,25 +421,25 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
if (!input.params.id) return
await input.sdk.client.session
.unshare({ sessionID: input.params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
title: input.language.t("toast.session.unshare.success.title"),
description: input.language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
title: input.language.t("toast.session.unshare.failed.title"),
description: input.language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@ -484,7 +448,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
]
})
command.register("session", () =>
input.command.register("session", () =>
[
sessionCommands(),
fileCommands(),
@ -495,6 +459,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionCommands(),
sessionActionCommands(),
shareCommands(),
].flatMap((section) => section),
].flatMap((x) => x),
)
}

View File

@ -224,7 +224,6 @@ export default function () {
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
expandedSteps: {} as Record<string, boolean>,
})
const messages = createMemo(() =>
data().sessionID
@ -296,10 +295,7 @@ export default function () {
{(message) => (
<SessionTurn
sessionID={data().sessionID}
sessionTitle={info().title}
messageID={message.id}
stepsExpanded={store.expandedSteps[message.id] ?? false}
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
@ -375,13 +371,6 @@ export default function () {
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
stepsExpanded={
store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
}
onStepsExpandedToggle={() => {
const id = store.messageId ?? firstUserMessage()!.id!
setStore("expandedSteps", id, (v) => !v)
}}
classes={{
root: "grow",
content: "flex flex-col justify-between",

View File

@ -4,15 +4,44 @@
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
justify-content: space-between;
gap: 0px;
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
align-self: stretch;
gap: 20px;
gap: 8px;
}
[data-slot="basic-tool-tool-indicator"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="basic-tool-tool-spinner"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="icon-svg"] {
@ -20,16 +49,17 @@
}
[data-slot="basic-tool-tool-info"] {
flex-grow: 1;
flex: 0 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: 100%;
width: auto;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
justify-content: flex-start;
}
[data-slot="basic-tool-tool-info-main"] {
@ -43,16 +73,21 @@
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
@ -62,12 +97,12 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
&.clickable {
cursor: pointer;
@ -78,6 +113,26 @@
color: var(--text-base);
}
}
&.subagent-link {
color: var(--text-interactive-base);
text-decoration: none;
text-underline-offset: 2px;
font-weight: var(--font-weight-regular);
&:hover {
color: var(--text-interactive-base);
text-decoration: underline;
}
&:active {
color: var(--text-interactive-base);
}
&:visited {
color: var(--text-interactive-base);
}
}
}
[data-slot="basic-tool-tool-arg"] {
@ -87,11 +142,11 @@
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
color: var(--text-base);
}
}

View File

@ -1,6 +1,7 @@
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { Collapsible } from "./collapsible"
import { Icon, IconProps } from "./icon"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
export type TriggerTitle = {
title: string
@ -22,6 +23,7 @@ export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
@ -31,22 +33,23 @@ export interface BasicToolProps {
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const pending = () => props.status === "pending" || props.status === "running"
createEffect(() => {
if (props.forceOpen) setOpen(true)
})
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setOpen(value)
}
return (
<Collapsible open={open()} onOpenChange={handleOpenChange}>
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<Icon name={props.icon} size="small" />
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
@ -59,8 +62,11 @@ export function BasicTool(props: BasicToolProps) {
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
{trigger().title}
<Show when={pending()} fallback={trigger().title}>
<TextShimmer text={trigger().title} />
</Show>
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
@ -92,8 +98,9 @@ export function BasicTool(props: BasicToolProps) {
)}
</For>
</Show>
</Show>
</div>
<Show when={trigger().action}>{trigger().action}</Show>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
@ -101,7 +108,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked}>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
@ -113,6 +120,6 @@ export function BasicTool(props: BasicToolProps) {
)
}
export function GenericTool(props: { tool: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) {
return <BasicTool icon="mcp" status={props.status} trigger={{ title: props.tool }} hideDetails={props.hideDetails} />
}

View File

@ -170,3 +170,15 @@
outline: none;
}
}
[data-component="button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
background-color: var(--surface-base-active);
}

View File

@ -1,6 +1,6 @@
[data-component="checkbox"] {
display: flex;
align-items: center;
align-items: var(--checkbox-align, center);
gap: 12px;
cursor: default;
@ -23,6 +23,7 @@
width: 16px;
height: 16px;
padding: 2px;
margin-top: var(--checkbox-offset, 0px);
aspect-ratio: 1;
flex-shrink: 0;
border-radius: var(--radius-sm);

View File

@ -2,23 +2,44 @@
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--surface-inset-base);
border: 1px solid var(--border-weaker-base);
background-color: transparent;
border: none;
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
overflow: clip;
&.tool-collapsible {
gap: 8px;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
padding: 6px 8px 6px 12px;
padding: 0;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
color: var(--text-base);
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
}
[data-slot="collapsible-arrow-icon"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: inline-flex;
}
&:hover [data-slot="collapsible-arrow"] {
opacity: 1;
}
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@ -48,6 +69,20 @@
}
}
[data-slot="collapsible-trigger"][aria-expanded="true"] {
[data-slot="collapsible-arrow"] {
opacity: 1;
}
[data-slot="collapsible-arrow-icon"][data-direction="right"] {
display: none;
}
[data-slot="collapsible-arrow-icon"][data-direction="down"] {
display: inline-flex;
}
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */

View File

@ -34,7 +34,12 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<Icon name="chevron-grabber-vertical" size="small" />
<span data-slot="collapsible-arrow-icon" data-direction="right">
<Icon name="chevron-right" size="small" />
</span>
<span data-slot="collapsible-arrow-icon" data-direction="down">
<Icon name="chevron-down" size="small" />
</span>
</div>
)
}

View File

@ -7,7 +7,7 @@
[data-slot="diff-changes-additions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
@ -19,7 +19,7 @@
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
@ -31,11 +31,12 @@
[data-component="diff-changes"][data-variant="bars"] {
width: 18px;
height: 14px;
flex-shrink: 0;
svg {
display: block;
width: 100%;
height: auto;
height: 100%;
}
}

View File

@ -96,10 +96,10 @@ export function DiffChanges(props: {
<div data-component="diff-changes" data-variant={variant()} classList={{ [props.class ?? ""]: true }}>
<Switch>
<Match when={variant() === "bars"}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 14" fill="none">
<g>
<For each={visibleBlocks()}>
{(color, i) => <rect x={i() * 4} width="2" height="12" rx="1" fill={color} />}
{(color, i) => <rect x={i() * 4} width="2" height="14" rx="1" fill={color} />}
</For>
</g>
</svg>

View File

@ -143,3 +143,39 @@
outline: none;
}
}
@media (prefers-reduced-motion: no-preference) {
[data-component="icon-button"][data-icon="stop"] [data-slot="icon-svg"] rect {
transform-origin: center;
transform-box: fill-box;
animation: stop-pulse 1.8s ease-in-out infinite;
}
}
@keyframes stop-pulse {
0%,
100% {
transform: scale(0.95);
}
50% {
transform: scale(1.12);
}
}
[data-component="icon-button"].titlebar-icon {
width: 32px;
height: 24px;
aspect-ratio: auto;
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-base-active);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
background-color: var(--surface-base-active);
}

View File

@ -15,6 +15,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
<Kobalte
{...rest}
data-component="icon-button"
data-icon={props.icon}
data-size={split.size || "normal"}
data-variant={split.variant || "secondary"}
classList={{

View File

@ -7,11 +7,13 @@ const icons = {
"arrow-right": `<path d="M11.6654 4.58398L17.082 10.0007L11.6654 15.4173M16.6654 10.0007H2.91536" stroke="currentColor" stroke-linecap="square"/>`,
archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-left": `<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8 15L13 10L8 5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-double-right": `<path d="M11.6654 13.3346L14.9987 10.0013L11.6654 6.66797M5.83203 13.3346L9.16536 10.0013L5.83203 6.66797" stroke="currentColor" stroke-linecap="square"/>`,
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
@ -28,9 +30,12 @@ const icons = {
eye: `<path d="M10 4.58325C5.83333 4.58325 2.5 9.99992 2.5 9.99992C2.5 9.99992 5.83333 15.4166 10 15.4166C14.1667 15.4166 17.5 9.99992 17.5 9.99992C17.5 9.99992 14.1667 4.58325 10 4.58325Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.5" stroke="currentColor"/>`,
enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`,
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
"file-tree": `<path d="M4.58203 16.6693L6.66536 9.58594H17.082M4.58203 16.6693H16.457L18.5404 9.58594H17.082M4.58203 16.6693H2.08203V3.33594H8.33203L9.9987 5.83594H17.082V9.58594" stroke="currentColor" stroke-linecap="round"/>`,
"file-tree-active": `<path d="M6.66536 9.58594L4.58203 16.6693H16.457L18.5404 9.58594H17.082H6.66536Z" fill="currentColor" fill-opacity="40%"/><path d="M4.58203 16.6693L6.66536 9.58594H17.082M4.58203 16.6693H16.457L18.5404 9.58594H17.082M4.58203 16.6693H2.08203V3.33594H8.33203L9.9987 5.83594H17.082V9.58594" stroke="currentColor" stroke-linecap="round"/>`,
"magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
"new-session": `<path d="M17.0827 17.0807V17.5807H17.5827V17.0807H17.0827ZM2.91602 17.0807H2.41602L2.41602 17.5807H2.91602L2.91602 17.0807ZM2.91602 2.91406V2.41406H2.41602V2.91406H2.91602ZM9.58268 3.41406H10.0827V2.41406L9.58268 2.41406V2.91406V3.41406ZM17.5827 10.4141V9.91406L16.5827 9.91406V10.4141H17.0827H17.5827ZM6.24935 11.2474L5.8958 10.8938L5.74935 11.0403V11.2474H6.24935ZM6.24935 13.7474H5.74935V14.2474H6.24935V13.7474ZM8.74935 13.7474V14.2474H8.95646L9.1029 14.101L8.74935 13.7474ZM15.2077 2.28906L15.5612 1.93551L15.2077 1.58196L14.8541 1.93551L15.2077 2.28906ZM17.7077 4.78906L18.0612 5.14262L18.4148 4.78906L18.0612 4.43551L17.7077 4.78906ZM17.0827 17.0807V16.5807H2.91602V17.0807L2.91602 17.5807H17.0827V17.0807ZM2.91602 17.0807H3.41602L3.41602 2.91406H2.91602H2.41602L2.41602 17.0807H2.91602ZM2.91602 2.91406V3.41406L9.58268 3.41406V2.91406V2.41406L2.91602 2.41406V2.91406ZM17.0827 10.4141H16.5827V17.0807H17.0827H17.5827V10.4141H17.0827ZM6.24935 11.2474H5.74935V13.7474H6.24935H6.74935V11.2474H6.24935ZM6.24935 13.7474V14.2474L8.74935 14.2474V13.7474V13.2474L6.24935 13.2474V13.7474ZM6.24935 11.2474L6.6029 11.6009L15.5612 2.64262L15.2077 2.28906L14.8541 1.93551L5.8958 10.8938L6.24935 11.2474ZM15.2077 2.28906L14.8541 2.64262L17.3541 5.14262L17.7077 4.78906L18.0612 4.43551L15.5612 1.93551L15.2077 2.28906ZM17.7077 4.78906L17.3541 4.43551L8.3958 13.3938L8.74935 13.7474L9.1029 14.101L18.0612 5.14262L17.7077 4.78906Z" fill="currentColor"/>`,
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
@ -41,9 +46,9 @@ const icons = {
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right": `<path d="M17.0832 2.91699L17.5832 2.91699L17.5832 2.41699L17.0832 2.41699L17.0832 2.91699ZM2.91651 2.91699L2.91651 2.41699L2.41651 2.41699L2.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.4165 17.0837L2.4165 17.5837L2.9165 17.5837L2.9165 17.0837ZM17.0832 17.0837L17.0832 17.5837L17.5832 17.5837L17.5832 17.0837L17.0832 17.0837ZM11.5832 17.0837L11.5832 17.5837L12.5832 17.5837L12.5832 17.0837L12.0832 17.0837L11.5832 17.0837ZM12.5832 2.91699L12.5832 2.41699L11.5832 2.41699L11.5832 2.91699L12.0832 2.91699L12.5832 2.91699ZM17.0832 2.91699L17.0832 2.41699L2.91651 2.41699L2.91651 2.91699L2.91651 3.41699L17.0832 3.41699L17.0832 2.91699ZM2.91651 2.91699L2.41651 2.91699L2.4165 17.0837L2.9165 17.0837L3.4165 17.0837L3.41651 2.91699L2.91651 2.91699ZM2.9165 17.0837L2.9165 17.5837L17.0832 17.5837L17.0832 17.0837L17.0832 16.5837L2.9165 16.5837L2.9165 17.0837ZM17.0832 17.0837L17.5832 17.0837L17.5832 2.91699L17.0832 2.91699L16.5832 2.91699L16.5832 17.0837L17.0832 17.0837ZM12.0832 17.0837L12.5832 17.0837L12.5832 2.91699L12.0832 2.91699L11.5832 2.91699L11.5832 17.0837L12.0832 17.0837Z" fill="currentColor"/>`,
"layout-right-partial": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right-full": `<path d="M12.0827 2.91602L2.91602 2.91602L2.91602 17.0827L12.0827 17.0827L12.0827 2.91602Z" fill="currentColor"/><path d="M2.91602 2.91602L17.0827 2.91602L17.0827 17.0827L2.91602 17.0827M2.91602 2.91602L2.91602 17.0827M2.91602 2.91602L12.0827 2.91602L12.0827 17.0827L2.91602 17.0827" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right": `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="40%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,

View File

@ -12,6 +12,6 @@
&:focus {
outline: none;
box-shadow: 0 0 0 1px var(--border-interactive-focus);
box-shadow: var(--inline-input-shadow, 0 0 0 1px var(--border-interactive-focus));
}
}

View File

@ -6,6 +6,17 @@ export type InlineInputProps = ComponentProps<"input"> & {
}
export function InlineInput(props: InlineInputProps) {
const [local, others] = splitProps(props, ["class", "width"])
return <input data-component="inline-input" class={local.class} style={{ width: local.width }} {...others} />
const [local, others] = splitProps(props, ["class", "width", "style"])
const style = () => {
if (!local.style) return { width: local.width }
if (typeof local.style === "string") {
if (!local.width) return local.style
return `${local.style};width:${local.width}`
}
if (!local.width) return local.style
return { ...local.style, width: local.width }
}
return <input data-component="inline-input" class={local.class} style={style()} {...others} />
}

View File

@ -3,7 +3,7 @@
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: var(--line-height-x-large);
@ -117,7 +117,7 @@
.shiki {
font-size: 13px;
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
}
@ -127,11 +127,55 @@
[data-slot="markdown-copy-button"] {
position: absolute;
top: 8px;
right: 8px;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
&::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-sm);
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
opacity: 0;
transition: opacity 0.15s ease;
}
}
[data-slot="markdown-copy-button"]:hover::after,
[data-slot="markdown-copy-button"]:focus-visible::after {
opacity: 1;
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {

View File

@ -85,7 +85,7 @@ function createCopyButton(labels: CopyLabels) {
button.setAttribute("data-size", "small")
button.setAttribute("data-slot", "markdown-copy-button")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
button.appendChild(createIcon(iconPaths.check, "check-icon"))
return button
@ -95,12 +95,12 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
if (copied) {
button.setAttribute("data-copied", "true")
button.setAttribute("aria-label", labels.copied)
button.setAttribute("title", labels.copied)
button.setAttribute("data-tooltip", labels.copied)
return
}
button.removeAttribute("data-copied")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("title", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
}
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {

View File

@ -14,15 +14,27 @@
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
color: var(--text-strong);
display: flex;
flex-direction: column;
align-items: flex-end;
align-self: stretch;
width: 100%;
max-width: 100%;
gap: 8px;
&[data-interrupted] {
color: var(--text-weak);
}
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
}
[data-slot="user-message-attachment"] {
@ -71,15 +83,24 @@
}
}
[data-slot="user-message-body"] {
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
}
[data-slot="user-message-text"] {
position: relative;
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
background: var(--surface-weak);
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
padding: 8px 12px;
border-radius: 4px;
border-radius: 6px;
[data-highlight="file"] {
color: var(--syntax-property);
@ -89,19 +110,36 @@
color: var(--syntax-type);
}
[data-slot="user-message-copy-wrapper"] {
position: absolute;
top: 7px;
right: 7px;
opacity: 0;
transition: opacity 0.15s ease;
max-width: 100%;
}
&:hover [data-slot="user-message-copy-wrapper"] {
opacity: 1;
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="user-message-copy-wrapper"][data-interrupted] {
gap: 12px;
}
&:hover [data-slot="user-message-copy-wrapper"],
&:focus-within [data-slot="user-message-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--text-strong);
}
@ -115,21 +153,36 @@
width: 100%;
[data-slot="text-part-body"] {
position: relative;
margin-top: 32px;
margin-top: 0;
}
[data-slot="text-part-copy-wrapper"] {
position: absolute;
top: -28px;
right: 8px;
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 1;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] {
[data-slot="text-part-copy-wrapper"][data-interrupted] {
width: 100%;
justify-content: flex-end;
gap: 12px;
}
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
@ -146,7 +199,7 @@
[data-component="markdown"] {
margin-top: 24px;
font-style: italic !important;
font-style: normal;
p:has(strong) {
margin-top: 24px;
@ -196,7 +249,8 @@
[data-component="tool-output"] {
white-space: pre;
padding: 8px 12px;
padding: 0;
margin-bottom: 24px;
height: fit-content;
display: flex;
flex-direction: column;
@ -238,6 +292,79 @@
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]),
[data-slot="collapsible-content"]:has([data-component="write-content"]),
[data-slot="collapsible-content"]:has([data-component="apply-patch-files"]) {
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
overflow: hidden;
}
[data-component="bash-output"] {
width: 100%;
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
position: relative;
overflow: hidden;
[data-slot="bash-copy"] {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="bash-copy"],
&:focus-within [data-slot="bash-copy"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-slot="bash-scroll"] {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
max-height: 240px;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="bash-pre"] {
margin: 0;
padding: 12px;
}
[data-slot="bash-pre"] code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: var(--line-height-large);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
}
[data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"],
[data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] {
border-top: none;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
@ -258,9 +385,9 @@
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
gap: 8px;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
@ -268,18 +395,37 @@
color: var(--text-base);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
@ -344,12 +490,19 @@
}
[data-component="todos"] {
padding: 10px 12px 24px 48px;
padding: 10px 0 24px 0;
display: flex;
flex-direction: column;
gap: 8px;
[data-component="checkbox"] {
--checkbox-align: flex-start;
--checkbox-offset: 0.5px;
}
[data-slot="message-part-todo-content"] {
line-height: var(--line-height-normal);
&[data-completed="completed"] {
text-decoration: line-through;
color: var(--text-weaker);
@ -357,41 +510,55 @@
}
}
[data-component="task-tools"] {
padding: 8px 12px;
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: flex-start;
gap: 0px;
cursor: pointer;
[data-slot="task-tool-item"] {
[data-slot="context-tool-group-title"] {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="icon-svg"] {
flex-shrink: 0;
color: var(--icon-weak);
}
}
[data-slot="task-tool-title"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-weak);
color: var(--text-strong);
}
[data-slot="task-tool-subtitle"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weaker);
[data-slot="context-tool-group-label"] {
flex-shrink: 0;
}
[data-slot="context-tool-group-summary"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-regular);
color: var(--text-base);
}
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
}
}
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
}
}
@ -549,170 +716,322 @@
}
[data-component="question-prompt"] {
position: relative;
display: flex;
flex-direction: column;
padding: 12px;
background-color: var(--surface-inset-base);
border-radius: 0 0 6px 6px;
gap: 12px;
gap: 0;
min-height: 0;
max-height: var(--question-prompt-max-height, 100dvh);
[data-slot="question-tabs"] {
[data-slot="question-body"] {
display: flex;
gap: 4px;
flex-wrap: wrap;
[data-slot="question-tab"] {
padding: 4px 12px;
font-size: 13px;
border-radius: 4px;
background-color: var(--surface-base);
color: var(--text-base);
border: none;
cursor: pointer;
transition:
color 0.15s,
background-color 0.15s;
&:hover {
background-color: var(--surface-base-hover);
flex-direction: column;
gap: 16px;
flex: 1;
min-height: 0;
padding: 8px 8px 0;
background-color: var(--surface-raised-stronger-non-alpha);
border-radius: 12px;
box-shadow: var(--shadow-xs-border);
overflow: clip;
position: relative;
z-index: 10;
}
&[data-active="true"] {
background-color: var(--surface-raised-base);
[data-slot="question-header"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 10px;
}
&[data-answered="true"] {
[data-slot="question-header-title"] {
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
min-width: 0;
}
[data-slot="question-progress"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
[data-slot="question-progress-segment"] {
width: 16px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
&::after {
content: "";
width: 16px;
height: 2px;
border-radius: 999px;
background-color: var(--icon-weak-base);
transition: background-color 0.2s ease;
}
&[data-active="true"]::after {
background-color: var(--icon-strong-base);
}
&[data-answered="true"]::after {
background-color: var(--icon-interactive-base);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
[data-slot="question-content"] {
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
flex: 1;
min-height: 0;
}
[data-slot="question-text"] {
font-family: var(--font-family-sans);
font-size: 14px;
color: var(--text-base);
line-height: 1.5;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
padding: 0 10px;
}
[data-slot="question-hint"] {
font-family: var(--font-family-sans);
font-size: 13px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-weak);
padding: 0 10px;
}
[data-slot="question-options"] {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
margin-top: 12px;
padding: 1px 1px 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-slot="question-option"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 12px;
background-color: var(--surface-base);
border: 1px solid var(--border-weaker-base);
gap: 12px;
padding: 8px 8px 8px 10px;
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid var(--border-weak-base);
border-radius: 6px;
cursor: pointer;
box-shadow: none;
text-align: left;
width: 100%;
cursor: pointer;
transition:
background-color 0.15s,
border-color 0.15s;
position: relative;
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
background-color: var(--surface-base-hover);
border-color: var(--border-default);
&:hover:not([data-picked="true"]) {
background-color: var(--background-base);
}
&[data-picked="true"] {
[data-component="icon"] {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-strong);
background-color: var(--surface-interactive-weak);
border-color: transparent;
box-shadow: var(--shadow-xs-border-hover);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
[data-slot="question-option-check"] {
display: inline-flex;
transform: translateY(2px);
}
[data-slot="question-option-box"] {
width: 16px;
height: 16px;
padding: 2px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
[data-component="icon"] {
opacity: 0;
color: var(--icon-base);
}
&[data-type="radio"] {
border-radius: 999px;
}
[data-slot="question-option-radio-dot"] {
width: 6px;
height: 6px;
border-radius: 999px;
background-color: var(--background-stronger);
opacity: 0;
}
&[data-picked="true"] {
border-color: var(--icon-interactive-base);
[data-component="icon"] {
opacity: 1;
color: var(--icon-invert-base);
}
&[data-type="checkbox"] {
background-color: var(--icon-interactive-base);
}
&[data-type="radio"] {
background-color: var(--icon-interactive-base);
[data-slot="question-option-radio-dot"] {
opacity: 1;
}
}
}
}
[data-slot="question-option-main"] {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
[data-slot="option-label"] {
font-family: var(--font-family-sans);
font-size: 14px;
color: var(--text-base);
font-weight: 500;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
color: var(--text-strong);
}
[data-slot="option-description"] {
font-size: 12px;
color: var(--text-weak);
}
}
[data-slot="custom-input-form"] {
display: flex;
gap: 8px;
padding: 8px 0;
align-items: stretch;
[data-slot="custom-input"] {
flex: 1;
padding: 8px 12px;
font-family: var(--font-family-sans);
font-size: 14px;
border: 1px solid var(--border-default);
border-radius: 6px;
background-color: var(--surface-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-base);
outline: none;
&:focus {
border-color: var(--border-focus);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="question-option"][data-custom="true"] {
[data-slot="option-description"] {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
}
[data-slot="question-custom"] {
display: flex;
flex-direction: column;
gap: 8px;
}
[data-slot="question-custom-input-wrap"] {
padding-left: 36px;
}
[data-slot="question-custom-input"] {
width: 100%;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
outline: none;
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--text-base);
min-width: 0;
cursor: text;
resize: none;
overflow: hidden;
overflow-wrap: anywhere;
&::placeholder {
color: var(--text-weak);
}
&:focus-visible {
outline: 1px solid var(--border-interactive-base);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
[data-component="button"] {
height: auto;
}
&:disabled {
opacity: 0.6;
}
}
[data-slot="question-review"] {
[data-slot="question-footer"] {
display: flex;
flex-direction: column;
gap: 12px;
[data-slot="review-title"] {
display: none;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 32px 8px 8px;
background-color: var(--background-base);
border: 1px solid var(--border-weak-base);
border-radius: 12px;
overflow: clip;
margin-top: -24px;
position: relative;
z-index: 0;
}
[data-slot="review-item"] {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
[data-slot="review-label"] {
color: var(--text-weak);
}
[data-slot="review-value"] {
color: var(--text-strong);
&[data-answered="false"] {
color: var(--text-weak);
}
}
}
}
[data-slot="question-actions"] {
[data-slot="question-footer-actions"] {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
}
@ -720,7 +1039,7 @@
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 12px;
padding: 8px 0;
[data-slot="question-answer-item"] {
display: flex;
@ -746,18 +1065,13 @@
[data-component="apply-patch-file"] {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-weaker-base);
&:first-child {
border-top: 1px solid var(--border-weaker-base);
}
[data-slot="apply-patch-file-header"] {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--surface-inset-base);
background-color: transparent;
}
[data-slot="apply-patch-file-action"] {
@ -799,7 +1113,12 @@
}
}
[data-component="apply-patch-file"] + [data-component="apply-patch-file"] {
border-top: 1px solid var(--border-weaker-base);
}
[data-component="apply-patch-file-diff"] {
border-top: 1px solid var(--border-weaker-base);
max-height: 420px;
overflow-y: auto;
scrollbar-width: none;

View File

@ -37,18 +37,17 @@ import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "./image-preview"
import { findLast } from "@opencode-ai/util/array"
import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { TextShimmer } from "./text-shimmer"
interface Diagnostic {
range: {
@ -92,6 +91,8 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element {
export interface MessageProps {
message: MessageType
parts: PartType[]
showAssistantCopyPartID?: string | null
interrupted?: boolean
}
export interface MessagePartProps {
@ -99,6 +100,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
showAssistantCopyPartID?: string | null
}
export type PartComponent = Component<MessagePartProps>
@ -107,12 +109,6 @@ export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
const TEXT_RENDER_THROTTLE_MS = 100
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
@ -157,22 +153,6 @@ function getDirectory(path: string | undefined) {
return relativizeProjectPaths(_getDirectory(path), data.directory)
}
export function getSessionToolParts(store: ReturnType<typeof useData>["store"], sessionId: string): ToolPart[] {
const messages = store.message[sessionId]?.filter((m) => m.role === "assistant")
if (!messages) return []
const parts: ToolPart[] = []
for (const m of messages) {
const msgParts = store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p && p.type === "tool") parts.push(p as ToolPart)
}
}
}
return parts
}
import type { IconProps } from "./icon"
export type ToolInfo = {
@ -269,6 +249,86 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
}
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
function isContextGroupTool(part: PartType): part is ToolPart {
return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool)
}
function contextToolDetail(part: ToolPart): string | undefined {
const info = getToolInfo(part.tool, part.state.input ?? {})
if (info.subtitle) return info.subtitle
if (part.state.status === "error") return part.state.error
if ((part.state.status === "running" || part.state.status === "completed") && part.state.title)
return part.state.title
const description = part.state.input?.description
if (typeof description === "string") return description
return undefined
}
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/"
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined
const limit = typeof input.limit === "number" ? input.limit : undefined
switch (part.tool) {
case "read": {
const args: string[] = []
if (offset !== undefined) args.push("offset=" + offset)
if (limit !== undefined) args.push("limit=" + limit)
return {
title: i18n.t("ui.tool.read"),
subtitle: filePath ? getFilename(filePath) : "",
args,
}
}
case "list":
return {
title: i18n.t("ui.tool.list"),
subtitle: getDirectory(path),
}
case "glob":
return {
title: i18n.t("ui.tool.glob"),
subtitle: getDirectory(path),
args: pattern ? ["pattern=" + pattern] : [],
}
case "grep": {
const args: string[] = []
if (pattern) args.push("pattern=" + pattern)
if (include) args.push("include=" + include)
return {
title: i18n.t("ui.tool.grep"),
subtitle: getDirectory(path),
args,
}
}
default: {
const info = getToolInfo(part.tool, input)
return {
title: info.title,
subtitle: info.subtitle || contextToolDetail(part),
args: [],
}
}
}
}
function contextToolSummary(parts: ToolPart[]) {
const read = parts.filter((part) => part.tool === "read").length
const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
const list = parts.filter((part) => part.tool === "list").length
return [
read ? `${read} ${read === 1 ? "read" : "reads"}` : undefined,
search ? `${search} ${search === 1 ? "search" : "searches"}` : undefined,
list ? `${list} ${list === 1 ? "list" : "lists"}` : undefined,
].filter((value): value is string => !!value)
}
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@ -277,51 +337,178 @@ export function Message(props: MessageProps) {
return (
<Switch>
<Match when={props.message.role === "user" && props.message}>
{(userMessage) => <UserMessageDisplay message={userMessage() as UserMessage} parts={props.parts} />}
{(userMessage) => (
<UserMessageDisplay
message={userMessage() as UserMessage}
parts={props.parts}
interrupted={props.interrupted}
/>
)}
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
<AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
<AssistantMessageDisplay
message={assistantMessage() as AssistantMessage}
parts={props.parts}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
</Match>
</Switch>
)
}
export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
const emptyParts: PartType[] = []
const filteredParts = createMemo(
() =>
props.parts.filter((x) => {
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
}),
emptyParts,
{ equals: same },
)
return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
export function AssistantMessageDisplay(props: {
message: AssistantMessage
parts: PartType[]
showAssistantCopyPartID?: string | null
}) {
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
keys.push(key)
items[key] = item
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const parts = props.parts
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.id}`, {
type: "context",
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((part, index) => {
if (isContextGroupTool(part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${part.id}`, { type: "part", part })
})
flush(parts.length - 1)
return { keys, items }
})
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
return (
<Show when={item()}>
{(value) => {
const entry = value()
if (entry.type === "context") return <ContextToolGroup parts={entry.parts} />
return (
<Part
part={entry.part}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)
}}
</Show>
)
}}
</For>
)
}
function ContextToolGroup(props: { parts: ToolPart[] }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(() =>
props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts))
const details = createMemo(() => summary().join(", "))
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<Show
when={pending()}
fallback={
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">Gathered context</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
</span>
}
>
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">
<TextShimmer text={i18n.t("ui.sessionTurn.status.gatheringContext")} />
</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
</span>
</Show>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-component="context-tool-group-list">
<For each={props.parts}>
{(part) => {
const trigger = contextToolTrigger(part, i18n)
const running = part.state.status === "pending" || part.state.status === "running"
return (
<div data-slot="context-tool-group-item">
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={running} fallback={trigger.title}>
<TextShimmer text={trigger.title} />
</Show>
</span>
<Show when={!running && trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
</Show>
<Show when={!running && trigger.args?.length}>
<For each={trigger.args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</Show>
</div>
</div>
</div>
</div>
</div>
</div>
)
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
)
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; interrupted?: boolean }) {
const dialog = useDialog()
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const [expanded, setExpanded] = createSignal(false)
const [canExpand, setCanExpand] = createSignal(false)
let textRef: HTMLDivElement | undefined
const updateCanExpand = () => {
const el = textRef
if (!el) return
if (expanded()) return
setCanExpand(el.scrollHeight > el.clientHeight + 2)
}
createResizeObserver(
() => textRef,
() => {
updateCanExpand()
},
)
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@ -329,11 +516,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
createEffect(() => {
text()
updateCanExpand()
})
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@ -364,13 +546,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
setTimeout(() => setCopied(false), 2000)
}
const toggleExpanded = () => {
if (!canExpand()) return
setExpanded((value) => !value)
}
return (
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
<Show when={attachments().length > 0}>
<div data-slot="user-message-attachments">
<For each={attachments()}>
@ -404,29 +581,25 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</div>
</Show>
<Show when={text()}>
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
<div data-slot="user-message-body">
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
<button
data-slot="user-message-expand"
type="button"
aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")}
onClick={(event) => {
event.stopPropagation()
toggleExpanded()
}}
>
<Icon name="chevron-down" size="small" />
</button>
<div data-slot="user-message-copy-wrapper">
</div>
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
<Show when={props.interrupted}>
<span data-slot="user-message-interrupted" class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.message.interrupted")}
</span>
</Show>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
@ -491,6 +664,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
</Show>
)
@ -536,6 +710,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = props.part as ToolPart
if (part.tool === "todowrite" || part.tool === "todoread") return null
const hideQuestion = createMemo(
() => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"),
)
const permission = createMemo(() => {
const next = data.store.permission?.[props.message.sessionID]?.[0]
@ -604,11 +783,21 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const render = ToolRegistry.render(part.tool) ?? GenericTool
return (
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
<Switch>
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part.tool === "question" && cleaned.includes("dismissed this question")) {
return (
<div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.tool.questions")} dismissed
</span>
</div>
)
}
const [title, ...rest] = cleaned.split(": ")
return (
<Card variant="error">
@ -663,6 +852,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</Show>
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
</div>
</Show>
)
}
@ -670,8 +860,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = props.part as TextPart
const interrupted = createMemo(
() =>
props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError",
)
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
const throttledText = createThrottledValue(displayText)
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
.at(-1)
return last?.id === part.id
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return isLastTextPart()
if (props.showAssistantCopyPartID === null) return false
if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part.id
return isLastTextPart()
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
@ -687,23 +893,30 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part.id} />
<div data-slot="text-part-copy-wrapper">
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
<Show when={interrupted()}>
<span data-slot="text-part-interrupted" class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.message.interrupted")}
</span>
</Show>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
</Show>
</div>
</Show>
)
@ -844,29 +1057,46 @@ ToolRegistry.register({
name: "webfetch",
render(props) {
const i18n = useI18n()
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const url = createMemo(() => {
const value = props.input.url
if (typeof value !== "string") return ""
return value
})
return (
<BasicTool
{...props}
hideDetails
icon="window-cursor"
trigger={{
title: i18n.t("ui.tool.webfetch"),
subtitle: props.input.url || "",
args: props.input.format ? ["format=" + props.input.format] : [],
action: (
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={pending()} fallback={i18n.t("ui.tool.webfetch")}>
<TextShimmer text={i18n.t("ui.tool.webfetch")} />
</Show>
</span>
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url()}
</a>
</Show>
</div>
<Show when={!pending() && url()}>
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
),
}}
>
<Show when={props.output}>
{(output) => (
<div data-component="tool-output" data-scrollable>
<Markdown text={output()} />
</div>
)}
</Show>
</BasicTool>
</div>
}
/>
)
},
})
@ -877,6 +1107,13 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const childSessionId = () => props.metadata.sessionId as string | undefined
const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
const description = createMemo(() => {
const value = props.input.description
if (typeof value === "string") return value
return undefined
})
const running = createMemo(() => props.status === "pending" || props.status === "running")
const href = createMemo(() => {
const sessionId = childSessionId()
@ -892,14 +1129,6 @@ ToolRegistry.register({
return `${path.slice(0, idx)}/session/${sessionId}`
})
createEffect(() => {
const sessionId = childSessionId()
if (!sessionId) return
const sync = data.syncSession
if (!sync) return
Promise.resolve(sync(sessionId)).catch(() => undefined)
})
const handleLinkClick = (e: MouseEvent) => {
const sessionId = childSessionId()
const url = href()
@ -921,23 +1150,30 @@ ToolRegistry.register({
}, 50)
}
const titleContent = () => <TextShimmer text={title()} active={running()} />
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize">
{i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })}
<span data-slot="basic-tool-tool-title" class="capitalize agent-title">
{titleContent()}
</span>
<Show when={props.input.description}>
<Show when={description()}>
<Switch>
<Match when={href()}>
{(url) => (
<a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}>
{props.input.description}
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
href={url()}
onClick={handleLinkClick}
>
{description()}
</a>
)}
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{props.input.description}</span>
<span data-slot="basic-tool-tool-subtitle">{description()}</span>
</Match>
</Switch>
</Show>
@ -945,134 +1181,7 @@ ToolRegistry.register({
</div>
)
const childToolParts = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return []
return getSessionToolParts(data.store, sessionId)
})
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "auto",
})
const childPermission = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return undefined
const permissions = data.store.permission?.[sessionId] ?? []
return permissions[0]
})
const childToolPart = createMemo(() => {
const perm = childPermission()
if (!perm || !perm.tool) return undefined
const sessionId = childSessionId()
if (!sessionId) return undefined
// Find the tool part that matches the permission's callID
const messages = data.store.message[sessionId] ?? []
const message = findLast(messages, (m) => m.id === perm.tool!.messageID)
if (!message) return undefined
const parts = data.store.part[message.id] ?? []
for (const part of parts) {
if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) {
return { part: part as ToolPart, message }
}
}
return undefined
})
const respond = (response: "once" | "always" | "reject") => {
const perm = childPermission()
if (!perm || !data.respondToPermission) return
data.respondToPermission({
sessionID: perm.sessionID,
permissionID: perm.id,
response,
})
}
const renderChildToolPart = () => {
const toolData = childToolPart()
if (!toolData) return null
const { part } = toolData
const render = ToolRegistry.render(part.tool) ?? GenericTool
// @ts-expect-error
const metadata = part.state?.metadata ?? {}
const input = part.state?.input ?? {}
return (
<Dynamic
component={render}
input={input}
tool={part.tool}
metadata={metadata}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
defaultOpen={true}
/>
)
}
return (
<div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
<Switch>
<Match when={childPermission()}>
<>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
{i18n.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
{i18n.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
{i18n.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</>
</Match>
<Match when={true}>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
data-component="tool-output"
data-scrollable
>
<div ref={autoScroll.contentRef} data-component="task-tools">
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state.input))
const subtitle = createMemo(() => {
if (info().subtitle) return info().subtitle
if (item.state.status === "completed" || item.state.status === "running") {
return item.state.title
}
})
return (
<div data-slot="task-tool-item">
<Icon name={info().icon} size="small" />
<span data-slot="task-tool-title">{info().title}</span>
<Show when={subtitle()}>
<span data-slot="task-tool-subtitle">{subtitle()}</span>
</Show>
</div>
)
}}
</For>
</div>
</div>
</BasicTool>
</Match>
</Switch>
</div>
)
return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails />
},
})
@ -1080,6 +1189,21 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
const out = stripAnsi(props.output || props.metadata.output || "")
return `$ ${cmd}${out ? "\n\n" + out : ""}`
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = text()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<BasicTool
{...props}
@ -1089,10 +1213,28 @@ ToolRegistry.register({
subtitle: props.input.description,
}}
>
<div data-component="tool-output" data-scrollable>
<Markdown
text={`\`\`\`command\n$ ${props.input.command ?? props.metadata.command ?? ""}${props.output || props.metadata.output ? "\n\n" + stripAnsi(props.output || props.metadata.output) : ""}\n\`\`\``}
<div data-component="bash-output">
<div data-slot="bash-copy">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
<div data-slot="bash-scroll" data-scrollable>
<pre data-slot="bash-pre">
<code>{text()}</code>
</pre>
</div>
</div>
</BasicTool>
)
@ -1106,6 +1248,7 @@ ToolRegistry.register({
const diffComponent = useDiffComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@ -1114,17 +1257,23 @@ ToolRegistry.register({
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.edit")}</span>
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.edit")}>
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} />
</Show>
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={props.metadata.filediff}>
<Show when={!pending() && props.metadata.filediff}>
<DiffChanges changes={props.metadata.filediff} />
</Show>
</div>
@ -1159,6 +1308,7 @@ ToolRegistry.register({
const codeComponent = useCodeComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
return (
<BasicTool
{...props}
@ -1167,10 +1317,16 @@ ToolRegistry.register({
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">{i18n.t("ui.messagePart.title.write")}</span>
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.write")}>
<TextShimmer text={i18n.t("ui.messagePart.title.write")} />
</Show>
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={props.input.filePath?.includes("/")}>
<Show when={!pending() && props.input.filePath?.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
</div>
@ -1323,9 +1479,12 @@ ToolRegistry.register({
<For each={todos()}>
{(todo: Todo) => (
<Checkbox readOnly checked={todo.status === "completed"}>
<div data-slot="message-part-todo-content" data-completed={todo.status === "completed"}>
<span
data-slot="message-part-todo-content"
data-completed={todo.status === "completed" ? "completed" : undefined}
>
{todo.content}
</div>
</span>
</Checkbox>
)}
</For>

View File

@ -1,7 +1,5 @@
[data-component="session-turn"] {
--session-turn-sticky-height: 0px;
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 24px);
/* flex: 1; */
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@ -30,525 +28,30 @@
min-width: 0;
gap: 18px;
overflow-anchor: none;
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
}
[data-slot="session-turn-attachments"] {
width: 100%;
min-width: 0;
align-self: stretch;
}
[data-slot="session-turn-sticky"] {
width: calc(100% + 9px);
position: sticky;
top: var(--session-title-height, 0px);
z-index: 20;
background-color: var(--background-stronger);
margin-left: -9px;
padding-left: 9px;
/* padding-bottom: 12px; */
display: flex;
flex-direction: column;
gap: 12px;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: var(--background-stronger);
z-index: -1;
}
&::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to bottom, var(--background-stronger), transparent);
pointer-events: none;
}
}
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
align-self: stretch;
height: 32px;
}
[data-slot="session-turn-message-content"] {
margin-top: 0;
width: 100%;
min-width: 0;
max-width: 100%;
}
[data-component="user-message"] [data-slot="user-message-text"] {
max-height: var(--user-message-collapsed-height, 64px);
}
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
max-height: none;
}
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
padding-right: 36px;
padding-bottom: 28px;
}
[data-component="user-message"][data-can-expand="true"]:not([data-expanded="true"])
[data-slot="user-message-text"]::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 8px;
bottom: 0px;
background:
linear-gradient(to bottom, transparent, var(--surface-weak)),
linear-gradient(to bottom, transparent, var(--surface-weak));
pointer-events: none;
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
display: none;
position: absolute;
bottom: 6px;
right: 6px;
padding: 0;
}
[data-component="user-message"][data-can-expand="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"],
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
width: 22px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
color: var(--text-weak);
[data-slot="icon-svg"] {
transition: transform 0.15s ease;
}
}
[data-component="user-message"][data-expanded="true"]
[data-slot="user-message-text"]
[data-slot="user-message-expand"]
[data-slot="icon-svg"] {
transform: rotate(180deg);
}
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
background: var(--surface-raised-base);
color: var(--text-base);
}
[data-slot="session-turn-user-badges"] {
display: flex;
align-items: center;
gap: 6px;
padding-left: 16px;
}
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: var(--font-size-large);
font-weight: 500;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-message-title"] h1 {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
font-size: inherit;
font-weight: inherit;
}
[data-slot="session-turn-typewriter"] {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
white-space: nowrap;
}
[data-slot="session-turn-summary-section"] {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
align-items: flex-start;
align-self: stretch;
}
[data-slot="session-turn-summary-header"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-self: stretch;
[data-slot="session-turn-summary-title-row"] {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
[data-slot="session-turn-response"] {
width: 100%;
}
[data-slot="session-turn-response-copy-wrapper"] {
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-response-copy-wrapper"],
&:focus-within [data-slot="session-turn-response-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
p {
font-size: var(--font-size-base);
line-height: var(--line-height-x-large);
}
}
[data-slot="session-turn-summary-title"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
color: var(--text-weak);
}
[data-slot="session-turn-markdown"],
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
[data-slot="session-turn-markdown"] {
&[data-diffs="true"] {
font-size: 15px;
}
&[data-fade="true"] > * {
animation: fadeUp 0.4s ease-out forwards;
opacity: 0;
&:nth-child(1) {
animation-delay: 0.1s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.4s;
}
&:nth-child(5) {
animation-delay: 0.5s;
}
&:nth-child(6) {
animation-delay: 0.6s;
}
&:nth-child(7) {
animation-delay: 0.7s;
}
&:nth-child(8) {
animation-delay: 0.8s;
}
&:nth-child(9) {
animation-delay: 0.9s;
}
&:nth-child(10) {
animation-delay: 1s;
}
&:nth-child(11) {
animation-delay: 1.1s;
}
&:nth-child(12) {
animation-delay: 1.2s;
}
&:nth-child(13) {
animation-delay: 1.3s;
}
&:nth-child(14) {
animation-delay: 1.4s;
}
&:nth-child(15) {
animation-delay: 1.5s;
}
&:nth-child(16) {
animation-delay: 1.6s;
}
&:nth-child(17) {
animation-delay: 1.7s;
}
&:nth-child(18) {
animation-delay: 1.8s;
}
&:nth-child(19) {
animation-delay: 1.9s;
}
&:nth-child(20) {
animation-delay: 2s;
}
&:nth-child(21) {
animation-delay: 2.1s;
}
&:nth-child(22) {
animation-delay: 2.2s;
}
&:nth-child(23) {
animation-delay: 2.3s;
}
&:nth-child(24) {
animation-delay: 2.4s;
}
&:nth-child(25) {
animation-delay: 2.5s;
}
&:nth-child(26) {
animation-delay: 2.6s;
}
&:nth-child(27) {
animation-delay: 2.7s;
}
&:nth-child(28) {
animation-delay: 2.8s;
}
&:nth-child(29) {
animation-delay: 2.9s;
}
&:nth-child(30) {
animation-delay: 3s;
}
}
}
[data-slot="session-turn-summary-section"] {
position: relative;
[data-slot="session-turn-summary-copy"] {
position: absolute;
top: 0;
right: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
&:hover [data-slot="session-turn-summary-copy"] {
opacity: 1;
}
}
[data-slot="session-turn-accordion"] {
width: 100%;
}
[data-component="sticky-accordion-header"] {
top: var(--sticky-header-height, 0px);
}
[data-component="sticky-accordion-header"][data-expanded]::before,
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
top: calc(-1 * var(--sticky-header-height, 0px));
}
[data-slot="session-turn-accordion-trigger-content"] {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 20px;
[data-expandable="false"] {
pointer-events: none;
}
}
[data-slot="session-turn-file-info"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 20px;
min-width: 0;
}
[data-slot="session-turn-file-icon"] {
flex-shrink: 0;
width: 16px;
height: 16px;
}
[data-slot="session-turn-file-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
}
[data-slot="session-turn-directory"] {
color: var(--text-base);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="session-turn-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="session-turn-accordion-actions"] {
flex-shrink: 0;
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
[data-slot="session-turn-accordion-content"] {
max-height: 240px;
/* max-h-60 */
overflow-y: auto;
scrollbar-width: none;
}
[data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
display: none;
}
[data-slot="session-turn-response-section"] {
width: calc(100% + 9px);
min-width: 0;
margin-left: -9px;
padding-left: 9px;
}
[data-slot="session-turn-collapsible"] {
gap: 32px;
overflow: visible;
}
[data-slot="session-turn-collapsible-trigger-content"] {
max-width: 100%;
min-width: 0;
[data-slot="session-turn-thinking"] {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-weak);
[data-slot="session-turn-trigger-icon"] {
color: var(--icon-base);
}
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
min-height: 20px;
[data-component="spinner"] {
width: 12px;
height: 12px;
margin-right: 4px;
width: 16px;
height: 16px;
}
[data-component="icon"] {
width: 14px;
height: 14px;
}
}
[data-slot="session-turn-retry-message"] {
font-weight: 500;
color: var(--syntax-critical);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-slot="session-turn-retry-seconds"] {
color: var(--text-weak);
}
[data-slot="session-turn-retry-attempt"] {
color: var(--text-weak);
}
[data-slot="session-turn-status-text"] {
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="session-turn-details-text"] {
font-size: 13px;
/* text-12-medium */
font-weight: 500;
}
.error-card {
@ -560,50 +63,112 @@
overflow-y: auto;
}
.retry-error-link,
.error-card-link {
color: var(--text-strong);
text-decoration: underline;
}
[data-slot="session-turn-collapsible-content-inner"] {
[data-slot="session-turn-assistant-content"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 12px;
margin-left: 12px;
padding-left: 12px;
padding-right: 12px;
border-left: 1px solid var(--border-base);
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
}
}
[data-slot="session-turn-permission-parts"] {
[data-slot="session-turn-diffs"] {
width: 100%;
min-width: 0;
}
[data-component="session-turn-diffs-trigger"] {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0;
}
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
align-items: baseline;
gap: 8px;
}
[data-slot="session-turn-diffs-label"] {
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
}
[data-slot="session-turn-diffs-count"] {
color: var(--text-base);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-x-large);
}
[data-slot="session-turn-diffs-meta"] {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
[data-component="diff-changes"][data-variant="bars"] {
transform: translateY(1px);
}
}
[data-component="session-turn-diffs-content"] {
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="session-turn-question-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
[data-component="session-turn-diff"] {
border: 1px solid var(--border-weaker-base);
border-radius: var(--radius-md);
overflow: clip;
}
[data-slot="session-turn-answered-question-parts"] {
[data-slot="session-turn-diff-header"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 10px;
border-bottom: 1px solid var(--border-weaker-base);
}
[data-slot="session-turn-diff-path"] {
display: inline-flex;
min-width: 0;
align-items: baseline;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-slot="session-turn-diff-directory"] {
color: var(--text-weak);
}
[data-slot="session-turn-diff-filename"] {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
[data-slot="session-turn-diff-view"] {
background-color: var(--surface-inset-base);
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
}

View File

@ -1,31 +1,18 @@
import {
AssistantMessage,
FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
type QuestionRequest,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2/client"
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
import { useDiffComponent } from "../context/diff"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { IconButton } from "./icon-button"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createMemo, createSignal, For, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Message } from "./message-part"
import { Card } from "./card"
import { Button } from "./button"
import { Spinner } from "./spinner"
import { Tooltip } from "./tooltip"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { TextShimmer } from "./text-shimmer"
import { createAutoScroll } from "../hooks"
import { createResizeObserver } from "@solid-primitives/resize-observer"
type Translator = (key: UiI18nKey, params?: UiI18nParams) => string
import { useI18n } from "../context/i18n"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@ -80,117 +67,42 @@ function unwrap(message: string) {
return message
}
function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined {
if (!part) return undefined
if (part.type === "tool") {
switch (part.tool) {
case "task":
return t("ui.sessionTurn.status.delegating")
case "todowrite":
case "todoread":
return t("ui.sessionTurn.status.planning")
case "read":
return t("ui.sessionTurn.status.gatheringContext")
case "list":
case "grep":
case "glob":
return t("ui.sessionTurn.status.searchingCodebase")
case "webfetch":
return t("ui.sessionTurn.status.searchingWeb")
case "edit":
case "write":
return t("ui.sessionTurn.status.makingEdits")
case "bash":
return t("ui.sessionTurn.status.runningCommands")
default:
return undefined
}
}
if (part.type === "reasoning") {
const text = part.text ?? ""
const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() })
return t("ui.sessionTurn.status.thinking")
}
if (part.type === "text") {
return t("ui.sessionTurn.status.gatheringThoughts")
}
return undefined
}
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function isAttachment(part: PartType | undefined) {
if (part?.type !== "file") return false
const mime = (part as FilePart).mime ?? ""
return mime.startsWith("image/") || mime === "application/pdf"
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
hidden?: () => readonly { messageID: string; callID: string }[]
}) {
const hidden = new Set(["todowrite", "todoread"])
function visible(part: PartType) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return false
}
function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string | null }) {
const data = useData()
const emptyParts: PartType[] = []
const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts))
const lastTextPart = createMemo(() => {
const parts = msgParts()
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (part?.type === "text") return part as TextPart
}
return undefined
})
const filteredParts = createMemo(() => {
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (props.hideResponsePart) {
const responsePartId = props.responsePartId
if (responsePartId && responsePartId === lastTextPart()?.id) {
parts = parts.filter((part) => part?.id !== responsePartId)
}
}
const hidden = props.hidden?.() ?? []
if (hidden.length === 0) return parts
const id = props.message.id
return parts.filter((part) => {
if (part?.type !== "tool") return true
const tool = part as ToolPart
return !hidden.some((h) => h.messageID === id && h.callID === tool.callID)
})
})
return <Message message={props.message} parts={filteredParts()} />
return <Message message={props.message} parts={msgParts()} showAssistantCopyPartID={props.showAssistantCopyPartID} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
classes?: {
root?: string
@ -199,16 +111,14 @@ export function SessionTurn(
}
}>,
) {
const i18n = useI18n()
const data = useData()
const i18n = useI18n()
const diffComponent = useDiffComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyQuestions: QuestionRequest[] = []
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@ -256,18 +166,22 @@ export function SessionTurn(
return list(data.store.part?.[msg.id], emptyParts)
})
const attachmentParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyFiles
return msgParts.filter((part) => isAttachment(part)) as FilePart[]
})
const diffs = createMemo(() => {
const files = message()?.summary?.diffs
if (!files?.length) return emptyDiffs
const stickyParts = createMemo(() => {
const msgParts = parts()
if (msgParts.length === 0) return emptyParts
if (attachmentParts().length === 0) return msgParts
return msgParts.filter((part) => !isAttachment(part))
const seen = new Set<string>()
return files
.reduceRight<FileDiff[]>((result, diff) => {
if (seen.has(diff.file)) return result
seen.add(diff.file)
result.push(diff)
return result
}, [])
.reverse()
})
const edited = createMemo(() => diffs().length)
const [open, setOpen] = createSignal(false)
const assistantMessages = createMemo(
() => {
@ -291,9 +205,27 @@ export function SessionTurn(
{ equals: same },
)
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@ -301,314 +233,29 @@ export function SessionTurn(
return unwrap(String(msg))
})
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = list(data.store.part?.[m.id], emptyParts)
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions))
const nextPermission = createMemo(() => permissions()[0])
const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions))
const nextQuestion = createMemo(() => questions()[0])
const hidden = createMemo(() => {
const out: { messageID: string; callID: string }[] = []
const perm = nextPermission()
if (perm?.tool) out.push(perm.tool)
const question = nextQuestion()
if (question?.tool) out.push(question.tool)
return out
})
const answeredQuestionParts = createMemo(() => {
if (props.stepsExpanded) return emptyQuestionParts
if (questions().length > 0) return emptyQuestionParts
const result: { part: ToolPart; message: AssistantMessage }[] = []
for (const msg of assistantMessages()) {
const parts = list(data.store.part?.[msg.id], emptyParts)
for (const part of parts) {
if (part?.type !== "tool") continue
const tool = part as ToolPart
if (tool.tool !== "question") continue
// @ts-expect-error metadata may not exist on all tool states
const answers = tool.state?.metadata?.answers
if (answers && answers.length > 0) {
result.push({ part: tool, message: msg })
}
}
}
return result
})
const shellModePart = createMemo(() => {
const p = parts()
if (p.length === 0) return
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = list(data.store.part?.[msgs[0].id], emptyParts)
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages)
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = list(data.store.part?.[msg.id], emptyParts)
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part, i18n.t)
}
}
}
return computeStatusFromPart(last, i18n.t)
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
// session_status is session-scoped; only show retry on the active (last) turn
if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
const assistantCopyPartID = createMemo(() => {
if (!isLastUserMessage()) return null
if (status().type !== "idle") return null
return showAssistantCopyPartID() ?? null
})
const isRetryFreeUsageLimitError = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.includes("Free usage exceeded")
})
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = response() ?? ""
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
const updateStickyHeight = (height: number) => {
const root = rootRef()
if (!root) return
const next = Math.ceil(height)
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
}
function duration() {
const msg = message()
if (!msg) return ""
const completed = lastAssistantMessage()?.time.completed
const from = DateTime.fromMillis(msg.time.created)
const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
const interval = Interval.fromDateTimes(from, to)
const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
const locale = i18n.locale()
const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({
notation: "compact",
unitDisplay: "narrow",
compactDisplay: "short",
showZeros: false,
})
return locale.startsWith("zh") ? human.replaceAll("、", "") : human
}
const assistantVisible = createMemo(() =>
assistantMessages().reduce((count, message) => {
const parts = list(data.store.part?.[message.id], emptyParts)
return count + parts.filter(visible).length
}, 0),
)
const autoScroll = createAutoScroll({
working,
onUserInteracted: props.onUserInteracted,
overflowAnchor: "auto",
})
createResizeObserver(
() => stickyRef(),
({ height }) => {
updateStickyHeight(height)
},
)
createEffect(() => {
const root = rootRef()
if (!root) return
const sticky = stickyRef()
if (!sticky) {
root.style.setProperty("--session-turn-sticky-height", "0px")
return
}
updateStickyHeight(sticky.getBoundingClientRect().height)
})
const [store, setStore] = createStore({
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
})
createEffect(() => {
const r = retry()
if (!r) {
setStore("retrySeconds", 0)
return
}
const updateSeconds = () => {
const next = r.next
if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
}
updateSeconds()
const timer = setInterval(updateSeconds, 1000)
onCleanup(() => clearInterval(timer))
})
let retryLog = ""
createEffect(() => {
const r = retry()
if (!r) return
const key = `${r.attempt}:${r.next}:${r.message}`
if (key === retryLog) return
retryLog = key
console.warn("[session-turn] retry", {
sessionID: props.sessionID,
messageID: props.messageID,
attempt: r.attempt,
next: r.next,
raw: r.message,
parsed: unwrap(r.message),
})
})
let errorLog = ""
createEffect(() => {
const value = error()?.data?.message
if (value === undefined || value === null) return
const raw = typeof value === "string" ? value : String(value)
if (!raw) return
if (raw === errorLog) return
errorLog = raw
console.warn("[session-turn] assistant-error", {
sessionID: props.sessionID,
messageID: props.messageID,
raw,
parsed: unwrap(raw),
})
})
createEffect(() => {
const update = () => {
setStore("duration", duration())
}
update()
// Only keep ticking while the active (in-progress) turn is running.
if (!working()) return
const timer = setInterval(update, 1000)
onCleanup(() => clearInterval(timer))
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
const newStatus = rawStatus()
if (newStatus === store.status || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
if (timeSinceLastChange >= 2500) {
setStore("status", newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
clearTimeout(statusTimeout)
statusTimeout = undefined
}
} else {
if (statusTimeout) clearTimeout(statusTimeout)
statusTimeout = setTimeout(() => {
setStore("status", rawStatus())
lastStatusChange = Date.now()
statusTimeout = undefined
}, 2500 - timeSinceLastChange) as unknown as number
}
})
onCleanup(() => {
if (!statusTimeout) return
clearTimeout(statusTimeout)
overflowAnchor: "dynamic",
})
return (
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
<div data-component="session-turn" class={props.classes?.root}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
@ -624,198 +271,84 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<Switch>
<Match when={isShellMode()}>
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
<Show when={attachmentParts().length > 0}>
<div data-slot="session-turn-attachments" aria-live="off">
<Message message={msg()} parts={attachmentParts()} />
</div>
</Show>
<div data-slot="session-turn-sticky" ref={setStickyRef}>
{/* User Message */}
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={msg()} parts={stickyParts()} />
<Message message={msg()} parts={parts()} interrupted={interrupted()} />
</div>
{/* Trigger (sticky) */}
<Show when={working() || hasSteps()}>
<div data-slot="session-turn-response-trigger">
<Button
data-expandable={assistantMessages().length > 0}
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
aria-expanded={props.stepsExpanded}
>
<Switch>
<Match when={working()}>
<Spinner />
</Match>
<Match when={!props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-slot="session-turn-trigger-icon"
>
<path
d="M8.125 1.875H1.875L5 8.125L8.125 1.875Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
<Match when={props.stepsExpanded}>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-icon-base"
>
<path
d="M8.125 8.125H1.875L5 1.875L8.125 8.125Z"
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
/>
</svg>
</Match>
</Switch>
<Switch>
<Match when={retry()}>
<span data-slot="session-turn-retry-message">
{(() => {
const r = retry()
if (!r) return ""
const msg = isRetryFreeUsageLimitError()
? i18n.t("ui.sessionTurn.error.freeUsageExceeded")
: unwrap(r.message)
return msg.length > 60 ? msg.slice(0, 60) + "..." : msg
})()}
</span>
<Show when={isRetryFreeUsageLimitError()}>
<a
href="https://opencode.ai/zen"
target="_blank"
class="retry-error-link"
rel="noopener noreferrer"
>
{i18n.t("ui.sessionTurn.error.addCredits")}
</a>
</Show>
<span data-slot="session-turn-retry-seconds">
· {i18n.t("ui.sessionTurn.retry.retrying")}
{store.retrySeconds > 0
? " " + i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: store.retrySeconds })
: ""}
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={working()}>
<span data-slot="session-turn-status-text">
{store.status ?? i18n.t("ui.sessionTurn.status.consideringNextSteps")}
</span>
</Match>
<Match when={props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.hide")}</span>
</Match>
<Match when={!props.stepsExpanded}>
<span data-slot="session-turn-status-text">{i18n.t("ui.sessionTurn.steps.show")}</span>
</Match>
</Switch>
<span aria-hidden="true">·</span>
<span aria-live="off">{store.duration}</span>
</Button>
<Show when={working() && assistantVisible() === 0 && !error()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
</div>
</Show>
</div>
{/* Response */}
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner" aria-hidden={working()}>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<For each={assistantMessages()}>
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
hidden={hidden}
showAssistantCopyPartID={assistantCopyPartID()}
/>
)}
</For>
</div>
</Show>
<Show when={edited() > 0}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">
{i18n.t("ui.sessionReview.change.modified")}
</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<For each={diffs()}>
{(diff) => (
<div data-component="session-turn-diff">
<div data-slot="session-turn-diff-header">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{getDirectory(diff.file)}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
</div>
<div data-slot="session-turn-diff-view">
<Dynamic
component={diffComponent}
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</div>
)}
</For>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
<Show when={!props.stepsExpanded && answeredQuestionParts().length > 0}>
<div data-slot="session-turn-answered-question-parts">
<For each={answeredQuestionParts()}>
{({ part, message }) => <Part part={part} message={message} />}
</For>
</div>
</Show>
{/* Response */}
<div class="sr-only" aria-live="polite">
{!working() && response() ? response() : ""}
</div>
<Show when={!working() && response()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<div data-slot="session-turn-summary-title-row">
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
<Show when={response()}>
<div data-slot="session-turn-response-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={8}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="secondary"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</Show>
</div>
<div data-slot="session-turn-response">
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response() ?? ""}
cacheKey={responsePartId()}
/>
</div>
</div>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</Match>
</Switch>
</div>
)}
</Show>
{props.children}

View File

@ -0,0 +1,43 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 1200ms;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
white-space: pre;
color: inherit;
}
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] {
animation-name: text-shimmer-char;
animation-duration: var(--text-shimmer-duration);
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index));
}
@keyframes text-shimmer-char {
0%,
100% {
color: var(--text-weaker);
}
30% {
color: var(--text-weak);
}
55% {
color: var(--text-base);
}
75% {
color: var(--text-strong);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
animation: none !important;
color: inherit;
}
}

View File

@ -0,0 +1,36 @@
import { For, createMemo, type ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
export const TextShimmer = <T extends ValidComponent = "span">(props: {
text: string
class?: string
as?: T
active?: boolean
stepMs?: number
durationMs?: number
}) => {
const chars = createMemo(() => Array.from(props.text))
const active = () => props.active ?? true
return (
<Dynamic
component={props.as || "span"}
data-component="text-shimmer"
data-active={active()}
class={props.class}
aria-label={props.text}
style={{
"--text-shimmer-step": `${props.stepMs ?? 45}ms`,
"--text-shimmer-duration": `${props.durationMs ?? 1200}ms`,
}}
>
<For each={chars()}>
{(char, index) => (
<span data-slot="text-shimmer-char" aria-hidden="true" style={{ "--text-shimmer-index": `${index()}` }}>
{char}
</span>
)}
</For>
</Dynamic>
)
}

View File

@ -50,8 +50,6 @@ export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@ -62,7 +60,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
}) => {
return {
get store() {
@ -76,7 +73,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
}
},
})

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "أسئلة",
"ui.common.add": "إضافة",
"ui.common.back": "رجوع",
"ui.common.cancel": "إلغاء",
"ui.common.confirm": "تأكيد",
"ui.common.dismiss": "رفض",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "طي الرسالة",
"ui.message.copy": "نسخ",
"ui.message.copied": "تم النسخ!",
"ui.message.interrupted": "تمت المقاطعة",
"ui.message.attachment.alt": "مرفق",
"ui.patch.action.deleted": "محذوف",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} أجيب",
"ui.question.answer.none": "(لا توجد إجابة)",
"ui.question.review.notAnswered": "(لم يتم الرد)",
"ui.question.multiHint": "(حدد كل ما ينطبق)",
"ui.question.multiHint": "حدد كل ما ينطبق",
"ui.question.singleHint": "حدد إجابة واحدة",
"ui.question.custom.placeholder": "اكتب إجابتك...",
}

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "perguntas",
"ui.common.add": "Adicionar",
"ui.common.back": "Voltar",
"ui.common.cancel": "Cancelar",
"ui.common.confirm": "Confirmar",
"ui.common.dismiss": "Descartar",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "Recolher mensagem",
"ui.message.copy": "Copiar",
"ui.message.copied": "Copiado!",
"ui.message.interrupted": "Interrompido",
"ui.message.attachment.alt": "anexo",
"ui.patch.action.deleted": "Excluído",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} respondidas",
"ui.question.answer.none": "(sem resposta)",
"ui.question.review.notAnswered": "(não respondida)",
"ui.question.multiHint": "(selecione todas que se aplicam)",
"ui.question.multiHint": "Selecione todas que se aplicam",
"ui.question.singleHint": "Selecione uma resposta",
"ui.question.custom.placeholder": "Digite sua resposta...",
}

View File

@ -86,6 +86,7 @@ export const dict = {
"ui.common.question.other": "pitanja",
"ui.common.add": "Dodaj",
"ui.common.back": "Nazad",
"ui.common.cancel": "Otkaži",
"ui.common.confirm": "Potvrdi",
"ui.common.dismiss": "Odbaci",
@ -101,6 +102,7 @@ export const dict = {
"ui.message.collapse": "Sažmi poruku",
"ui.message.copy": "Kopiraj",
"ui.message.copied": "Kopirano!",
"ui.message.interrupted": "Prekinuto",
"ui.message.attachment.alt": "prilog",
"ui.patch.action.deleted": "Obrisano",
@ -111,6 +113,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} odgovoreno",
"ui.question.answer.none": "(nema odgovora)",
"ui.question.review.notAnswered": "(nije odgovoreno)",
"ui.question.multiHint": "(odaberi sve što važi)",
"ui.question.multiHint": "Odaberi sve što važi",
"ui.question.singleHint": "Odaberi jedan odgovor",
"ui.question.custom.placeholder": "Unesi svoj odgovor...",
} satisfies Partial<Record<Keys, string>>

View File

@ -81,6 +81,7 @@ export const dict = {
"ui.common.question.other": "spørgsmål",
"ui.common.add": "Tilføj",
"ui.common.back": "Tilbage",
"ui.common.cancel": "Annuller",
"ui.common.confirm": "Bekræft",
"ui.common.dismiss": "Afvis",
@ -96,6 +97,7 @@ export const dict = {
"ui.message.collapse": "Skjul besked",
"ui.message.copy": "Kopier",
"ui.message.copied": "Kopieret!",
"ui.message.interrupted": "Afbrudt",
"ui.message.attachment.alt": "vedhæftning",
"ui.patch.action.deleted": "Slettet",
@ -106,6 +108,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} besvaret",
"ui.question.answer.none": "(intet svar)",
"ui.question.review.notAnswered": "(ikke besvaret)",
"ui.question.multiHint": "(vælg alle der gælder)",
"ui.question.multiHint": "Vælg alle der gælder",
"ui.question.singleHint": "Vælg ét svar",
"ui.question.custom.placeholder": "Skriv dit svar...",
}

View File

@ -85,6 +85,7 @@ export const dict = {
"ui.common.question.other": "Fragen",
"ui.common.add": "Hinzufügen",
"ui.common.back": "Zurück",
"ui.common.cancel": "Abbrechen",
"ui.common.confirm": "Bestätigen",
"ui.common.dismiss": "Verwerfen",
@ -100,6 +101,7 @@ export const dict = {
"ui.message.collapse": "Nachricht reduzieren",
"ui.message.copy": "Kopieren",
"ui.message.copied": "Kopiert!",
"ui.message.interrupted": "Unterbrochen",
"ui.message.attachment.alt": "Anhang",
"ui.patch.action.deleted": "Gelöscht",
@ -110,6 +112,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} beantwortet",
"ui.question.answer.none": "(keine Antwort)",
"ui.question.review.notAnswered": "(nicht beantwortet)",
"ui.question.multiHint": "(alle zutreffenden auswählen)",
"ui.question.multiHint": "Alle zutreffenden auswählen",
"ui.question.singleHint": "Eine Antwort auswählen",
"ui.question.custom.placeholder": "Geben Sie Ihre Antwort ein...",
} satisfies Partial<Record<Keys, string>>

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "questions",
"ui.common.add": "Add",
"ui.common.back": "Back",
"ui.common.cancel": "Cancel",
"ui.common.confirm": "Confirm",
"ui.common.dismiss": "Dismiss",
@ -96,7 +97,8 @@ export const dict = {
"ui.message.expand": "Expand message",
"ui.message.collapse": "Collapse message",
"ui.message.copy": "Copy",
"ui.message.copied": "Copied!",
"ui.message.copied": "Copied",
"ui.message.interrupted": "Interrupted",
"ui.message.attachment.alt": "attachment",
"ui.patch.action.deleted": "Deleted",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} answered",
"ui.question.answer.none": "(no answer)",
"ui.question.review.notAnswered": "(not answered)",
"ui.question.multiHint": "(select all that apply)",
"ui.question.multiHint": "Select all answers that apply",
"ui.question.singleHint": "Select one answer",
"ui.question.custom.placeholder": "Type your answer...",
}

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "preguntas",
"ui.common.add": "Añadir",
"ui.common.back": "Atrás",
"ui.common.cancel": "Cancelar",
"ui.common.confirm": "Confirmar",
"ui.common.dismiss": "Descartar",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "Colapsar mensaje",
"ui.message.copy": "Copiar",
"ui.message.copied": "¡Copiado!",
"ui.message.interrupted": "Interrumpido",
"ui.message.attachment.alt": "adjunto",
"ui.patch.action.deleted": "Eliminado",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} respondidas",
"ui.question.answer.none": "(sin respuesta)",
"ui.question.review.notAnswered": "(no respondida)",
"ui.question.multiHint": "(selecciona todas las que correspondan)",
"ui.question.multiHint": "Selecciona todas las que correspondan",
"ui.question.singleHint": "Selecciona una respuesta",
"ui.question.custom.placeholder": "Escribe tu respuesta...",
}

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "questions",
"ui.common.add": "Ajouter",
"ui.common.back": "Retour",
"ui.common.cancel": "Annuler",
"ui.common.confirm": "Confirmer",
"ui.common.dismiss": "Ignorer",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "Réduire le message",
"ui.message.copy": "Copier",
"ui.message.copied": "Copié !",
"ui.message.interrupted": "Interrompu",
"ui.message.attachment.alt": "pièce jointe",
"ui.patch.action.deleted": "Supprimé",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} répondu(s)",
"ui.question.answer.none": "(pas de réponse)",
"ui.question.review.notAnswered": "(non répondu)",
"ui.question.multiHint": "(sélectionnez tout ce qui s'applique)",
"ui.question.multiHint": "Sélectionnez tout ce qui s'applique",
"ui.question.singleHint": "Sélectionnez une réponse",
"ui.question.custom.placeholder": "Tapez votre réponse...",
}

View File

@ -81,6 +81,7 @@ export const dict = {
"ui.common.question.other": "質問",
"ui.common.add": "追加",
"ui.common.back": "戻る",
"ui.common.cancel": "キャンセル",
"ui.common.confirm": "確認",
"ui.common.dismiss": "閉じる",
@ -96,6 +97,7 @@ export const dict = {
"ui.message.collapse": "メッセージを折りたたむ",
"ui.message.copy": "コピー",
"ui.message.copied": "コピーしました!",
"ui.message.interrupted": "中断",
"ui.message.attachment.alt": "添付ファイル",
"ui.patch.action.deleted": "削除済み",
@ -106,6 +108,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}}件回答済み",
"ui.question.answer.none": "(回答なし)",
"ui.question.review.notAnswered": "(未回答)",
"ui.question.multiHint": "(該当するものをすべて選択)",
"ui.question.multiHint": "該当するものをすべて選択",
"ui.question.singleHint": "1 つ選択",
"ui.question.custom.placeholder": "回答を入力...",
}

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "질문",
"ui.common.add": "추가",
"ui.common.back": "뒤로",
"ui.common.cancel": "취소",
"ui.common.confirm": "확인",
"ui.common.dismiss": "닫기",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "메시지 접기",
"ui.message.copy": "복사",
"ui.message.copied": "복사됨!",
"ui.message.interrupted": "중단됨",
"ui.message.attachment.alt": "첨부 파일",
"ui.patch.action.deleted": "삭제됨",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}}개 답변됨",
"ui.question.answer.none": "(답변 없음)",
"ui.question.review.notAnswered": "(답변되지 않음)",
"ui.question.multiHint": "(해당하는 항목 모두 선택)",
"ui.question.multiHint": "해당하는 항목 모두 선택",
"ui.question.singleHint": "하나의 답변을 선택",
"ui.question.custom.placeholder": "답변 입력...",
}

View File

@ -85,6 +85,7 @@ export const dict: Record<Keys, string> = {
"ui.common.question.other": "spørsmål",
"ui.common.add": "Legg til",
"ui.common.back": "Tilbake",
"ui.common.cancel": "Avbryt",
"ui.common.confirm": "Bekreft",
"ui.common.dismiss": "Avvis",
@ -100,6 +101,7 @@ export const dict: Record<Keys, string> = {
"ui.message.collapse": "Skjul melding",
"ui.message.copy": "Kopier",
"ui.message.copied": "Kopiert!",
"ui.message.interrupted": "Avbrutt",
"ui.message.attachment.alt": "vedlegg",
"ui.patch.action.deleted": "Slettet",
@ -110,6 +112,7 @@ export const dict: Record<Keys, string> = {
"ui.question.subtitle.answered": "{{count}} besvart",
"ui.question.answer.none": "(ingen svar)",
"ui.question.review.notAnswered": "(ikke besvart)",
"ui.question.multiHint": "(velg alle som gjelder)",
"ui.question.multiHint": "Velg alle som gjelder",
"ui.question.singleHint": "Velg ett svar",
"ui.question.custom.placeholder": "Skriv svaret ditt...",
}

View File

@ -81,6 +81,7 @@ export const dict = {
"ui.common.question.other": "pytania",
"ui.common.add": "Dodaj",
"ui.common.back": "Wstecz",
"ui.common.cancel": "Anuluj",
"ui.common.confirm": "Potwierdź",
"ui.common.dismiss": "Odrzuć",
@ -96,6 +97,7 @@ export const dict = {
"ui.message.collapse": "Zwiń wiadomość",
"ui.message.copy": "Kopiuj",
"ui.message.copied": "Skopiowano!",
"ui.message.interrupted": "Przerwano",
"ui.message.attachment.alt": "załącznik",
"ui.patch.action.deleted": "Usunięto",
@ -106,6 +108,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} odpowiedzi",
"ui.question.answer.none": "(brak odpowiedzi)",
"ui.question.review.notAnswered": "(bez odpowiedzi)",
"ui.question.multiHint": "(zaznacz wszystkie pasujące)",
"ui.question.multiHint": "Zaznacz wszystkie pasujące",
"ui.question.singleHint": "Wybierz jedną odpowiedź",
"ui.question.custom.placeholder": "Wpisz swoją odpowiedź...",
}

View File

@ -81,6 +81,7 @@ export const dict = {
"ui.common.question.other": "вопросов",
"ui.common.add": "Добавить",
"ui.common.back": "Назад",
"ui.common.cancel": "Отмена",
"ui.common.confirm": "Подтвердить",
"ui.common.dismiss": "Закрыть",
@ -96,6 +97,7 @@ export const dict = {
"ui.message.collapse": "Свернуть сообщение",
"ui.message.copy": "Копировать",
"ui.message.copied": "Скопировано!",
"ui.message.interrupted": "Прервано",
"ui.message.attachment.alt": "вложение",
"ui.patch.action.deleted": "Удалено",
@ -106,6 +108,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} отвечено",
"ui.question.answer.none": "(нет ответа)",
"ui.question.review.notAnswered": "(не отвечено)",
"ui.question.multiHint": "(выберите все подходящие)",
"ui.question.multiHint": "Выберите все подходящие",
"ui.question.singleHint": "Выберите один ответ",
"ui.question.custom.placeholder": "Введите ваш ответ...",
}

View File

@ -82,6 +82,7 @@ export const dict = {
"ui.common.question.other": "คำถาม",
"ui.common.add": "เพิ่ม",
"ui.common.back": "ย้อนกลับ",
"ui.common.cancel": "ยกเลิก",
"ui.common.confirm": "ยืนยัน",
"ui.common.dismiss": "ปิด",
@ -97,6 +98,7 @@ export const dict = {
"ui.message.collapse": "ย่อข้อความ",
"ui.message.copy": "คัดลอก",
"ui.message.copied": "คัดลอกแล้ว!",
"ui.message.interrupted": "ถูกขัดจังหวะ",
"ui.message.attachment.alt": "ไฟล์แนบ",
"ui.patch.action.deleted": "ลบ",
@ -107,6 +109,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} ตอบแล้ว",
"ui.question.answer.none": "(ไม่มีคำตอบ)",
"ui.question.review.notAnswered": "(ไม่ได้ตอบ)",
"ui.question.multiHint": "(เลือกทั้งหมดที่ใช้)",
"ui.question.multiHint": "เลือกทั้งหมดที่ใช้",
"ui.question.singleHint": "เลือกหนึ่งคำตอบ",
"ui.question.custom.placeholder": "พิมพ์คำตอบของคุณ...",
}

View File

@ -86,6 +86,7 @@ export const dict = {
"ui.common.question.other": "个问题",
"ui.common.add": "添加",
"ui.common.back": "返回",
"ui.common.cancel": "取消",
"ui.common.confirm": "确认",
"ui.common.dismiss": "忽略",
@ -101,6 +102,7 @@ export const dict = {
"ui.message.collapse": "收起消息",
"ui.message.copy": "复制",
"ui.message.copied": "已复制!",
"ui.message.interrupted": "已中断",
"ui.message.attachment.alt": "附件",
"ui.patch.action.deleted": "已删除",
@ -111,6 +113,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} 已回答",
"ui.question.answer.none": "(无答案)",
"ui.question.review.notAnswered": "(未回答)",
"ui.question.multiHint": "(可多选)",
"ui.question.multiHint": "可多选",
"ui.question.singleHint": "选择一个答案",
"ui.question.custom.placeholder": "输入你的答案...",
} satisfies Partial<Record<Keys, string>>

View File

@ -86,6 +86,7 @@ export const dict = {
"ui.common.question.other": "個問題",
"ui.common.add": "新增",
"ui.common.back": "返回",
"ui.common.cancel": "取消",
"ui.common.confirm": "確認",
"ui.common.dismiss": "忽略",
@ -101,6 +102,7 @@ export const dict = {
"ui.message.collapse": "收合訊息",
"ui.message.copy": "複製",
"ui.message.copied": "已複製!",
"ui.message.interrupted": "已中斷",
"ui.message.attachment.alt": "附件",
"ui.patch.action.deleted": "已刪除",
@ -111,6 +113,7 @@ export const dict = {
"ui.question.subtitle.answered": "{{count}} 已回答",
"ui.question.answer.none": "(無答案)",
"ui.question.review.notAnswered": "(未回答)",
"ui.question.multiHint": "(可多選)",
"ui.question.multiHint": "可多選",
"ui.question.singleHint": "選擇一個答案",
"ui.question.custom.placeholder": "輸入你的答案...",
} satisfies Partial<Record<Keys, string>>

View File

@ -1,5 +1,6 @@
:root {
--animate-pulse: pulse-opacity 2s ease-in-out infinite;
--animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite;
}
@keyframes pulse-opacity {
@ -12,6 +13,16 @@
}
}
@keyframes pulse-scale {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(0.6666667);
}
}
@keyframes pulse-opacity-dim {
0%,
100% {

View File

@ -48,6 +48,7 @@
@import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tag.css" layer(components);
@import "../components/text-shimmer.css" layer(components);
@import "../components/toast.css" layer(components);
@import "../components/tooltip.css" layer(components);
@import "../components/typewriter.css" layer(components);

View File

@ -10,6 +10,7 @@
--font-size-x-large: 20px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--line-height-normal: 130%;
--line-height-large: 150%;
--line-height-x-large: 180%;
--line-height-2x-large: 200%;