diff options
| author | Adam <[email protected]> | 2026-02-17 07:16:23 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-17 07:16:23 -0600 |
| commit | 10985671ad9553e7ac594ede30981166f69ba3c5 (patch) | |
| tree | 0f501ccba5dcf90e86beb18a0732826f7a3b1d0a /packages/app/src | |
| parent | 3dfbb7059345350fdcb3f45fe9a44697c08a040a (diff) | |
| download | opencode-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')
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), ) } |
