summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-05 11:55:31 -0600
committerAdam <[email protected]>2025-11-05 11:55:35 -0600
commitd525fbf82940442d8d47265b7d7d0a9af8c282bc (patch)
tree5404bdca83992a030fea1693227343d2d401376c /packages/desktop/src/components
parent69a499f80786b2656121cee4469f39df2e6c40e7 (diff)
downloadopencode-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.tsx11
-rw-r--r--packages/desktop/src/components/prompt-input.tsx223
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