diff options
Diffstat (limited to 'packages/app/src/components')
14 files changed, 946 insertions, 377 deletions
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index a196db231..9f7afb8cd 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={4} > <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e21798738..7813e01cd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -94,7 +93,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const local = useLocal() const files = useFile() const prompt = usePrompt() - const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length) const layout = useLayout() const comments = useComments() const params = useParams() @@ -105,7 +103,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const language = useLanguage() const platform = usePlatform() let editorRef!: HTMLDivElement - let fileInputRef!: HTMLInputElement + let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement @@ -223,14 +221,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { mode: "normal", applyingHistory: false, }) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: language.t(EXAMPLES[store.placeholder]), - t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never), - }), - ) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + + const hasUserPrompt = createMemo(() => { + const sessionID = params.id + if (!sessionID) return false + const messages = sync.data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -250,6 +259,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }), ) + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never), + }), + ) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -282,6 +303,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + const pick = () => fileInputRef?.click() + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => editorRef?.focus()) + } + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + ]) + const closePopover = () => setStore("popover", null) const resetHistoryNavigation = (force = false) => { @@ -326,6 +366,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { createEffect(() => { params.id if (params.id) return + if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) }, 6500) @@ -816,6 +857,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + event.preventDefault() + if (store.mode !== "normal") return + pick() + return + } + if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { @@ -956,8 +1004,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } + const variants = createMemo(() => ["default", ...local.model.variant.list()]) + return ( - <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> + <div class="relative size-full _max-h-[320px] flex flex-col gap-0"> <PromptPopover popover={store.popover} setSlashPopoverRef={(el) => (slashPopoverRef = el)} @@ -977,8 +1027,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onSubmit={handleSubmit} classList={{ "group/prompt-input": true, - "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, - "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, + "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} @@ -988,7 +1038,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} /> <PromptContextItems - items={prompt.context.items()} + items={contextItems()} active={(item) => { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -1008,7 +1058,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} /> - <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}> + <div + class="relative max-h-[240px] overflow-y-auto" + ref={(el) => (scrollRef = el)} + onMouseDown={(e) => { + const target = e.target + if (!(target instanceof HTMLElement)) return + if ( + target.closest( + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + ) + ) { + return + } + editorRef?.focus() + }} + > <div data-component="prompt-input" ref={(el) => { @@ -1029,41 +1094,158 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> <Show when={!prompt.dirty()}> - <div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> + <div + class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate" + classList={{ "font-mono!": store.mode === "shell" }} + > {placeholder()} </div> </Show> + + <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2"> + <input + ref={fileInputRef} + type="file" + accept={ACCEPTED_FILE_TYPES.join(",")} + class="hidden" + onChange={(e) => { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} + /> + + <div + aria-hidden={store.mode !== "normal"} + class="flex items-center gap-1 transition-all duration-200 ease-out" + classList={{ + "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal", + "opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal", + }} + > + <TooltipKeybind + placement="top" + title={language.t("prompt.action.attachFile")} + keybind={command.keybind("file.attach")} + > + <Button + data-action="prompt-attach" + type="button" + variant="ghost" + class="size-8 p-0" + onClick={pick} + disabled={store.mode !== "normal"} + tabIndex={store.mode === "normal" ? undefined : -1} + aria-label={language.t("prompt.action.attachFile")} + > + <Icon name="plus" class="size-4.5" /> + </Button> + </TooltipKeybind> + + <Tooltip + placement="top" + inactive={!prompt.dirty() && !working()} + value={ + <Switch> + <Match when={working()}> + <div class="flex items-center gap-2"> + <span>{language.t("prompt.action.stop")}</span> + <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span> + </div> + </Match> + <Match when={true}> + <div class="flex items-center gap-2"> + <span>{language.t("prompt.action.send")}</span> + <Icon name="enter" size="small" class="text-icon-base" /> + </div> + </Match> + </Switch> + } + > + <IconButton + data-action="prompt-submit" + type="submit" + disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)} + tabIndex={store.mode === "normal" ? undefined : -1} + icon={working() ? "stop" : "arrow-up"} + variant="primary" + class="size-8" + aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} + /> + </Tooltip> + </div> + </div> + + <Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}> + <div class="pointer-events-none absolute bottom-2 left-2"> + <div class="pointer-events-auto"> + <TooltipKeybind + placement="top" + gutter={8} + title={language.t("command.permissions.autoaccept.enable")} + keybind={command.keybind("permissions.autoaccept")} + > + <Button + data-action="prompt-permissions" + variant="ghost" + onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} + classList={{ + "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), + "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), + }} + aria-label={ + permission.isAutoAccepting(params.id!, sdk.directory) + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable") + } + aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)} + > + <Icon + name="chevron-double-right" + size="small" + classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }} + /> + </Button> + </TooltipKeybind> + </div> + </div> + </Show> </div> - <div class="relative p-3 flex items-center justify-between gap-2"> - <div class="flex items-center gap-2 min-w-0 flex-1"> - <Switch> - <Match when={store.mode === "shell"}> - <div class="flex items-center gap-2 px-2 h-6"> - <Icon name="console" size="small" class="text-icon-primary" /> - <span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span> - <span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span> + </form> + <Show when={store.mode === "normal" || store.mode === "shell"}> + <div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip"> + <div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> + <div class="flex items-center gap-1.5 min-w-0 flex-1"> + <Show when={store.mode === "shell"}> + <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}> + <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span> + <div class="size-4 shrink-0" /> </div> - </Match> - <Match when={store.mode === "normal"}> + </Show> + + <Show when={store.mode === "normal"}> <TooltipKeybind placement="top" - gutter={8} + gutter={4} title={language.t("command.agent.cycle")} keybind={command.keybind("agent.cycle")} > <Select + size="normal" options={agentNames()} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`} - valueClass="truncate" + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} variant="ghost" /> </TooltipKeybind> @@ -1072,18 +1254,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => { fallback={ <TooltipKeybind placement="top" - gutter={8} + gutter={4} title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > <Button as="div" variant="ghost" - class="px-2 min-w-0 max-w-[240px]" + size="normal" + class="min-w-0 max-w-[320px] text-13-regular group" + style={{ height: "28px" }} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} > <Show when={local.model.current()?.provider?.id}> - <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> + <ProviderIcon + id={local.model.current()!.provider.id as IconName} + class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" + style={{ "will-change": "opacity", transform: "translateZ(0)" }} + /> </Show> <span class="truncate"> {local.model.current()?.name ?? language.t("dialog.model.select.title")} @@ -1095,16 +1283,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { > <TooltipKeybind placement="top" - gutter={8} + gutter={4} title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > <ModelSelectorPopover triggerAs={Button} - triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }} + triggerProps={{ + variant: "ghost", + size: "normal", + style: { height: "28px" }, + class: "min-w-0 max-w-[320px] text-13-regular group", + }} > <Show when={local.model.current()?.provider?.id}> - <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> + <ProviderIcon + id={local.model.current()!.provider.id as IconName} + class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" + style={{ "will-change": "opacity", transform: "translateZ(0)" }} + /> </Show> <span class="truncate"> {local.model.current()?.name ?? language.t("dialog.model.select.title")} @@ -1113,116 +1310,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </ModelSelectorPopover> </TooltipKeybind> </Show> - <Show when={local.model.variant.list().length > 0}> - <TooltipKeybind - placement="top" - gutter={8} - title={language.t("command.model.variant.cycle")} - keybind={command.keybind("model.variant.cycle")} - > - <Button - data-action="model-variant-cycle" - variant="ghost" - class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular" - onClick={() => local.model.variant.cycle()} - > - {local.model.variant.current() ?? language.t("common.default")} - </Button> - </TooltipKeybind> - </Show> - <Show when={permission.permissionsEnabled() && params.id}> - <TooltipKeybind - placement="top" - gutter={8} - title={language.t("command.permissions.autoaccept.enable")} - keybind={command.keybind("permissions.autoaccept")} - > - <Button - variant="ghost" - onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} - classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, - "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), - "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), - }} - aria-label={ - permission.isAutoAccepting(params.id!, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable") - } - aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)} - > - <Icon - name="chevron-double-right" - size="small" - classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }} - /> - </Button> - </TooltipKeybind> - </Show> - </Match> - </Switch> - </div> - <div class="flex items-center gap-1 shrink-0"> - <input - ref={fileInputRef} - type="file" - accept={ACCEPTED_FILE_TYPES.join(",")} - class="hidden" - onChange={(e) => { - const file = e.currentTarget.files?.[0] - if (file) addImageAttachment(file) - e.currentTarget.value = "" - }} - /> - <div class="flex items-center gap-1 mr-1"> - <SessionContextUsage /> - <Show when={store.mode === "normal"}> - <Tooltip placement="top" value={language.t("prompt.action.attachFile")}> - <Button - type="button" + <TooltipKeybind + placement="top" + gutter={4} + title={language.t("command.model.variant.cycle")} + keybind={command.keybind("model.variant.cycle")} + > + <Select + size="normal" + options={variants()} + current={local.model.variant.current() ?? "default"} + label={(x) => (x === "default" ? language.t("common.default") : x)} + onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} variant="ghost" - class="size-6 px-1" - onClick={() => fileInputRef.click()} - aria-label={language.t("prompt.action.attachFile")} - > - <Icon name="photo" class="size-4.5" /> - </Button> - </Tooltip> + /> + </TooltipKeybind> </Show> </div> - <Tooltip - placement="top" - inactive={!prompt.dirty() && !working()} - value={ - <Switch> - <Match when={working()}> - <div class="flex items-center gap-2"> - <span>{language.t("prompt.action.stop")}</span> - <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span> - </div> - </Match> - <Match when={true}> - <div class="flex items-center gap-2"> - <span>{language.t("prompt.action.send")}</span> - <Icon name="enter" size="small" class="text-icon-base" /> - </div> - </Match> - </Switch> - } - > - <IconButton - type="submit" - disabled={!prompt.dirty() && !working() && commentCount() === 0} - icon={working() ? "stop" : "arrow-up"} - variant="primary" - class="h-6 w-4.5" - aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} - /> - </Tooltip> + + <div class="shrink-0"> + <div + data-component="prompt-mode-toggle" + class="relative h-8 w-[68px] rounded-[4px] bg-surface-inset-base shadow-[var(--shadow-xs-border-base)] p-0 flex items-center gap-1 overflow-visible" + > + <div + class="absolute inset-y-0 left-0 w-[calc((100%-4px)/2)] rounded-[4px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-xs-border)] transition-transform duration-200 ease-out will-change-transform" + style={{ + transform: store.mode === "shell" ? "translateX(0px)" : "translateX(calc(100% + 4px))", + }} + /> + <button + type="button" + class="relative z-10 flex-1 h-full flex items-center justify-center rounded-[4px]" + aria-pressed={store.mode === "shell"} + onClick={() => setMode("shell")} + > + <Icon + name="console" + size="normal" + classList={{ + "text-icon-strong-base": store.mode === "shell", + "text-icon-weak": store.mode !== "shell", + }} + /> + </button> + <button + type="button" + class="relative z-10 flex-1 h-full flex items-center justify-center rounded-[4px]" + aria-pressed={store.mode === "normal"} + onClick={() => setMode("normal")} + > + <Icon + name="prompt" + size="normal" + classList={{ + "text-icon-interactive-base": store.mode === "normal", + "text-icon-weak": store.mode !== "normal", + }} + /> + </button> + </div> + </div> </div> </div> - </form> + </Show> </div> ) } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b575c3961..b138fe3ef 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => { > <div classList={{ - "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, - "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected, - "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": - selected, + "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, + "hover:bg-surface-interactive-weak": !!item.commentID && !selected, + "bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected, "bg-background-stronger": !selected, }} onClick={() => props.openComment(item)} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index b633df829..5f6aa59e9 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -9,27 +9,40 @@ describe("promptPlaceholder", () => { mode: "shell", commentCount: 0, example: "example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.shell") }) test("returns summarize placeholders for comment context", () => { - expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComment", ) - expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComments", ) }) - test("returns default placeholder with example", () => { + test("returns default placeholder with example when suggestions enabled", () => { const value = promptPlaceholder({ mode: "normal", commentCount: 0, example: "translated-example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.normal:translated-example") }) + + test("returns simple placeholder when suggestions disabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: false, + t, + }) + expect(value).toBe("prompt.placeholder.simple") + }) }) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 07f6a43b5..395fee51b 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -2,6 +2,7 @@ type PromptPlaceholderInput = { mode: "normal" | "shell" commentCount: number example: string + suggest: boolean t: (key: string, params?: Record<string, string>) => string } @@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") return input.t("prompt.placeholder.normal", { example: input.example }) } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 259883d61..65eb01c79 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => { ref={(el) => { if (props.popover === "slash") props.setSlashPopoverRef(el) }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] + bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" onMouseDown={(e) => e.preventDefault()} > <Switch> diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9a1fba5d5..6b6f4a4e0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { queued.abort.abort() queued.cleanup() pending.delete(sessionID) + globalSync.todo.set(sessionID, undefined) return Promise.resolve() } return sdk.client.session @@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { sessionID, }) .catch(() => {}) + .finally(() => { + globalSync.todo.set(sessionID, undefined) + }) } const restoreCommentItems = (items: CommentItem[]) => { diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 5054253b8..1a0bbbe97 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type Component } from "solid-js" +import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,25 +12,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const language = useLanguage() const questions = createMemo(() => props.request.questions) - const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const total = createMemo(() => questions().length) const [store, setStore] = createStore({ tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], + customOn: [] as boolean[], editing: false, sending: false, }) + let root: HTMLDivElement | undefined + const question = createMemo(() => questions()[store.tab]) - const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") + const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) - const customPicked = createMemo(() => { - const value = input() - if (!value) return false - return store.answers[store.tab]?.includes(value) ?? false + + const summary = createMemo(() => { + const n = Math.min(store.tab + 1, total()) + return `${n} of ${total()} questions` + }) + + const last = createMemo(() => store.tab >= total() - 1) + + const customUpdate = (value: string, selected: boolean = on()) => { + const prev = input().trim() + const next = value.trim() + + setStore("custom", store.tab, value) + if (!selected) return + + if (multi()) { + setStore("answers", store.tab, (current = []) => { + const removed = prev ? current.filter((item) => item.trim() !== prev) : current + if (!next) return removed + if (removed.some((item) => item.trim() === next)) return removed + return [...removed, next] + }) + return + } + + setStore("answers", store.tab, next ? [next] : []) + } + + const measure = () => { + if (!root) return + + const scroller = document.querySelector(".session-scroller") + const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined + const top = + head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 + if (!top) { + root.style.removeProperty("--question-prompt-max-height") + return + } + + const dock = root.closest('[data-component="session-prompt-dock"]') + if (!(dock instanceof HTMLElement)) return + + const dockBottom = dock.getBoundingClientRect().bottom + const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) + const gap = 8 + const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) + root.style.setProperty("--question-prompt-max-height", `${max}px`) + } + + onMount(() => { + let raf: number | undefined + const update = () => { + if (raf !== undefined) cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => { + raf = undefined + measure() + }) + } + + update() + window.addEventListener("resize", update) + + const dock = root?.closest('[data-component="session-prompt-dock"]') + const scroller = document.querySelector(".session-scroller") + const observer = new ResizeObserver(update) + if (dock instanceof HTMLElement) observer.observe(dock) + if (scroller instanceof HTMLElement) observer.observe(scroller) + + onCleanup(() => { + window.removeEventListener("resize", update) + observer.disconnect() + if (raf !== undefined) cancelAnimationFrame(raf) + }) }) const fail = (err: unknown) => { @@ -64,23 +137,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } } - const submit = () => { - void reply(questions().map((_, i) => store.answers[i] ?? [])) - } + const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) - - if (custom) { - setStore("custom", store.tab, answer) - } - - if (single()) { - void reply([[answer]]) - return - } - - setStore("tab", store.tab + 1) + if (custom) setStore("custom", store.tab, answer) + if (!custom) setStore("customOn", store.tab, false) + setStore("editing", false) } const toggle = (answer: string) => { @@ -90,16 +153,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }) } - const selectTab = (index: number) => { - setStore("tab", index) + const customToggle = () => { + if (store.sending) return + + if (!multi()) { + setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + return + } + + const next = !on() + setStore("customOn", store.tab, next) + if (next) { + setStore("editing", true) + customUpdate(input(), true) + return + } + + const value = input().trim() + if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) setStore("editing", false) } + const customOpen = () => { + if (store.sending) return + if (!on()) setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + } + const selectOption = (optIndex: number) => { if (store.sending) return if (optIndex === options().length) { - setStore("editing", true) + customOpen() return } @@ -112,67 +200,67 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => pick(opt.label) } - const handleCustomSubmit = (e: Event) => { - e.preventDefault() + const commitCustom = () => { + setStore("editing", false) + customUpdate(input()) + } + + const next = () => { if (store.sending) return + if (store.editing) commitCustom() - const value = input().trim() - if (!value) { - setStore("editing", false) + if (store.tab >= total() - 1) { + submit() return } - if (multi()) { - setStore("answers", store.tab, (current = []) => { - if (current.includes(value)) return current - return [...current, value] - }) - setStore("editing", false) - return - } + setStore("tab", store.tab + 1) + setStore("editing", false) + } - pick(value, true) + const back = () => { + if (store.sending) return + if (store.tab <= 0) return + setStore("tab", store.tab - 1) + setStore("editing", false) + } + + const jump = (tab: number) => { + if (store.sending) return + setStore("tab", tab) setStore("editing", false) } return ( - <div data-component="question-prompt"> - <Show when={!single()}> - <div data-slot="question-tabs"> - <For each={questions()}> - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( + <div data-component="question-prompt" ref={(el) => (root = el)}> + <div data-slot="question-body"> + <div data-slot="question-header"> + <div data-slot="question-header-title">{summary()}</div> + <div data-slot="question-progress"> + <For each={questions()}> + {(_, i) => ( <button - data-slot="question-tab" - data-active={active()} - data-answered={answered()} + type="button" + data-slot="question-progress-segment" + data-active={i() === store.tab} + data-answered={ + (store.answers[i()]?.length ?? 0) > 0 || + (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) + } disabled={store.sending} - onClick={() => selectTab(index())} - > - {q.header} - </button> - ) - }} - </For> - <button - data-slot="question-tab" - data-active={confirm()} - disabled={store.sending} - onClick={() => selectTab(questions().length)} - > - {language.t("ui.common.confirm")} - </button> + onClick={() => jump(i())} + aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} + /> + )} + </For> + </div> </div> - </Show> - <Show when={!confirm()}> <div data-slot="question-content"> - <div data-slot="question-text"> - {question()?.question} - {multi() ? " " + language.t("ui.question.multiHint") : ""} - </div> + <div data-slot="question-text">{question()?.question}</div> + <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}> + <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div> + </Show> <div data-slot="question-options"> <For each={options()}> {(opt, i) => { @@ -181,106 +269,156 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => <button data-slot="question-option" data-picked={picked()} + role={multi() ? "checkbox" : "radio"} + aria-checked={picked()} disabled={store.sending} onClick={() => selectOption(i())} > - <span data-slot="option-label">{opt.label}</span> - <Show when={opt.description}> - <span data-slot="option-description">{opt.description}</span> - </Show> - <Show when={picked()}> - <Icon name="check-small" size="normal" /> - </Show> + <span data-slot="question-option-check" aria-hidden="true"> + <span + data-slot="question-option-box" + data-type={multi() ? "checkbox" : "radio"} + data-picked={picked()} + > + <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> + <Icon name="check-small" size="small" /> + </Show> + </span> + </span> + <span data-slot="question-option-main"> + <span data-slot="option-label">{opt.label}</span> + <Show when={opt.description}> + <span data-slot="option-description">{opt.description}</span> + </Show> + </span> </button> ) }} </For> - <button - data-slot="question-option" - data-picked={customPicked()} - disabled={store.sending} - onClick={() => selectOption(options().length)} - > - <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> - <Show when={!store.editing && input()}> - <span data-slot="option-description">{input()}</span> - </Show> - <Show when={customPicked()}> - <Icon name="check-small" size="normal" /> - </Show> - </button> - <Show when={store.editing}> - <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}> - <input - ref={(el) => setTimeout(() => el.focus(), 0)} - type="text" - data-slot="custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} + + <Show + when={store.editing} + fallback={ + <button + data-slot="question-option" + data-custom="true" + data-picked={on()} + role={multi() ? "checkbox" : "radio"} + aria-checked={on()} disabled={store.sending} - onInput={(e) => { - setStore("custom", store.tab, e.currentTarget.value) + onClick={customOpen} + > + <span + data-slot="question-option-check" + aria-hidden="true" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + customToggle() + }} + > + <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}> + <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> + <Icon name="check-small" size="small" /> + </Show> + </span> + </span> + <span data-slot="question-option-main"> + <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> + <span data-slot="option-description"> + {input() || language.t("ui.question.custom.placeholder")} + </span> + </span> + </button> + } + > + <form + data-slot="question-option" + data-custom="true" + data-picked={on()} + role={multi() ? "checkbox" : "radio"} + aria-checked={on()} + onMouseDown={(e) => { + if (store.sending) { + e.preventDefault() + return + } + if (e.target instanceof HTMLTextAreaElement) return + const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(e) => { + e.preventDefault() + commitCustom() + }} + > + <span + data-slot="question-option-check" + aria-hidden="true" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + customToggle() }} - /> - <Button type="submit" variant="primary" size="small" disabled={store.sending}> - {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")} - </Button> - <Button - type="button" - variant="ghost" - size="small" - disabled={store.sending} - onClick={() => setStore("editing", false)} > - {language.t("ui.common.cancel")} - </Button> + <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}> + <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> + <Icon name="check-small" size="small" /> + </Show> + </span> + </span> + <span data-slot="question-option-main"> + <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> + <textarea + ref={(el) => + setTimeout(() => { + el.focus() + el.style.height = "0px" + el.style.height = `${el.scrollHeight}px` + }, 0) + } + data-slot="question-custom-input" + placeholder={language.t("ui.question.custom.placeholder")} + value={input()} + rows={1} + disabled={store.sending} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + return + } + if (e.key !== "Enter" || e.shiftKey) return + e.preventDefault() + commitCustom() + }} + onInput={(e) => { + customUpdate(e.currentTarget.value) + e.currentTarget.style.height = "0px" + e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` + }} + /> + </span> </form> </Show> </div> </div> - </Show> - - <Show when={confirm()}> - <div data-slot="question-review"> - <div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div> - <For each={questions()}> - {(q, index) => { - const value = () => store.answers[index()]?.join(", ") ?? "" - const answered = () => Boolean(value()) - return ( - <div data-slot="review-item"> - <span data-slot="review-label">{q.question}</span> - <span data-slot="review-value" data-answered={answered()}> - {answered() ? value() : language.t("ui.question.review.notAnswered")} - </span> - </div> - ) - }} - </For> - </div> - </Show> + </div> - <div data-slot="question-actions"> - <Button variant="ghost" size="small" onClick={reject} disabled={store.sending}> + <div data-slot="question-footer"> + <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> {language.t("ui.common.dismiss")} </Button> - <Show when={!single()}> - <Show when={confirm()}> - <Button variant="primary" size="small" onClick={submit} disabled={store.sending}> - {language.t("ui.common.submit")} - </Button> - </Show> - <Show when={!confirm() && multi()}> - <Button - variant="secondary" - size="small" - onClick={() => selectTab(store.tab + 1)} - disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0} - > - {language.t("ui.common.next")} + <div data-slot="question-footer-actions"> + <Show when={store.tab > 0}> + <Button variant="secondary" size="large" disabled={store.sending} onClick={back}> + {language.t("ui.common.back")} </Button> </Show> - </Show> + <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}> + {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} + </Button> + </div> </div> </div> ) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c6e60d3ed..47030aa17 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,5 +1,5 @@ import { Match, Show, Switch, createMemo } from "solid-js" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" import { useParams } from "@solidjs/router" @@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m interface SessionContextUsageProps { variant?: "button" | "indicator" + placement?: TooltipProps["placement"] } function openSessionContext(args: { @@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return + + if (tabs().active() === "context") { + tabs().close("context") + return + } openSessionContext({ view: view(), layout, @@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return ( <Show when={params.id}> - <Tooltip value={tooltipValue()} placement="top"> + <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Switch> <Match when={variant() === "indicator"}>{circle()}</Match> <Match when={true}> diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx new file mode 100644 index 000000000..aeb2e421b --- /dev/null +++ b/packages/app/src/components/session-todo-dock.tsx @@ -0,0 +1,208 @@ +import type { Todo } from "@opencode-ai/sdk/v2" +import { Checkbox } from "@opencode-ai/ui/checkbox" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +function dot(status: Todo["status"]) { + if (status !== "in_progress") return undefined + return ( + <svg + viewBox="0 0 12 12" + width="12" + height="12" + fill="currentColor" + xmlns="http://www.w3.org/2000/svg" + class="block" + > + <circle + cx="6" + cy="6" + r="3" + style={{ + animation: "var(--animate-pulse-scale)", + "transform-origin": "center", + "transform-box": "fill-box", + }} + /> + </svg> + ) +} + +export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { + const [store, setStore] = createStore({ + collapsed: false, + }) + + const toggle = () => setStore("collapsed", (value) => !value) + + const summary = createMemo(() => { + const total = props.todos.length + if (total === 0) return "" + const completed = props.todos.filter((todo) => todo.status === "completed").length + return `${completed} of ${total} ${props.title.toLowerCase()} completed` + }) + + const active = createMemo( + () => + props.todos.find((todo) => todo.status === "in_progress") ?? + props.todos.find((todo) => todo.status === "pending") ?? + props.todos.filter((todo) => todo.status === "completed").at(-1) ?? + props.todos[0], + ) + + const preview = createMemo(() => active()?.content ?? "") + + return ( + <div + classList={{ + "bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true, + "h-[78px]": store.collapsed, + }} + > + <div + class="pl-3 pr-2 py-2 flex items-center gap-2" + role="button" + tabIndex={0} + onClick={toggle} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + <span class="text-14-regular text-text-strong cursor-default">{summary()}</span> + <Show when={store.collapsed}> + <div class="ml-1 flex-1 min-w-0"> + <Show when={preview()}> + <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div> + </Show> + </div> + </Show> + <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}> + <IconButton + icon="chevron-down" + size="normal" + variant="ghost" + classList={{ "rotate-180": !store.collapsed }} + onMouseDown={(event) => { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + toggle() + }} + aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + /> + </div> + </div> + + <div hidden={store.collapsed}> + <TodoList todos={props.todos} open={!store.collapsed} /> + </div> + </div> + ) +} + +function TodoList(props: { todos: Todo[]; open: boolean }) { + const [stuck, setStuck] = createSignal(false) + const [scrolling, setScrolling] = createSignal(false) + let scrollRef!: HTMLDivElement + let timer: number | undefined + + const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress")) + + const ensure = () => { + if (!props.open) return + if (scrolling()) return + if (!scrollRef || scrollRef.offsetParent === null) return + + const el = scrollRef.querySelector("[data-in-progress]") + if (!(el instanceof HTMLElement)) return + + const topFade = 16 + const bottomFade = 44 + const container = scrollRef.getBoundingClientRect() + const rect = el.getBoundingClientRect() + const top = rect.top - container.top + scrollRef.scrollTop + const bottom = rect.bottom - container.top + scrollRef.scrollTop + const viewTop = scrollRef.scrollTop + topFade + const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade + + if (top < viewTop) { + scrollRef.scrollTop = Math.max(0, top - topFade) + } else if (bottom > viewBottom) { + scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) + } + + setStuck(scrollRef.scrollTop > 0) + } + + createEffect( + on([() => props.open, inProgress], () => { + if (!props.open || inProgress() < 0) return + requestAnimationFrame(ensure) + }), + ) + + onCleanup(() => { + if (!timer) return + window.clearTimeout(timer) + }) + + return ( + <div class="relative"> + <div + class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar" + ref={scrollRef} + style={{ "overflow-anchor": "none" }} + onScroll={(e) => { + setStuck(e.currentTarget.scrollTop > 0) + setScrolling(true) + if (timer) window.clearTimeout(timer) + timer = window.setTimeout(() => { + setScrolling(false) + if (inProgress() < 0) return + requestAnimationFrame(ensure) + }, 250) + }} + > + <For each={props.todos}> + {(todo) => ( + <Checkbox + readOnly + checked={todo.status === "completed"} + indeterminate={todo.status === "in_progress"} + data-in-progress={todo.status === "in_progress" ? "" : undefined} + icon={dot(todo.status)} + style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }} + > + <span + class="text-14-regular min-w-0 break-words" + classList={{ + "text-text-weak": todo.status === "completed" || todo.status === "cancelled", + "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled", + }} + style={{ + "line-height": "var(--line-height-normal)", + "text-decoration": + todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined, + }} + > + {todo.content} + </span> + </Checkbox> + )} + </For> + </div> + <div + class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" + style={{ + background: "linear-gradient(to bottom, var(--background-base), transparent)", + opacity: stuck() ? 1 : 0, + }} + /> + </div> + ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index f81a2ec44..8bbe59e38 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -372,7 +372,7 @@ export function SessionHeader() { <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden"> <Button variant="ghost" - class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none" + class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none" onClick={() => openDir(current().id)} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} > @@ -552,14 +552,14 @@ export function SessionHeader() { </Show> </div> </Show> - <div class="flex items-center gap-3 ml-2 shrink-0"> + <div class="hidden md:flex items-center gap-3 ml-2 shrink-0"> <TooltipKeybind title={language.t("command.terminal.toggle")} keybind={command.keybind("terminal.toggle")} > <Button variant="ghost" - class="group/terminal-toggle size-6 p-0" + class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border" onClick={() => view().terminal.toggle()} aria-label={language.t("command.terminal.toggle")} aria-expanded={view().terminal.opened()} @@ -568,7 +568,7 @@ export function SessionHeader() { <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <Icon size="small" - name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"} class="group-hover/terminal-toggle:hidden" /> <Icon @@ -578,18 +578,18 @@ export function SessionHeader() { /> <Icon size="small" - name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"} class="hidden group-active/terminal-toggle:inline-block" /> </div> </Button> </TooltipKeybind> </div> - <div class="hidden lg:block shrink-0"> + <div class="hidden md:block shrink-0"> <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> <Button variant="ghost" - class="group/review-toggle size-6 p-0" + class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border" onClick={() => view().reviewPanel.toggle()} aria-label={language.t("command.review.toggle")} aria-expanded={view().reviewPanel.opened()} @@ -598,7 +598,7 @@ export function SessionHeader() { <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <Icon size="small" - name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} + name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"} class="group-hover/review-toggle:hidden" /> <Icon @@ -608,38 +608,57 @@ export function SessionHeader() { /> <Icon size="small" - name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} + name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"} class="hidden group-active/review-toggle:inline-block" /> </div> </Button> </TooltipKeybind> </div> - <div class="hidden lg:block shrink-0"> - <TooltipKeybind - title={language.t("command.fileTree.toggle")} - keybind={command.keybind("fileTree.toggle")} + <div class="hidden md:block shrink-0"> + <div + aria-hidden={!view().reviewPanel.opened()} + class="overflow-hidden transition-[width,margin-left] duration-200 ease-out motion-reduce:transition-none" + classList={{ + "w-8 ml-0": view().reviewPanel.opened(), + "w-0 -ml-1": !view().reviewPanel.opened(), + }} > - <Button - variant="ghost" - class="group/file-tree-toggle size-6 p-0" - onClick={() => layout.fileTree.toggle()} - aria-label={language.t("command.fileTree.toggle")} - aria-expanded={layout.fileTree.opened()} - aria-controls="file-tree-panel" + <div + class="transition-[opacity,transform] duration-200 ease-out origin-center motion-reduce:transition-none" + classList={{ + "opacity-100 scale-100": view().reviewPanel.opened(), + "opacity-0 scale-90": !view().reviewPanel.opened(), + }} > - <div class="relative flex items-center justify-center size-4"> - <Icon - size="small" - name="bullet-list" - classList={{ - "text-icon-strong": layout.fileTree.opened(), - "text-icon-weak": !layout.fileTree.opened(), - }} - /> - </div> - </Button> - </TooltipKeybind> + <TooltipKeybind + title={language.t("command.fileTree.toggle")} + keybind={command.keybind("fileTree.toggle")} + > + <Button + variant="ghost" + class="titlebar-icon w-8 h-6 p-0 box-border" + onClick={() => layout.fileTree.toggle()} + disabled={!view().reviewPanel.opened()} + aria-label={language.t("command.fileTree.toggle")} + aria-expanded={layout.fileTree.opened()} + aria-controls="file-tree-panel" + tabIndex={view().reviewPanel.opened() ? undefined : -1} + > + <div class="relative flex items-center justify-center size-4"> + <Icon + size="small" + name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"} + classList={{ + "text-icon-strong": layout.fileTree.opened(), + "text-icon-weak": !layout.fileTree.opened(), + }} + /> + </div> + </Button> + </TooltipKeybind> + </div> + </div> </div> </div> </Portal> diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index ab96652d4..b7a544ba9 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" const ROOT_CLASS = - "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]" + "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16" interface NewSessionViewProps { worktree: string diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 38152b823..5411d7d01 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -196,19 +196,21 @@ export function StatusPopover() { triggerProps={{ variant: "ghost", class: - "rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", + "rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", style: { scale: 1 }, }} trigger={ - <div class="flex items-center gap-1.5"> - <div - classList={{ - "size-1.5 rounded-full": true, - "bg-icon-success-base": overallHealthy(), - "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, - "bg-border-weak-base": server.healthy() === undefined, - }} - /> + <div class="flex items-center gap-0.5"> + <div class="size-4 flex items-center justify-center"> + <div + classList={{ + "size-1.5 rounded-full": true, + "bg-icon-success-base": overallHealthy(), + "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, + "bg-border-weak-base": server.healthy() === undefined, + }} + /> + </div> <span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span> </div> } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 039a25fae..e10fbe3b3 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useNavigate } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -43,6 +43,7 @@ export function Titlebar() { const theme = useTheme() const navigate = useNavigate() const location = useLocation() + const params = useParams() const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") @@ -171,9 +172,10 @@ export function Titlebar() { <IconButton icon="menu" variant="ghost" - class="size-8 rounded-md" + class="titlebar-icon rounded-md" onClick={layout.mobileSidebar.toggle} aria-label={language.t("sidebar.menu.toggle")} + aria-expanded={layout.mobileSidebar.opened()} /> </div> </Show> @@ -182,13 +184,14 @@ export function Titlebar() { <IconButton icon="menu" variant="ghost" - class="size-8 rounded-md" + class="titlebar-icon rounded-md" onClick={layout.mobileSidebar.toggle} aria-label={language.t("sidebar.menu.toggle")} + aria-expanded={layout.mobileSidebar.opened()} /> </div> </Show> - <div class="flex items-center gap-3 shrink-0"> + <div class="flex items-center gap-1 shrink-0"> <TooltipKeybind class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"} placement="bottom" @@ -197,7 +200,7 @@ export function Titlebar() { > <Button variant="ghost" - class="group/sidebar-toggle size-6 p-0" + class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border" onClick={layout.sidebar.toggle} aria-label={language.t("command.sidebar.toggle")} aria-expanded={layout.sidebar.opened()} @@ -205,39 +208,60 @@ export function Titlebar() { <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <Icon size="small" - name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"} + name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"} class="group-hover/sidebar-toggle:hidden" /> <Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" /> <Icon size="small" - name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"} + name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"} class="hidden group-active/sidebar-toggle:inline-block" /> </div> </Button> </TooltipKeybind> - <div class="hidden xl:flex items-center gap-1 shrink-0"> - <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> - <Button - variant="ghost" - icon="arrow-left" - class="size-6 p-0" - disabled={!canBack()} - onClick={back} - aria-label={language.t("common.goBack")} - /> - </Tooltip> - <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> - <Button - variant="ghost" - icon="arrow-right" - class="size-6 p-0" - disabled={!canForward()} - onClick={forward} - aria-label={language.t("common.goForward")} - /> - </Tooltip> + <div class="hidden xl:flex items-center shrink-0"> + <Show when={params.dir}> + <TooltipKeybind + placement="bottom" + title={language.t("command.session.new")} + keybind={command.keybind("session.new")} + openDelay={2000} + > + <Button + variant="ghost" + icon="new-session" + class="titlebar-icon w-8 h-6 p-0 box-border" + onClick={() => { + if (!params.dir) return + navigate(`/${params.dir}/session`) + }} + aria-label={language.t("command.session.new")} + /> + </TooltipKeybind> + </Show> + <div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}> + <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> + <Button + variant="ghost" + icon="chevron-left" + class="titlebar-icon w-6 h-6 p-0 box-border" + disabled={!canBack()} + onClick={back} + aria-label={language.t("common.goBack")} + /> + </Tooltip> + <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> + <Button + variant="ghost" + icon="chevron-right" + class="titlebar-icon w-6 h-6 p-0 box-border" + disabled={!canForward()} + onClick={forward} + aria-label={language.t("common.goForward")} + /> + </Tooltip> + </div> </div> </div> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> |
