diff options
| author | Adam <[email protected]> | 2025-11-05 11:55:31 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-11-05 11:55:35 -0600 |
| commit | d525fbf82940442d8d47265b7d7d0a9af8c282bc (patch) | |
| tree | 5404bdca83992a030fea1693227343d2d401376c /packages/desktop/src/components | |
| parent | 69a499f80786b2656121cee4469f39df2e6c40e7 (diff) | |
| download | opencode-d525fbf82940442d8d47265b7d7d0a9af8c282bc.tar.gz opencode-d525fbf82940442d8d47265b7d7d0a9af8c282bc.zip | |
feat(desktop): session router, interrupt agent, visual cleanup
Diffstat (limited to 'packages/desktop/src/components')
| -rw-r--r-- | packages/desktop/src/components/message-progress.tsx | 11 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 223 |
2 files changed, 161 insertions, 73 deletions
diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx index c0037f57c..a9be2ae5e 100644 --- a/packages/desktop/src/components/message-progress.tsx +++ b/packages/desktop/src/components/message-progress.tsx @@ -70,9 +70,8 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0)) const rawStatus = createMemo(() => { - const defaultStatus = "Working..." const last = lastPart() - if (!last) return defaultStatus + if (!last) return undefined if (last.type === "tool") { switch (last.tool) { @@ -102,7 +101,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa } else if (last.type === "text") { return "Gathering thoughts..." } - return defaultStatus + return undefined }) const [status, setStatus] = createSignal(rawStatus()) @@ -111,11 +110,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa createEffect(() => { const newStatus = rawStatus() - if (newStatus === status()) return + if (newStatus === status() || !newStatus) return const timeSinceLastChange = Date.now() - lastStatusChange - if (timeSinceLastChange >= 1000) { + if (timeSinceLastChange >= 1500) { setStatus(newStatus) lastStatusChange = Date.now() if (statusTimeout) { @@ -145,7 +144,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa {/* )} */} {/* </Show> */} <div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base"> - <Spinner /> <span class="text-12-medium">{status()}</span> + <Spinner /> <span class="text-12-medium">{status() ?? "Considering next steps..."}</span> </div> <Show when={eligibleItems().length > 0}> <div diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 3d9622cbe..7dc67f6ae 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,51 +1,41 @@ import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui" import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { FileIcon } from "@/ui" import { getDirectory, getFilename } from "@/utils" import { createFocusSignal } from "@solid-primitives/active-element" -import { TextSelection, useLocal } from "@/context/local" +import { useLocal } from "@/context/local" import { DateTime } from "luxon" - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: TextSelection -} - -export type ContentPart = TextPart | FileAttachmentPart +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { useSDK } from "@/context/sdk" +import { useNavigate } from "@solidjs/router" +import { useSync } from "@/context/sync" interface PromptInputProps { - onSubmit: (parts: ContentPart[]) => void class?: string ref?: (el: HTMLDivElement) => void } export const PromptInput: Component<PromptInputProps> = (props) => { + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() const local = useLocal() + const session = useSession() let editorRef!: HTMLDivElement - const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const] const [store, setStore] = createStore<{ - contentParts: ContentPart[] popoverIsOpen: boolean }>({ - contentParts: defaultParts, popoverIsOpen: false, }) - const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts)) + createEffect(() => { + session.id + editorRef.focus() + }) + const isFocused = createFocusSignal(() => editorRef) const handlePaste = (event: ClipboardEvent) => { @@ -71,14 +61,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) + const handleFileSelect = (path: string | undefined) => { + if (!path) return + addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 }) + setStore("popoverIsOpen", false) + } + const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({ items: local.file.searchFilesAndDirectories, key: (x) => x, - onSelect: (path) => { - if (!path) return - addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 }) - setStore("popoverIsOpen", false) - }, + onSelect: handleFileSelect, }) createEffect(() => { @@ -88,10 +80,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { createEffect( on( - () => store.contentParts, + () => session.prompt.current(), (currentParts) => { const domParts = parseFromDOM() - if (isEqual(currentParts, domParts)) return + if (isPromptEqual(currentParts, domParts)) return const selection = window.getSelection() let cursorPosition: number | null = null @@ -122,8 +114,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ), ) - const parseFromDOM = (): ContentPart[] => { - const newParts: ContentPart[] = [] + createEffect( + on( + () => session.prompt.cursor(), + (cursor) => { + if (cursor === undefined) return + queueMicrotask(() => setCursorPosition(editorRef, cursor)) + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const newParts: Prompt = [] let position = 0 editorRef.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -150,7 +152,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } } }) - if (newParts.length === 0) newParts.push(...defaultParts) + if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT) return newParts } @@ -167,12 +169,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("popoverIsOpen", false) } - setStore("contentParts", rawParts) + session.prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { const cursorPosition = getCursorPosition(editorRef) - const rawText = store.contentParts.map((p) => p.content).join("") + const rawText = session.prompt + .current() + .map((p) => p.content) + .join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -198,7 +203,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { parts: nextParts, inserted, cursorPositionAfter, - } = store.contentParts.reduce( + } = session.prompt.current().reduce( (acc, item) => { if (acc.inserted) { acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length }) @@ -257,7 +262,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) if (!inserted) { - const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === "")) + const baseParts = session.prompt.current().filter((item) => !(item.type === "text" && item.content === "")) const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0) const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex } if (part.type === "text") { @@ -270,20 +275,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => { end: appendedAcc.runningIndex + part.content.length, }) } - const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts - setStore("contentParts", next) - setStore("popoverIsOpen", false) + const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT const nextCursor = rawText.length + part.content.length + session.prompt.set(next, nextCursor) + setStore("popoverIsOpen", false) queueMicrotask(() => setCursorPosition(editorRef, nextCursor)) return } - setStore("contentParts", nextParts) + session.prompt.set(nextParts, cursorPositionAfter) setStore("popoverIsOpen", false) queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter)) } + const abort = () => + sdk.client.session.abort({ + path: { + id: session.id!, + }, + }) + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) @@ -293,14 +305,101 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } + if (event.key === "Escape") { + if (store.popoverIsOpen) { + setStore("popoverIsOpen", false) + } else if (session.working()) { + abort() + } + } } - const handleSubmit = (event: Event) => { + const handleSubmit = async (event: Event) => { event.preventDefault() - if (store.contentParts.length > 0) { - props.onSubmit([...store.contentParts]) - setStore("contentParts", defaultParts) + const text = session.prompt + .current() + .map((part) => part.content) + .join("") + if (text.trim().length === 0) { + if (session.working()) abort() + return } + + let existing = session.info() + if (!existing) { + const created = await sdk.client.session.create() + existing = created.data ?? undefined + } + if (!existing) return + + navigate(`/session/${existing.id}`) + if (!session.id) { + // session.layout.setOpenedTabs( + // session.layout.copyTabs("", session.id) + } + session.layout.setActiveTab(undefined) + const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) + + const attachments = session.prompt.current().filter((part) => part.type === "file") + + // const activeFile = local.context.active() + // if (activeFile) { + // registerAttachment( + // activeFile.path, + // activeFile.selection, + // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), + // ) + // } + + // for (const contextFile of local.context.all()) { + // registerAttachment( + // contextFile.path, + // contextFile.selection, + // formatAttachmentLabel(contextFile.path, contextFile.selection), + // ) + // } + + const attachmentParts = attachments.map((attachment) => { + const absolute = toAbsolutePath(attachment.path) + const query = attachment.selection + ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` + : "" + return { + type: "file" as const, + mime: "text/plain", + url: `file://${absolute}${query}`, + filename: getFilename(attachment.path), + source: { + type: "file" as const, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path: absolute, + }, + } + }) + + session.prompt.set(DEFAULT_PROMPT, 0) + + await sdk.client.session.prompt({ + path: { id: existing.id }, + body: { + agent: local.agent.current()!.name, + model: { + modelID: local.model.current()!.id, + providerID: local.model.current()!.provider.id, + }, + parts: [ + { + type: "text", + text, + }, + ...attachmentParts, + ], + }, + }) } return ( @@ -310,11 +409,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}> <For each={flat()}> {(i) => ( - <div + <button classList={{ "w-full flex items-center justify-between rounded-md": true, "bg-surface-raised-base-hover": active() === i, }} + onClick={() => handleFileSelect(i)} > <div class="flex items-center gap-x-2 grow min-w-0"> <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> @@ -326,7 +426,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> </div> <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </div> + </button> )} </For> </Show> @@ -354,7 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - <Show when={isEmpty()}> + <Show when={!session.prompt.dirty()}> <div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none"> Plan and build anything </div> @@ -419,29 +519,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { )} </SelectDialog> </div> - <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" /> + <IconButton + type="submit" + disabled={!session.prompt.dirty() && !session.working()} + icon={session.working() ? "stop" : "arrow-up"} + variant="primary" + /> </div> </form> </div> ) } -function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean { - if (arrA.length !== arrB.length) return false - for (let i = 0; i < arrA.length; i++) { - const partA = arrA[i] - const partB = arrB[i] - if (partA.type !== partB.type) return false - if (partA.type === "text" && partA.content !== (partB as TextPart).content) { - return false - } - if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { - return false - } - } - return true -} - function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 |
