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

Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
Adam
2026-02-17 07:16:23 -06:00
committed by GitHub
parent 3dfbb70593
commit 10985671ad
85 changed files with 3158 additions and 2477 deletions

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,54 +1121,262 @@ 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")}>
<Button
type="button"
variant="ghost"
class="size-6 px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
</div>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
<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",
}}
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working() && commentCount() === 0}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
<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-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
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("editing", false)
return
}
setStore("tab", store.tab + 1)
setStore("editing", false)
}
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">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
<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()}>
{(_, 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>
)
}}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</div>
<div data-slot="question-content">
<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="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 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>
</span>
</button>
)
}}
</For>
<button
data-slot="question-option"
data-picked={customPicked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
>
<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>
</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"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
<Show
when={store.editing}
fallback={
<button
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending}
onInput={(e) => {
setStore("custom", store.tab, e.currentTarget.value)
}}
/>
<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)}
onClick={customOpen}
>
{language.t("ui.common.cancel")}
</Button>
<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>
<span data-slot="option-description">
{input() || language.t("ui.question.custom.placeholder")}
</span>
</span>
</button>
}
>
<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) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show>
</div>
</div>
</Show>
</div>
<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>
</Show>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</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,38 +608,57 @@ 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">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
<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(),
}}
>
<Button
variant="ghost"
class="group/file-tree-toggle size-6 p-0"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<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(),
}}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
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={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
</div>
</Portal>

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,19 +196,21 @@ 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
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<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,
"bg-icon-success-base": overallHealthy(),
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
"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,39 +208,60 @@ 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">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-left"
class="size-6 p-0"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="arrow-right"
class="size-6 p-0"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
<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="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />