summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-select-model.tsx2
-rw-r--r--packages/app/src/components/prompt-input.tsx438
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx7
-rw-r--r--packages/app/src/components/prompt-input/placeholder.test.ts19
-rw-r--r--packages/app/src/components/prompt-input/placeholder.ts2
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx6
-rw-r--r--packages/app/src/components/prompt-input/submit.ts4
-rw-r--r--packages/app/src/components/question-dock.tsx440
-rw-r--r--packages/app/src/components/session-context-usage.tsx10
-rw-r--r--packages/app/src/components/session-todo-dock.tsx208
-rw-r--r--packages/app/src/components/session/session-header.tsx83
-rw-r--r--packages/app/src/components/session/session-new-view.tsx2
-rw-r--r--packages/app/src/components/status-popover.tsx22
-rw-r--r--packages/app/src/components/titlebar.tsx80
14 files changed, 946 insertions, 377 deletions
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index a196db231..9f7afb8cd 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -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}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index e21798738..7813e01cd 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -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,41 +1094,158 @@ 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 class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept={ACCEPTED_FILE_TYPES.join(",")}
+ class="hidden"
+ onChange={(e) => {
+ const file = e.currentTarget.files?.[0]
+ if (file) addImageAttachment(file)
+ e.currentTarget.value = ""
+ }}
+ />
+
+ <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-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>
- <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>
+ </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>
- </Match>
- <Match when={store.mode === "normal"}>
+ </Show>
+
+ <Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
- gutter={8}
+ 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 ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
- valueClass="truncate"
+ class="capitalize max-w-[160px]"
+ valueClass="truncate text-13-regular"
+ triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1072,18 +1254,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind
placement="top"
- gutter={8}
+ gutter={4}
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]"
+ 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" />
+ <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")}
@@ -1095,16 +1283,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<TooltipKeybind
placement="top"
- gutter={8}
+ gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
- triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
+ 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" />
+ <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")}
@@ -1113,116 +1310,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</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">
- <input
- ref={fileInputRef}
- type="file"
- accept={ACCEPTED_FILE_TYPES.join(",")}
- class="hidden"
- onChange={(e) => {
- const file = e.currentTarget.files?.[0]
- if (file) addImageAttachment(file)
- 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"
+ <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"
- class="size-6 px-1"
- onClick={() => fileInputRef.click()}
- aria-label={language.t("prompt.action.attachFile")}
- >
- <Icon name="photo" class="size-4.5" />
- </Button>
- </Tooltip>
+ />
+ </TooltipKeybind>
</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>
- }
- >
- <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>
+
+ <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>
- </form>
+ </Show>
</div>
)
}
diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx
index b575c3961..b138fe3ef 100644
--- a/packages/app/src/components/prompt-input/context-items.tsx
+++ b/packages/app/src/components/prompt-input/context-items.tsx
@@ -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)}
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts
index b633df829..5f6aa59e9 100644
--- a/packages/app/src/components/prompt-input/placeholder.test.ts
+++ b/packages/app/src/components/prompt-input/placeholder.test.ts
@@ -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")
+ })
})
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
index 07f6a43b5..395fee51b 100644
--- a/packages/app/src/components/prompt-input/placeholder.ts
+++ b/packages/app/src/components/prompt-input/placeholder.ts
@@ -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 })
}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx
index 259883d61..65eb01c79 100644
--- a/packages/app/src/components/prompt-input/slash-popover.tsx
+++ b/packages/app/src/components/prompt-input/slash-popover.tsx
@@ -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>
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 9a1fba5d5..6b6f4a4e0 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -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[]) => {
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx
index 5054253b8..1a0bbbe97 100644
--- a/packages/app/src/components/question-dock.tsx
+++ b/packages/app/src/components/question-dock.tsx
@@ -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>
+ onClick={() => jump(i())}
+ aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
+ />
+ )}
+ </For>
+ </div>
</div>
- </Show>
- <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="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)
+ 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>
+ <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()
}}
- />
- <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 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>
-
- <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>
- <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")}
- </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")}
+ <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>
+ <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>
)
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index c6e60d3ed..47030aa17 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -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}>
diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx
new file mode 100644
index 000000000..aeb2e421b
--- /dev/null
+++ b/packages/app/src/components/session-todo-dock.tsx
@@ -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>
+ )
+}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index f81a2ec44..8bbe59e38 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -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>
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index ab96652d4..b7a544ba9 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -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
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 38152b823..5411d7d01 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -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>
}
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index 039a25fae..e10fbe3b3 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -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" />