diff options
| author | Adam <[email protected]> | 2025-12-18 09:38:35 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-18 11:16:33 -0600 |
| commit | 268f37f8c9813b3805e508d37c20fec6e6e12cb1 (patch) | |
| tree | 50c24c2763e1a69cceef19f6e675d0bb6e17bca9 /packages/desktop/src | |
| parent | b0aaf04957336538dc49d2b0416f44dfbd538531 (diff) | |
| download | opencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.tar.gz opencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.zip | |
fix(desktop): prompt history nav, optimistic prompt dup
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 87 | ||||
| -rw-r--r-- | packages/desktop/src/components/terminal.tsx | 1 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 14 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 10 |
5 files changed, 75 insertions, 39 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 02fa700bf..98092f5d5 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" import { persisted } from "@/utils/persist" +import { Identifier } from "@opencode-ai/util/identifier" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] @@ -100,6 +101,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { dragging: boolean imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" + applyingHistory: boolean }>({ popover: null, historyIndex: -1, @@ -108,6 +110,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { dragging: false, imageAttachments: [], mode: "normal", + applyingHistory: false, }) const MAX_HISTORY = 100 @@ -135,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) + setStore("applyingHistory", true) prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) + setStore("applyingHistory", false) }) } @@ -429,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") + const trimmed = rawText.replace(/\u200B/g, "").trim() + const hasNonText = rawParts.some((part) => part.type !== "text") + const shouldReset = trimmed.length === 0 && !hasNonText - const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) - const slashMatch = rawText.match(/^\/(\S*)$/) + if (shouldReset) { + setStore("popover", null) + if (store.historyIndex >= 0 && !store.applyingHistory) { + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + if (prompt.dirty()) { + prompt.set(DEFAULT_PROMPT, 0) + } + return + } - if (atMatch) { - onInput(atMatch[1]) - setStore("popover", "file") - } else if (slashMatch) { - slashOnInput(slashMatch[1]) - setStore("popover", "slash") + const shellMode = store.mode === "shell" + + if (!shellMode) { + const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + if (atMatch) { + onInput(atMatch[1]) + setStore("popover", "file") + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) + } } else { setStore("popover", null) } - if (store.historyIndex >= 0) { + if (store.historyIndex >= 0 && !store.applyingHistory) { setStore("historyIndex", -1) setStore("savedPrompt", null) } @@ -591,8 +617,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } if (store.mode === "shell") { - const cursorPosition = getCursorPosition(editorRef) - if ((event.key === "Backspace" && cursorPosition === 0) || event.key === "Escape") { + const { collapsed, cursorPosition, textLength } = getCaretState() + if (event.key === "Escape") { + setStore("mode", "normal") + event.preventDefault() + return + } + if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { setStore("mode", "normal") event.preventDefault() return @@ -685,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` : "" return { + id: Identifier.ascending("part"), type: "file" as const, mime: "text/plain", url: `file://${absolute}${query}`, @@ -702,6 +734,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { }) const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, url: attachment.dataUrl, @@ -747,14 +780,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } + const messageID = Identifier.ascending("message") + const textPart = { + id: Identifier.ascending("part"), + type: "text" as const, + text, + } + const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts] + const optimisticParts = requestParts.map((part) => ({ + ...part, + sessionID: existing.id, + messageID, + })) + sync.session.addOptimisticMessage({ sessionID: existing.id, - text, - parts: [ - { type: "text", text } as import("@opencode-ai/sdk/v2/client").Part, - ...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]), - ...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]), - ], + messageID, + parts: optimisticParts, agent, model, }) @@ -763,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { sessionID: existing.id, agent, model, - parts: [ - { - type: "text", - text, - }, - ...fileAttachmentParts, - ...imageAttachmentParts, - ], + messageID, + parts: requestParts, }) } @@ -911,6 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { classList={{ "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&>[data-type=file]]:text-icon-info-active": true, + "font-mono!": store.mode === "shell", }} /> <Show when={!prompt.dirty() && store.imageAttachments.length === 0}> diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 2156b10ee..c05ddfbf6 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => { <div ref={container} data-component="terminal" + data-prevent-autofocus classList={{ ...(local.classList ?? {}), "size-full px-6 py-3 font-mono": true, diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index cb782c24b..ca25cae98 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -33,14 +33,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, addOptimisticMessage(input: { sessionID: string - text: string + messageID: string parts: Part[] agent: string model: { providerID: string; modelID: string } }) { - const messageID = crypto.randomUUID() const message: Message = { - id: messageID, + id: input.messageID, sessionID: input.sessionID, role: "user", time: { created: Date.now() }, @@ -53,15 +52,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (!messages) { draft.message[input.sessionID] = [message] } else { - const result = Binary.search(messages, messageID, (m) => m.id) + const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[messageID] = input.parts.map((part, i) => ({ - ...part, - id: `${messageID}-${i}`, - sessionID: input.sessionID, - messageID, - })) + draft.part[input.messageID] = input.parts.slice() }), ) }, diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 387f2415f..5f4a5d797 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -358,7 +358,7 @@ export default function Layout(props: ParentProps) { const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( - <div class="relative size-5 shrink-0 rounded-sm overflow-hidden"> + <div class="relative size-5 shrink-0 rounded-sm"> <Avatar fallback={name()} src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url} diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index c9f24518e..c3cc31b5c 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -327,11 +327,15 @@ export default function Page() { ]) const handleKeyDown = (event: KeyboardEvent) => { - if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return + const activeElement = document.activeElement as HTMLElement | undefined + if (activeElement) { + const isProtected = activeElement.closest("[data-prevent-autofocus]") + const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable + if (isProtected || isInput) return + } if (dialog.active) return - const focused = document.activeElement === inputRef - if (focused) { + if (activeElement === inputRef) { if (event.key === "Escape") inputRef?.blur() return } |
