summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-17 07:16:23 -0600
committerGitHub <[email protected]>2026-02-17 07:16:23 -0600
commit10985671ad9553e7ac594ede30981166f69ba3c5 (patch)
tree0f501ccba5dcf90e86beb18a0732826f7a3b1d0a /packages/app/src
parent3dfbb7059345350fdcb3f45fe9a44697c08a040a (diff)
downloadopencode-10985671ad9553e7ac594ede30981166f69ba3c5.tar.gz
opencode-10985671ad9553e7ac594ede30981166f69ba3c5.zip
feat(app): session timeline/turn rework (#13196)
Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/app/src')
-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
-rw-r--r--packages/app/src/context/command.tsx2
-rw-r--r--packages/app/src/context/global-sync.tsx23
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts4
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts14
-rw-r--r--packages/app/src/context/sync.tsx17
-rw-r--r--packages/app/src/i18n/ar.ts4
-rw-r--r--packages/app/src/i18n/br.ts4
-rw-r--r--packages/app/src/i18n/bs.ts4
-rw-r--r--packages/app/src/i18n/da.ts4
-rw-r--r--packages/app/src/i18n/de.ts4
-rw-r--r--packages/app/src/i18n/en.ts6
-rw-r--r--packages/app/src/i18n/es.ts4
-rw-r--r--packages/app/src/i18n/fr.ts4
-rw-r--r--packages/app/src/i18n/ja.ts4
-rw-r--r--packages/app/src/i18n/ko.ts4
-rw-r--r--packages/app/src/i18n/no.ts4
-rw-r--r--packages/app/src/i18n/pl.ts4
-rw-r--r--packages/app/src/i18n/ru.ts4
-rw-r--r--packages/app/src/i18n/th.ts4
-rw-r--r--packages/app/src/i18n/zh.ts4
-rw-r--r--packages/app/src/i18n/zht.ts4
-rw-r--r--packages/app/src/pages/directory-layout.tsx1
-rw-r--r--packages/app/src/pages/layout.tsx8
-rw-r--r--packages/app/src/pages/layout/sidebar-shell.tsx4
-rw-r--r--packages/app/src/pages/session.tsx81
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx54
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx138
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx368
42 files changed, 1432 insertions, 671 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" />
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index 03437c973..03bd6318d 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
-const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
+const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index ec5efc675..9733b72af 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -4,6 +4,7 @@ import {
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
+ type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -41,6 +42,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
+ session_todo: {
+ [sessionID: string]: Todo[]
+ }
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -87,12 +91,27 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
+ session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
+ const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
+ if (!sessionID) return
+ if (!todos) {
+ setGlobalStore(
+ "session_todo",
+ produce((draft) => {
+ delete draft[sessionID]
+ }),
+ )
+ return
+ }
+ setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
+ }
+
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
@@ -288,6 +307,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
+ setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@@ -358,6 +378,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
+ todo: {
+ set: setSessionTodo,
+ },
}
}
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 2137a19a8..478bc02f5 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -6,6 +6,7 @@ import {
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
+ type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@@ -20,6 +21,9 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
+ session_todo: {
+ [sessionID: string]: Todo[]
+ }
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index 48ac0fea1..241dfb14d 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
-function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
+function cleanupSessionCaches(
+ store: Store<State>,
+ setStore: SetStoreFunction<State>,
+ sessionID: string,
+ setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
+) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
+ setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
+ setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.store, input.setStore, info.id)
+ cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
- cleanupSessionCaches(input.store, input.setStore, info.id)
+ cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+ input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index e5916598b..60888b1a6 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
- if (store.todo[sessionID] !== undefined) return
+ const existing = store.todo[sessionID]
+ if (existing !== undefined) {
+ if (globalSync.data.session_todo[sessionID] === undefined) {
+ globalSync.todo.set(sessionID, existing)
+ }
+ return
+ }
+
+ const cached = globalSync.data.session_todo[sessionID]
+ if (cached !== undefined) {
+ setStore("todo", sessionID, reconcile(cached, { key: "id" }))
+ }
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
- setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
+ const list = todo.data ?? []
+ setStore("todo", sessionID, reconcile(list, { key: "id" }))
+ globalSync.todo.set(sessionID, list)
}),
)
},
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 3d347c842..1116d88d1 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
+ "prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
@@ -447,6 +448,9 @@ export const dict = {
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
+ "session.todo.title": "المهام",
+ "session.todo.collapse": "طي",
+ "session.todo.expand": "توسيع",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 730c01fdf..19fac5ec4 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
+ "prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell",
@@ -450,6 +451,9 @@ export const dict = {
"session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
+ "session.todo.title": "Tarefas",
+ "session.todo.collapse": "Recolher",
+ "session.todo.expand": "Expandir",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index d53c26112..cecdb5918 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -224,6 +224,7 @@ export const dict = {
"prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
+ "prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…",
"prompt.placeholder.summarizeComment": "Sažmi komentar…",
"prompt.mode.shell": "Shell",
@@ -505,6 +506,9 @@ export const dict = {
"session.messages.jumpToLatest": "Idi na najnovije",
"session.context.addToContext": "Dodaj {{selection}} u kontekst",
+ "session.todo.title": "Zadaci",
+ "session.todo.collapse": "Sažmi",
+ "session.todo.expand": "Proširi",
"session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 9faa14d3d..fe62d9219 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -222,6 +222,7 @@ export const dict = {
"prompt.placeholder.shell": "Indtast shell-kommando...",
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
+ "prompt.placeholder.simple": "Spørg om hvad som helst...",
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
"prompt.placeholder.summarizeComment": "Opsummér kommentar…",
"prompt.mode.shell": "Shell",
@@ -500,6 +501,9 @@ export const dict = {
"session.messages.jumpToLatest": "Gå til seneste",
"session.context.addToContext": "Tilføj {{selection}} til kontekst",
+ "session.todo.title": "Opgaver",
+ "session.todo.collapse": "Skjul",
+ "session.todo.expand": "Udvid",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index d350af6cf..d82cd305b 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -211,6 +211,7 @@ export const dict = {
"common.attachment": "Anhang",
"prompt.placeholder.shell": "Shell-Befehl eingeben...",
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
+ "prompt.placeholder.simple": "Fragen Sie alles...",
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
"prompt.mode.shell": "Shell",
@@ -458,6 +459,9 @@ export const dict = {
"session.messages.loading": "Lade Nachrichten...",
"session.messages.jumpToLatest": "Zum neuesten springen",
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
+ "session.todo.title": "Aufgaben",
+ "session.todo.collapse": "Einklappen",
+ "session.todo.expand": "Ausklappen",
"session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index cb42b016f..3e3a34da3 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -224,6 +224,7 @@ export const dict = {
"prompt.placeholder.shell": "Enter shell command...",
"prompt.placeholder.normal": 'Ask anything... "{{example}}"',
+ "prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.summarizeComments": "Summarize comments…",
"prompt.placeholder.summarizeComment": "Summarize comment…",
"prompt.mode.shell": "Shell",
@@ -266,7 +267,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context",
- "prompt.action.attachFile": "Attach file",
+ "prompt.action.attachFile": "Add file",
"prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send",
"prompt.action.stop": "Stop",
@@ -504,6 +505,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
+ "session.todo.title": "Todos",
+ "session.todo.collapse": "Collapse",
+ "session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index c4ec378dc..a813dd450 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "Introduce comando de shell...",
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
+ "prompt.placeholder.simple": "Pregunta cualquier cosa...",
"prompt.placeholder.summarizeComments": "Resumir comentarios…",
"prompt.placeholder.summarizeComment": "Resumir comentario…",
"prompt.mode.shell": "Shell",
@@ -506,6 +507,9 @@ export const dict = {
"session.messages.jumpToLatest": "Ir al último",
"session.context.addToContext": "Añadir {{selection}} al contexto",
+ "session.todo.title": "Tareas",
+ "session.todo.collapse": "Contraer",
+ "session.todo.expand": "Expandir",
"session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index 7069fbd98..a817e93c7 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -206,6 +206,7 @@ export const dict = {
"common.attachment": "pièce jointe",
"prompt.placeholder.shell": "Entrez une commande shell...",
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
+ "prompt.placeholder.simple": "Demandez n'importe quoi...",
"prompt.placeholder.summarizeComments": "Résumer les commentaires…",
"prompt.placeholder.summarizeComment": "Résumer le commentaire…",
"prompt.mode.shell": "Shell",
@@ -456,6 +457,9 @@ export const dict = {
"session.messages.loading": "Chargement des messages...",
"session.messages.jumpToLatest": "Aller au dernier",
"session.context.addToContext": "Ajouter {{selection}} au contexte",
+ "session.todo.title": "Tâches",
+ "session.todo.collapse": "Réduire",
+ "session.todo.expand": "Développer",
"session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index e7e24a9bd..d6acd7c22 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -205,6 +205,7 @@ export const dict = {
"common.attachment": "添付ファイル",
"prompt.placeholder.shell": "シェルコマンドを入力...",
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
+ "prompt.placeholder.simple": "何でも聞いてください...",
"prompt.placeholder.summarizeComments": "コメントを要約…",
"prompt.placeholder.summarizeComment": "コメントを要約…",
"prompt.mode.shell": "シェル",
@@ -448,6 +449,9 @@ export const dict = {
"session.messages.loading": "メッセージを読み込み中...",
"session.messages.jumpToLatest": "最新へジャンプ",
"session.context.addToContext": "{{selection}}をコンテキストに追加",
+ "session.todo.title": "ToDo",
+ "session.todo.collapse": "折りたたむ",
+ "session.todo.expand": "展開",
"session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 650b7e662..b8cfe6379 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -209,6 +209,7 @@ export const dict = {
"common.attachment": "첨부 파일",
"prompt.placeholder.shell": "셸 명령어 입력...",
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
+ "prompt.placeholder.simple": "무엇이든 물어보세요...",
"prompt.placeholder.summarizeComments": "댓글 요약…",
"prompt.placeholder.summarizeComment": "댓글 요약…",
"prompt.mode.shell": "셸",
@@ -450,6 +451,9 @@ export const dict = {
"session.messages.loading": "메시지 로드 중...",
"session.messages.jumpToLatest": "최신으로 이동",
"session.context.addToContext": "컨텍스트에 {{selection}} 추가",
+ "session.todo.title": "할 일",
+ "session.todo.collapse": "접기",
+ "session.todo.expand": "펼치기",
"session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
"session.new.worktree.create": "새 작업 트리 생성",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index afc162ab1..3ecfcd444 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -226,6 +226,7 @@ export const dict = {
"prompt.placeholder.shell": "Skriv inn shell-kommando...",
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
+ "prompt.placeholder.simple": "Spør om hva som helst...",
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
"prompt.mode.shell": "Shell",
@@ -506,6 +507,9 @@ export const dict = {
"session.messages.jumpToLatest": "Hopp til nyeste",
"session.context.addToContext": "Legg til {{selection}} i kontekst",
+ "session.todo.title": "Oppgaver",
+ "session.todo.collapse": "Skjul",
+ "session.todo.expand": "Utvid",
"session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index d8572148a..30698a957 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -207,6 +207,7 @@ export const dict = {
"common.attachment": "załącznik",
"prompt.placeholder.shell": "Wpisz polecenie terminala...",
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
+ "prompt.placeholder.simple": "Zapytaj o cokolwiek...",
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
"prompt.mode.shell": "Terminal",
@@ -449,6 +450,9 @@ export const dict = {
"session.messages.loading": "Ładowanie wiadomości...",
"session.messages.jumpToLatest": "Przejdź do najnowszych",
"session.context.addToContext": "Dodaj {{selection}} do kontekstu",
+ "session.todo.title": "Zadania",
+ "session.todo.collapse": "Zwiń",
+ "session.todo.expand": "Rozwiń",
"session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 86d201ceb..f5cdf4c41 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "Введите команду оболочки...",
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
+ "prompt.placeholder.simple": "Спросите что угодно...",
"prompt.placeholder.summarizeComments": "Суммировать комментарии…",
"prompt.placeholder.summarizeComment": "Суммировать комментарий…",
"prompt.mode.shell": "Оболочка",
@@ -504,6 +505,9 @@ export const dict = {
"session.messages.jumpToLatest": "Перейти к последнему",
"session.context.addToContext": "Добавить {{selection}} в контекст",
+ "session.todo.title": "Задачи",
+ "session.todo.collapse": "Свернуть",
+ "session.todo.expand": "Развернуть",
"session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 83020bf8c..9a581c5b7 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
+ "prompt.placeholder.simple": "ถามอะไรก็ได้...",
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
"prompt.mode.shell": "เชลล์",
@@ -501,6 +502,9 @@ export const dict = {
"session.messages.jumpToLatest": "ไปที่ล่าสุด",
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
+ "session.todo.title": "สิ่งที่ต้องทำ",
+ "session.todo.collapse": "ย่อ",
+ "session.todo.expand": "ขยาย",
"session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index d0bf86cbb..ed6849c7d 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -244,6 +244,7 @@ export const dict = {
"prompt.placeholder.shell": "输入 shell 命令...",
"prompt.placeholder.normal": '随便问点什么... "{{example}}"',
+ "prompt.placeholder.simple": "随便问点什么...",
"prompt.placeholder.summarizeComments": "总结评论…",
"prompt.placeholder.summarizeComment": "总结该评论…",
"prompt.mode.shell": "Shell",
@@ -500,6 +501,9 @@ export const dict = {
"session.messages.loading": "正在加载消息...",
"session.messages.jumpToLatest": "跳转到最新",
"session.context.addToContext": "将 {{selection}} 添加到上下文",
+ "session.todo.title": "待办事项",
+ "session.todo.collapse": "折叠",
+ "session.todo.expand": "展开",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
"session.new.worktree.create": "创建新的 worktree",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 349c90b0e..21aafea2c 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -223,6 +223,7 @@ export const dict = {
"prompt.placeholder.shell": "輸入 shell 命令...",
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
+ "prompt.placeholder.simple": "隨便問點什麼...",
"prompt.placeholder.summarizeComments": "摘要評論…",
"prompt.placeholder.summarizeComment": "摘要這則評論…",
"prompt.mode.shell": "Shell",
@@ -497,6 +498,9 @@ export const dict = {
"session.messages.jumpToLatest": "跳到最新",
"session.context.addToContext": "將 {{selection}} 新增到上下文",
+ "session.todo.title": "待辦事項",
+ "session.todo.collapse": "折疊",
+ "session.todo.expand": "展開",
"session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 2dee09dfb..4f1d93ab2 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
- onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 7d4a5c0cb..cecb52617 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
- "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
+ "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
"flex-1 min-w-0": panelProps.mobile,
}}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
@@ -1725,8 +1725,8 @@ export default function Layout(props: ParentProps) {
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => renameProject(p(), next)}
- class="text-16-medium text-text-strong truncate"
- displayClass="text-16-medium text-text-strong truncate"
+ class="text-14-medium text-text-strong truncate"
+ displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
@@ -2042,7 +2042,7 @@ export default function Layout(props: ParentProps) {
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
- "xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
+ "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
}}
>
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index 23abdf157..d813ef3e1 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -51,7 +51,7 @@ export const SidebarContent = (props: {
>
<DragDropSensors />
<ConstrainDragXAxis />
- <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
+ <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider>
@@ -78,7 +78,7 @@ export const SidebarContent = (props: {
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
</DragDropProvider>
</div>
- <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
+ <div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index d958990c2..23dc0304e 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -5,7 +5,6 @@ import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
-import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
@@ -20,9 +19,11 @@ import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
+import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
@@ -34,6 +35,7 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
@@ -89,6 +91,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
+ const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
@@ -99,6 +102,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
+ const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
@@ -229,7 +233,7 @@ export default function Page() {
})
}
- const isDesktop = createMediaQuery("(min-width: 1024px)")
+ const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -269,7 +273,6 @@ export default function Page() {
if (!path) return
file.load(path)
openReviewPanel()
- tabs().setActive(next)
}
createEffect(() => {
@@ -554,13 +557,11 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
- expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
- promptHeight: 0,
})
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
@@ -651,6 +652,7 @@ export default function Page() {
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
+ let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
@@ -673,7 +675,8 @@ export default function Page() {
sdk.directory
const id = params.id
if (!id) return
- sync.session.sync(id)
+ void sync.session.sync(id)
+ void sync.session.todo(id)
})
createEffect(() => {
@@ -726,13 +729,17 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
+ const todos = createMemo(() => {
+ const id = params.id
+ if (!id) return []
+ return globalSync.data.session_todo[id] ?? []
+ })
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
- setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@@ -751,12 +758,6 @@ export default function Page() {
),
)
- createEffect(() => {
- const id = lastUserMessage()?.id
- if (!id) return
- setStore("expanded", id, status().type !== "idle")
- })
-
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@@ -767,6 +768,11 @@ export default function Page() {
return lines.slice(0, 2).join("\n")
}
+ const addSelectionToContext = (path: string, selection: FileSelection) => {
+ const preview = selectionPreview(path, selection)
+ prompt.context.add({ type: "file", path, selection, preview })
+ }
+
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -806,8 +812,8 @@ export default function Page() {
return
}
- // Don't autofocus chat if terminal panel is open
- if (view().terminal.opened()) return
+ // Don't autofocus chat if desktop terminal panel is open
+ if (isDesktop() && view().terminal.opened()) return
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@@ -905,11 +911,29 @@ export default function Page() {
const focusInput = () => inputRef?.focus()
useSessionCommands({
- activeMessage,
+ command,
+ dialog,
+ file,
+ language,
+ local,
+ permission,
+ prompt,
+ sdk,
+ sync,
+ terminal,
+ layout,
+ params,
+ navigate,
+ tabs,
+ view,
+ info,
+ status,
+ userMessages,
+ visibleUserMessages,
showAllFiles,
navigateMessageByOffset,
- setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
+ addSelectionToContext,
focusInput,
})
@@ -933,7 +957,6 @@ export default function Page() {
onSelect={(option) => option && setStore("changes", option)}
variant="ghost"
size="large"
- triggerStyle={{ "font-size": "var(--font-size-large)" }}
/>
)
@@ -1421,12 +1444,12 @@ export default function Page() {
({ height }) => {
const next = Math.ceil(height)
- if (next === store.promptHeight) return
+ if (next === dockHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
- setStore("promptHeight", next)
+ dockHeight = next
if (stick && el) {
requestAnimationFrame(() => {
@@ -1524,13 +1547,7 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader />
- <div
- class="flex-1 min-h-0 flex"
- classList={{
- "flex-col": !isDesktop(),
- "flex-row": isDesktop(),
- }}
- >
+ <div class="flex-1 min-h-0 flex flex-col md:flex-row">
<SessionMobileTabs
open={!isDesktop() && !!params.id}
mobileTab={store.mobileTab}
@@ -1545,12 +1562,11 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
- "flex-1 pt-2 md:pt-3": true,
+ "flex-1": true,
"md:flex-none": desktopSidePanelOpen(),
}}
style={{
width: sessionPanelWidth(),
- "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
@@ -1562,7 +1578,7 @@ export default function Page() {
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ root: "pb-8",
header: "px-4",
container: "px-4",
},
@@ -1627,8 +1643,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
- expanded={store.expanded}
- onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>
@@ -1659,6 +1673,7 @@ export default function Page() {
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
+ todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
@@ -1731,7 +1746,7 @@ export default function Page() {
</div>
<TerminalPanel
- open={view().terminal.opened()}
+ open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index d5f04ccf9..53cf441d6 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -4,10 +4,10 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { InlineInput } from "@opencode-ai/ui/inline-input"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+import { SessionContextUsage } from "@/components/session-context-usage"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@@ -88,8 +88,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
- expanded: Record<string, boolean>
- onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@@ -100,7 +98,7 @@ export function MessageTimeline(props: {
>
<div class="relative w-full h-full min-w-0">
<div
- class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
+ class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
@@ -164,14 +162,15 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
- "sticky top-0 z-30 bg-background-stronger": true,
+ "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
- "px-4 md:px-6": true,
+ "pb-4": true,
+ "pl-2 pr-4 md:pl-4 md:pr-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
- <div class="h-10 w-full flex items-center justify-between gap-2">
- <div class="flex items-center gap-1 min-w-0 flex-1">
+ <div class="h-12 w-full flex items-center justify-between gap-2">
+ <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={props.parentID}>
<IconButton
tabIndex={-1}
@@ -185,7 +184,10 @@ export function MessageTimeline(props: {
<Show
when={props.titleState.editing}
fallback={
- <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
+ <h1
+ class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
+ onDblClick={props.openTitleEditor}
+ >
{props.title}
</h1>
}
@@ -194,7 +196,8 @@ export function MessageTimeline(props: {
ref={props.titleRef}
value={props.titleState.draft}
disabled={props.titleState.saving}
- class="text-16-medium text-text-strong grow-1 min-w-0"
+ class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
+ style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
@@ -215,19 +218,24 @@ export function MessageTimeline(props: {
</div>
<Show when={props.sessionID}>
{(id) => (
- <div class="shrink-0 flex items-center">
- <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
- <Tooltip value={props.t("common.moreOptions")} placement="top">
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={props.t("common.moreOptions")}
- />
- </Tooltip>
+ <div class="shrink-0 flex items-center gap-3">
+ <SessionContextUsage placement="bottom" />
+ <DropdownMenu
+ gutter={4}
+ placement="bottom-end"
+ open={props.titleState.menuOpen}
+ onOpenChange={props.onTitleMenuOpen}
+ >
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ aria-label={props.t("common.moreOptions")}
+ />
<DropdownMenu.Portal>
<DropdownMenu.Content
+ style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!props.titleState.pendingRename) return
event.preventDefault()
@@ -263,7 +271,7 @@ export function MessageTimeline(props: {
<div
ref={props.setContentRef}
role="log"
- class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+ class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -316,8 +324,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
- stepsExpanded={props.expanded[message.id] ?? false}
- onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
index 8ec4f3b9f..2b9571982 100644
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -1,16 +1,17 @@
-import { For, Show } from "solid-js"
-import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
-import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
+import { SessionTodoDock } from "@/components/session-todo-dock"
export function SessionPromptDock(props: {
centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
+ todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
@@ -22,10 +23,88 @@ export function SessionPromptDock(props: {
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
+ const done = createMemo(
+ () =>
+ props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
+ )
+
+ const [dock, setDock] = createSignal(props.todos.length > 0)
+ const [closing, setClosing] = createSignal(false)
+ const [opening, setOpening] = createSignal(false)
+ let timer: number | undefined
+ let raf: number | undefined
+
+ const scheduleClose = () => {
+ if (timer) window.clearTimeout(timer)
+ timer = window.setTimeout(() => {
+ setDock(false)
+ setClosing(false)
+ timer = undefined
+ }, 400)
+ }
+
+ createEffect(
+ on(
+ () => [props.todos.length, done()] as const,
+ ([count, complete], prev) => {
+ if (raf) cancelAnimationFrame(raf)
+ raf = undefined
+
+ if (count === 0) {
+ if (timer) window.clearTimeout(timer)
+ timer = undefined
+ setDock(false)
+ setClosing(false)
+ setOpening(false)
+ return
+ }
+
+ if (!complete) {
+ if (timer) window.clearTimeout(timer)
+ timer = undefined
+ const wasHidden = !dock() || closing()
+ setDock(true)
+ setClosing(false)
+ if (wasHidden) {
+ setOpening(true)
+ raf = requestAnimationFrame(() => {
+ setOpening(false)
+ raf = undefined
+ })
+ return
+ }
+ setOpening(false)
+ return
+ }
+
+ if (prev && prev[1]) {
+ if (closing() && !timer) scheduleClose()
+ return
+ }
+
+ setDock(true)
+ setOpening(false)
+ setClosing(true)
+ scheduleClose()
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ if (!timer) return
+ window.clearTimeout(timer)
+ })
+
+ onCleanup(() => {
+ if (!raf) return
+ cancelAnimationFrame(raf)
+ })
+
return (
<div
ref={props.setPromptDockRef}
- class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
+ data-component="session-prompt-dock"
+ class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
@@ -35,18 +114,8 @@ export function SessionPromptDock(props: {
>
<Show when={props.questionRequest()} keyed>
{(req) => {
- const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
return (
- <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
- <BasicTool
- icon="bubble-5"
- locked
- defaultOpen
- trigger={{
- title: props.t("ui.tool.questions"),
- subtitle,
- }}
- />
+ <div>
<QuestionDock request={req} />
</div>
)
@@ -122,12 +191,39 @@ export function SessionPromptDock(props: {
</div>
}
>
- <PromptInput
- ref={props.inputRef}
- newSessionWorktree={props.newSessionWorktree}
- onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
- onSubmit={props.onSubmit}
- />
+ <Show when={dock()}>
+ <div
+ classList={{
+ "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
+ "max-h-[320px]": !closing(),
+ "max-h-0 pointer-events-none": closing(),
+ "opacity-0 translate-y-9": closing() || opening(),
+ "opacity-100 translate-y-0": !closing() && !opening(),
+ }}
+ >
+ <SessionTodoDock
+ todos={props.todos}
+ title={props.t("session.todo.title")}
+ collapseLabel={props.t("session.todo.collapse")}
+ expandLabel={props.t("session.todo.expand")}
+ />
+ </div>
+ </Show>
+ <div
+ classList={{
+ "relative z-10": true,
+ "transition-[margin] duration-[400ms] ease-out": true,
+ "-mt-9": dock() && !closing(),
+ "mt-0": !dock() || closing(),
+ }}
+ >
+ <PromptInput
+ ref={props.inputRef}
+ newSessionWorktree={props.newSessionWorktree}
+ onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+ onSubmit={props.onSubmit}
+ />
+ </div>
</Show>
</Show>
</div>
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index d2f74288f..4d68afd66 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -22,11 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
- activeMessage: () => UserMessage | undefined
+ command: ReturnType<typeof useCommand>
+ dialog: ReturnType<typeof useDialog>
+ file: ReturnType<typeof useFile>
+ language: ReturnType<typeof useLanguage>
+ local: ReturnType<typeof useLocal>
+ permission: ReturnType<typeof usePermission>
+ prompt: ReturnType<typeof usePrompt>
+ sdk: ReturnType<typeof useSDK>
+ sync: ReturnType<typeof useSync>
+ terminal: ReturnType<typeof useTerminal>
+ layout: ReturnType<typeof useLayout>
+ params: ReturnType<typeof useParams>
+ navigate: ReturnType<typeof useNavigate>
+ tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
+ status: () => { type: string }
+ userMessages: () => UserMessage[]
+ visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
- setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
+ addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@@ -37,88 +55,45 @@ const withCategory = (category: string) => {
})
}
-export const useSessionCommands = (args: SessionCommandContext) => {
- const command = useCommand()
- const dialog = useDialog()
- const file = useFile()
- const language = useLanguage()
- const local = useLocal()
- const permission = usePermission()
- const prompt = usePrompt()
- const sdk = useSDK()
- const sync = useSync()
- const terminal = useTerminal()
- const layout = useLayout()
- const params = useParams()
- const navigate = useNavigate()
-
- const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
- const tabs = createMemo(() => layout.tabs(sessionKey))
- const view = createMemo(() => layout.view(sessionKey))
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
- const idle = { type: "idle" as const }
- const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
- const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
- const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
- const visibleUserMessages = createMemo(() => {
- const revert = info()?.revert?.messageID
- if (!revert) return userMessages()
- return userMessages().filter((m) => m.id < revert)
- })
-
- const selectionPreview = (path: string, selection: FileSelection) => {
- const content = file.get(path)?.content?.content
- if (!content) return undefined
- const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
- const end = Math.max(selection.startLine, selection.endLine)
- const lines = content.split("\n").slice(start - 1, end)
- if (lines.length === 0) return undefined
- return lines.slice(0, 2).join("\n")
- }
-
- const addSelectionToContext = (path: string, selection: FileSelection) => {
- const preview = selectionPreview(path, selection)
- prompt.context.add({ type: "file", path, selection, preview })
- }
-
- const sessionCommand = withCategory(language.t("command.category.session"))
- const fileCommand = withCategory(language.t("command.category.file"))
- const contextCommand = withCategory(language.t("command.category.context"))
- const viewCommand = withCategory(language.t("command.category.view"))
- const terminalCommand = withCategory(language.t("command.category.terminal"))
- const modelCommand = withCategory(language.t("command.category.model"))
- const mcpCommand = withCategory(language.t("command.category.mcp"))
- const agentCommand = withCategory(language.t("command.category.agent"))
- const permissionsCommand = withCategory(language.t("command.category.permissions"))
+export const useSessionCommands = (input: SessionCommandContext) => {
+ const sessionCommand = withCategory(input.language.t("command.category.session"))
+ const fileCommand = withCategory(input.language.t("command.category.file"))
+ const contextCommand = withCategory(input.language.t("command.category.context"))
+ const viewCommand = withCategory(input.language.t("command.category.view"))
+ const terminalCommand = withCategory(input.language.t("command.category.terminal"))
+ const modelCommand = withCategory(input.language.t("command.category.model"))
+ const mcpCommand = withCategory(input.language.t("command.category.mcp"))
+ const agentCommand = withCategory(input.language.t("command.category.agent"))
+ const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
- title: language.t("command.session.new"),
+ title: input.language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
- onSelect: () => navigate(`/${params.dir}/session`),
+ onSelect: () => input.navigate(`/${input.params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
- title: language.t("command.file.open"),
- description: language.t("palette.search.placeholder"),
+ title: input.language.t("command.file.open"),
+ description: input.language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
- onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
+ onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}),
fileCommand({
id: "tab.close",
- title: language.t("command.tab.close"),
+ title: input.language.t("command.tab.close"),
keybind: "mod+w",
- disabled: !tabs().active(),
+ disabled: !input.tabs().active(),
onSelect: () => {
- const active = tabs().active()
+ const active = input.tabs().active()
if (!active) return
- tabs().close(active)
+ input.tabs().close(active)
},
}),
])
@@ -126,30 +101,30 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
- title: language.t("command.context.addSelection"),
- description: language.t("command.context.addSelection.description"),
+ title: input.language.t("command.context.addSelection"),
+ description: input.language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
- active: tabs().active(),
- pathFromTab: file.pathFromTab,
- selectedLines: file.selectedLines,
+ active: input.tabs().active(),
+ pathFromTab: input.file.pathFromTab,
+ selectedLines: input.file.selectedLines,
}),
onSelect: () => {
- const active = tabs().active()
+ const active = input.tabs().active()
if (!active) return
- const path = file.pathFromTab(active)
+ const path = input.file.pathFromTab(active)
if (!path) return
- const range = file.selectedLines(path) as SelectedLineRange | null | undefined
+ const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
- title: language.t("toast.context.noLineSelection.title"),
- description: language.t("toast.context.noLineSelection.description"),
+ title: input.language.t("toast.context.noLineSelection.title"),
+ description: input.language.t("toast.context.noLineSelection.description"),
})
return
}
- addSelectionToContext(path, selectionFromLines(range))
+ input.addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@@ -157,50 +132,37 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
- title: language.t("command.terminal.toggle"),
+ title: input.language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
- onSelect: () => view().terminal.toggle(),
+ onSelect: () => input.view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
- title: language.t("command.review.toggle"),
+ title: input.language.t("command.review.toggle"),
keybind: "mod+shift+r",
- onSelect: () => view().reviewPanel.toggle(),
+ onSelect: () => input.view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
- title: language.t("command.fileTree.toggle"),
+ title: input.language.t("command.fileTree.toggle"),
keybind: "mod+\\",
- onSelect: () => layout.fileTree.toggle(),
+ onSelect: () => input.layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
- title: language.t("command.input.focus"),
+ title: input.language.t("command.input.focus"),
keybind: "ctrl+l",
- onSelect: () => args.focusInput(),
+ onSelect: () => input.focusInput(),
}),
terminalCommand({
id: "terminal.new",
- title: language.t("command.terminal.new"),
- description: language.t("command.terminal.new.description"),
+ title: input.language.t("command.terminal.new"),
+ description: input.language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
- if (terminal.all().length > 0) terminal.new()
- view().terminal.open()
- },
- }),
- viewCommand({
- id: "steps.toggle",
- title: language.t("command.steps.toggle"),
- description: language.t("command.steps.toggle.description"),
- keybind: "mod+e",
- slash: "steps",
- disabled: !params.id,
- onSelect: () => {
- const msg = args.activeMessage()
- if (!msg) return
- args.setExpanded(msg.id, (open: boolean | undefined) => !open)
+ if (input.terminal.all().length > 0) input.terminal.new()
+ input.view().terminal.open()
},
}),
])
@@ -208,61 +170,61 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
- title: language.t("command.message.previous"),
- description: language.t("command.message.previous.description"),
+ title: input.language.t("command.message.previous"),
+ description: input.language.t("command.message.previous.description"),
keybind: "mod+arrowup",
- disabled: !params.id,
- onSelect: () => args.navigateMessageByOffset(-1),
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
- title: language.t("command.message.next"),
- description: language.t("command.message.next.description"),
+ title: input.language.t("command.message.next"),
+ description: input.language.t("command.message.next.description"),
keybind: "mod+arrowdown",
- disabled: !params.id,
- onSelect: () => args.navigateMessageByOffset(1),
+ disabled: !input.params.id,
+ onSelect: () => input.navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
- title: language.t("command.model.choose"),
- description: language.t("command.model.choose.description"),
+ title: input.language.t("command.model.choose"),
+ description: input.language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
- onSelect: () => dialog.show(() => <DialogSelectModel />),
+ onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
- title: language.t("command.mcp.toggle"),
- description: language.t("command.mcp.toggle.description"),
+ title: input.language.t("command.mcp.toggle"),
+ description: input.language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
- onSelect: () => dialog.show(() => <DialogSelectMcp />),
+ onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
- title: language.t("command.agent.cycle"),
- description: language.t("command.agent.cycle.description"),
+ title: input.language.t("command.agent.cycle"),
+ description: input.language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
- onSelect: () => local.agent.move(1),
+ onSelect: () => input.local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
- title: language.t("command.agent.cycle.reverse"),
- description: language.t("command.agent.cycle.reverse.description"),
+ title: input.language.t("command.agent.cycle.reverse"),
+ description: input.language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
- onSelect: () => local.agent.move(-1),
+ onSelect: () => input.local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
- title: language.t("command.model.variant.cycle"),
- description: language.t("command.model.variant.cycle.description"),
+ title: input.language.t("command.model.variant.cycle"),
+ description: input.language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
- local.model.variant.cycle()
+ input.local.model.variant.cycle()
},
}),
])
@@ -271,22 +233,22 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
- params.id && permission.isAutoAccepting(params.id, sdk.directory)
- ? language.t("command.permissions.autoaccept.disable")
- : language.t("command.permissions.autoaccept.enable"),
+ input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
+ ? input.language.t("command.permissions.autoaccept.disable")
+ : input.language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
- disabled: !params.id || !permission.permissionsEnabled(),
+ disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => {
- const sessionID = params.id
+ const sessionID = input.params.id
if (!sessionID) return
- permission.toggleAutoAccept(sessionID, sdk.directory)
+ input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
showToast({
- title: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.title")
- : language.t("toast.permissions.autoaccept.off.title"),
- description: permission.isAutoAccepting(sessionID, sdk.directory)
- ? language.t("toast.permissions.autoaccept.on.description")
- : language.t("toast.permissions.autoaccept.off.description"),
+ title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.title")
+ : input.language.t("toast.permissions.autoaccept.off.title"),
+ description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+ ? input.language.t("toast.permissions.autoaccept.on.description")
+ : input.language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@@ -295,71 +257,71 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
- title: language.t("command.session.undo"),
- description: language.t("command.session.undo.description"),
+ title: input.language.t("command.session.undo"),
+ description: input.language.t("command.session.undo.description"),
slash: "undo",
- disabled: !params.id || visibleUserMessages().length === 0,
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
- const sessionID = params.id
+ const sessionID = input.params.id
if (!sessionID) return
- if (status()?.type !== "idle") {
- await sdk.client.session.abort({ sessionID }).catch(() => {})
+ if (input.status()?.type !== "idle") {
+ await input.sdk.client.session.abort({ sessionID }).catch(() => {})
}
- const revert = info()?.revert?.messageID
- const message = findLast(userMessages(), (x) => !revert || x.id < revert)
+ const revert = input.info()?.revert?.messageID
+ const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
if (!message) return
- await sdk.client.session.revert({ sessionID, messageID: message.id })
- const parts = sync.data.part[message.id]
+ await input.sdk.client.session.revert({ sessionID, messageID: message.id })
+ const parts = input.sync.data.part[message.id]
if (parts) {
- const restored = extractPromptFromParts(parts, { directory: sdk.directory })
- prompt.set(restored)
+ const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
+ input.prompt.set(restored)
}
- const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
- args.setActiveMessage(priorMessage)
+ const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
+ input.setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
- title: language.t("command.session.redo"),
- description: language.t("command.session.redo.description"),
+ title: input.language.t("command.session.redo"),
+ description: input.language.t("command.session.redo.description"),
slash: "redo",
- disabled: !params.id || !info()?.revert?.messageID,
+ disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => {
- const sessionID = params.id
+ const sessionID = input.params.id
if (!sessionID) return
- const revertMessageID = info()?.revert?.messageID
+ const revertMessageID = input.info()?.revert?.messageID
if (!revertMessageID) return
- const nextMessage = userMessages().find((x) => x.id > revertMessageID)
+ const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
- await sdk.client.session.unrevert({ sessionID })
- prompt.reset()
- const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
- args.setActiveMessage(lastMsg)
+ await input.sdk.client.session.unrevert({ sessionID })
+ input.prompt.reset()
+ const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
+ input.setActiveMessage(lastMsg)
return
}
- await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
- args.setActiveMessage(priorMsg)
+ await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+ const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
+ input.setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
- title: language.t("command.session.compact"),
- description: language.t("command.session.compact.description"),
+ title: input.language.t("command.session.compact"),
+ description: input.language.t("command.session.compact.description"),
slash: "compact",
- disabled: !params.id || visibleUserMessages().length === 0,
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
- const sessionID = params.id
+ const sessionID = input.params.id
if (!sessionID) return
- const model = local.model.current()
+ const model = input.local.model.current()
if (!model) {
showToast({
- title: language.t("toast.model.none.title"),
- description: language.t("toast.model.none.description"),
+ title: input.language.t("toast.model.none.title"),
+ description: input.language.t("toast.model.none.description"),
})
return
}
- await sdk.client.session.summarize({
+ await input.sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@@ -368,27 +330,29 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
- title: language.t("command.session.fork"),
- description: language.t("command.session.fork.description"),
+ title: input.language.t("command.session.fork"),
+ description: input.language.t("command.session.fork.description"),
slash: "fork",
- disabled: !params.id || visibleUserMessages().length === 0,
- onSelect: () => dialog.show(() => <DialogFork />),
+ disabled: !input.params.id || input.visibleUserMessages().length === 0,
+ onSelect: () => input.dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
- if (sync.data.config.share === "disabled") return []
+ if (input.sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
- title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
- description: info()?.share?.url
- ? language.t("toast.session.share.success.description")
- : language.t("command.session.share.description"),
+ title: input.info()?.share?.url
+ ? input.language.t("session.share.copy.copyLink")
+ : input.language.t("command.session.share"),
+ description: input.info()?.share?.url
+ ? input.language.t("toast.session.share.success.description")
+ : input.language.t("command.session.share.description"),
slash: "share",
- disabled: !params.id,
+ disabled: !input.params.id,
onSelect: async () => {
- if (!params.id) return
+ if (!input.params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@@ -418,7 +382,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
- title: language.t("toast.session.share.copyFailed.title"),
+ title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@@ -426,27 +390,27 @@ export const useSessionCommands = (args: SessionCommandContext) => {
showToast({
title: existing
- ? language.t("session.share.copy.copied")
- : language.t("toast.session.share.success.title"),
- description: language.t("toast.session.share.success.description"),
+ ? input.language.t("session.share.copy.copied")
+ : input.language.t("toast.session.share.success.title"),
+ description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
- const existing = info()?.share?.url
+ const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
- const url = await sdk.client.session
- .share({ sessionID: params.id })
+ const url = await input.sdk.client.session
+ .share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
- title: language.t("toast.session.share.failed.title"),
- description: language.t("toast.session.share.failed.description"),
+ title: input.language.t("toast.session.share.failed.title"),
+ description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@@ -457,25 +421,25 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
- title: language.t("command.session.unshare"),
- description: language.t("command.session.unshare.description"),
+ title: input.language.t("command.session.unshare"),
+ description: input.language.t("command.session.unshare.description"),
slash: "unshare",
- disabled: !params.id || !info()?.share?.url,
+ disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .unshare({ sessionID: params.id })
+ if (!input.params.id) return
+ await input.sdk.client.session
+ .unshare({ sessionID: input.params.id })
.then(() =>
showToast({
- title: language.t("toast.session.unshare.success.title"),
- description: language.t("toast.session.unshare.success.description"),
+ title: input.language.t("toast.session.unshare.success.title"),
+ description: input.language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
- title: language.t("toast.session.unshare.failed.title"),
- description: language.t("toast.session.unshare.failed.description"),
+ title: input.language.t("toast.session.unshare.failed.title"),
+ description: input.language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@@ -484,7 +448,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
]
})
- command.register("session", () =>
+ input.command.register("session", () =>
[
sessionCommands(),
fileCommands(),
@@ -495,6 +459,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionCommands(),
sessionActionCommands(),
shareCommands(),
- ].flatMap((section) => section),
+ ].flatMap((x) => x),
)
}