diff options
| author | Adam <[email protected]> | 2025-12-15 09:34:00 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-15 10:22:04 -0600 |
| commit | 5cf6a1343c6ca088bd2b586197faf7fe58961290 (patch) | |
| tree | d8001631005d2f4791bfe3a0dd3a0b21003a2516 /packages/desktop/src | |
| parent | 44d6c5780d41616bf29a749020c9d7f98895407f (diff) | |
| download | opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.tar.gz opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.zip | |
wip(desktop): progress
Diffstat (limited to 'packages/desktop/src')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 191 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 40 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/context/prompt.tsx | 14 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 211 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 148 | ||||
| -rw-r--r-- | packages/desktop/src/utils/prompt.ts | 47 |
7 files changed, 510 insertions, 143 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 37d05c311..f3f758102 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,10 +1,10 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -22,6 +22,9 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand, formatKeybind } from "@/context/command" +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -93,11 +96,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -113,16 +120,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) @@ -162,14 +170,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -328,7 +411,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) // Slash commands only trigger when / is at the start of input @@ -358,7 +441,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const cursorPosition = getCursorPosition(editorRef) const currentPrompt = prompt.current() - const rawText = currentPrompt.map((p) => p.content).join("") + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -424,7 +507,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -432,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -532,8 +615,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => part.content).join("") - if (text.trim().length === 0) { + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { if (working()) abort() return } @@ -555,7 +639,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] - const attachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -577,9 +661,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + tabs().setActive(undefined) editorRef.innerHTML = "" prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") @@ -609,7 +701,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } @@ -686,12 +779,58 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </Show> <form onSubmit={handleSubmit} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} classList={{ - "bg-surface-raised-stronger-non-alpha shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true, + "border-icon-info-active border-dashed": store.dragging, [props.class ?? ""]: !!props.class, }} > + <Show when={store.dragging}> + <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> + <div class="flex flex-col items-center gap-2 text-text-weak"> + <Icon name="plus" class="size-8" /> + <span class="text-14-regular">Drop images or PDFs here</span> + </div> + </div> + </Show> + <Show when={store.imageAttachments.length > 0}> + <div class="flex flex-wrap gap-2 px-3 pt-3"> + <For each={store.imageAttachments}> + {(attachment) => ( + <div class="relative group"> + <Show + when={attachment.mime.startsWith("image/")} + fallback={ + <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> + <Icon name="folder" class="size-6 text-text-weak" /> + </div> + } + > + <img + src={attachment.dataUrl} + alt={attachment.filename} + class="size-16 rounded-md object-cover border border-border-base" + /> + </Show> + <button + type="button" + onClick={() => removeImageAttachment(attachment.id)} + class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" + > + <Icon name="close" class="size-3 text-text-weak" /> + </button> + <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> + <span class="text-10-regular text-white truncate block">{attachment.filename}</span> + </div> + </div> + )} + </For> + </div> + </Show> <div class="relative max-h-[240px] overflow-y-auto"> <div ref={(el) => { @@ -706,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - <Show when={!prompt.dirty()}> + <Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none"> Ask anything... "{PLACEHOLDERS[store.placeholder]}" </div> @@ -735,7 +874,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> <Tooltip placement="top" - inactive={!session.prompt.dirty() && !session.working()} + inactive={!prompt.dirty() && !working()} value={ <Switch> <Match when={working()}> @@ -755,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { > <IconButton type="submit" - disabled={!prompt.dirty() && !working()} + disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" class="h-10 w-8 absolute right-2 bottom-2" diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index b90dde34f..bebce64d7 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -100,11 +100,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function loadSessions(directory: string) { globalSDK.client.session.list({ directory }).then((x) => { - const sessions = (x.data ?? []) + const oneHourAgo = Date.now() - 60 * 60 * 1000 + const nonArchived = (x.data ?? []) .slice() .filter((s) => !s.time.archived) .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) + // Include at least 5 sessions, plus any updated in the last hour + const sessions = nonArchived.filter((s, i) => { + if (i < 5) return true + const updated = new Date(s.time.updated).getTime() + return updated > oneHourAgo + }) const [, setStore] = child(directory) setStore("session", sessions) }) @@ -220,6 +226,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } case "message.part.updated": { const part = event.properties.part const parts = store.part[part.messageID] @@ -241,6 +262,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ) break } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } } }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 6ec9778cc..b12679210 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -406,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ case "file.watcher.updated": const relativePath = relative(event.properties.file) if (relativePath.startsWith(".git/")) return - load(relativePath) + if (store.node[relativePath]) load(relativePath) break } }) diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx index c3b3bbace..2da0a08d5 100644 --- a/packages/desktop/src/context/prompt.tsx +++ b/packages/desktop/src/context/prompt.tsx @@ -21,7 +21,15 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } -export type ContentPart = TextPart | FileAttachmentPart +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -38,6 +46,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } } return true } @@ -49,6 +60,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 53078e01b..6632abe3a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,10 +55,32 @@ export default function Layout(props: ParentProps) { const dialog = useDialog() const command = useCommand() + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map<string, Session[]>() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + const currentSessions = createMemo(() => { if (!params.dir) return [] const directory = base64Decode(params.dir) - return globalSync.child(directory)[0].session ?? [] + return flattenSessions(globalSync.child(directory)[0].session ?? []) }) function navigateSessionByOffset(offset: number) { @@ -98,7 +120,7 @@ export default function Layout(props: ParentProps) { const nextProject = projects[nextProjectIndex] if (!nextProject) return - const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? [] + const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? []) if (nextProjectSessions.length === 0) { // Navigate to the project's new session page if no sessions navigateToProject(nextProject.worktree) @@ -375,6 +397,98 @@ export default function Layout(props: ParentProps) { ) } + const SessionItem = (props: { + session: Session + slug: string + project: Project + depth?: number + childrenMap: Map<string, Session[]> + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo( + () => + props.session.id !== params.id && + globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy", + ) + return ( + <> + <div + class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors + hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" + style={{ "padding-left": `${16 + depth * 12}px` }} + > + <Tooltip placement="right" value={props.session.title} gutter={10}> + <A + href={`${props.slug}/session/${props.session.id}`} + class="flex flex-col min-w-0 text-left w-full focus:outline-none" + > + <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> + <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> + <Switch> + <Match when={isWorking()}> + <Spinner class="size-2.5 mr-0.5" /> + </Match> + <Match when={hasError()}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={notifications().length > 0}> + <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> + </Match> + <Match when={true}> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </Match> + </Switch> + </div> + </div> + <Show when={props.session.summary?.files}> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </Show> + </A> + </Tooltip> + <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> + <Tooltip placement="right" value="Archive session"> + <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> + </Tooltip> + </div> + </div> + <For each={children()}> + {(child) => ( + <SessionItem + session={child} + slug={props.slug} + project={props.project} + depth={depth + 1} + childrenMap={props.childrenMap} + /> + )} + </For> + </> + ) + } + const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => { const notification = useNotification() const sortable = createSortable(props.project.worktree) @@ -382,6 +496,18 @@ export default function Layout(props: ParentProps) { const name = createMemo(() => getFilename(props.project.worktree)) const [store, setStore] = globalSync.child(props.project.worktree) const sessions = createMemo(() => store.session ?? []) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map<string, Session[]>() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) const [expanded, setExpanded] = createSignal(true) return ( // @ts-ignore @@ -421,78 +547,17 @@ export default function Layout(props: ParentProps) { </Button> <Collapsible.Content> <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> - <For each={sessions()}> - {(session) => { - const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) - const notifications = createMemo(() => notification.session.unseen(session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) - const isWorking = createMemo( - () => - session.id !== params.id && - globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy", - ) - return ( - <div - class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors - hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" - > - <Tooltip placement="right" value={session.title} gutter={10}> - <A - href={`${slug()}/session/${session.id}`} - class="flex flex-col min-w-0 text-left w-full focus:outline-none" - > - <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <Switch> - <Match when={isWorking()}> - <Spinner class="size-2.5 mr-0.5" /> - </Match> - <Match when={hasError()}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={notifications().length > 0}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> - </Match> - <Match when={true}> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </Match> - </Switch> - </div> - </div> - <Show when={session.summary?.files}> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> - </div> - </Show> - </A> - </Tooltip> - <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> - {/* <IconButton icon="dot-grid" variant="ghost" /> */} - <Tooltip placement="right" value="Archive session"> - <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(session)} /> - </Tooltip> - </div> - </div> - ) - }} + <For each={rootSessions()}> + {(session) => ( + <SessionItem + session={session} + slug={slug()} + project={props.project} + childrenMap={childSessionsByParent()} + /> + )} </For> - <Show when={sessions().length === 0}> + <Show when={rootSessions().length === 0}> <div class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 05a9e8a1d..11056a598 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -38,6 +38,9 @@ import { DialogSelectModel } from "@/components/dialog-select-model" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { extractPromptFromParts } from "@/utils/prompt" export default function Page() { const layout = useLayout() @@ -48,45 +51,56 @@ export default function Page() { const command = useCommand() const params = useParams() const navigate = useNavigate() + const sdk = useSDK() + const prompt = usePrompt() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") .sort((a, b) => a.id.localeCompare(b.id)), ) - const lastUserMessage = createMemo(() => userMessages()?.at(-1)) + // Visible user messages excludes reverted messages (those >= revertMessageID) + const visibleUserMessages = createMemo(() => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }) + const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1)) const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({}) const activeMessage = createMemo(() => { if (!messageStore.messageId) return lastUserMessage() - return userMessages()?.find((m) => m.id === messageStore.messageId) + // If the stored message is no longer visible (e.g., was reverted), fall back to last visible + const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId) + return found ?? lastUserMessage() }) const setActiveMessage = (message: UserMessage | undefined) => { setMessageStore("messageId", message?.id) } function navigateMessageByOffset(offset: number) { - const messages = userMessages() - if (messages.length === 0) return + const msgs = visibleUserMessages() + if (msgs.length === 0) return const current = activeMessage() - const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1 + const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 let targetIndex: number if (currentIndex === -1) { - targetIndex = offset > 0 ? 0 : messages.length - 1 + targetIndex = offset > 0 ? 0 : msgs.length - 1 } else { targetIndex = currentIndex + offset } - if (targetIndex < 0 || targetIndex >= messages.length) return + if (targetIndex < 0 || targetIndex >= msgs.length) return - setActiveMessage(messages[targetIndex]) + setActiveMessage(msgs[targetIndex]) } const last = createMemo( @@ -131,6 +145,24 @@ export default function Page() { } }) + // Auto-navigate to new messages when they're added + // This handles the case after undo + submit where we want to see the new message + // We track the last message ID and only navigate when a NEW message is added (ID increases) + createEffect( + on( + () => visibleUserMessages().at(-1)?.id, + (lastId, prevLastId) => { + // Only navigate if a new message was added (lastId is greater/newer than previous) + if (lastId && prevLastId && lastId > prevLastId) { + setMessageStore("messageId", undefined) + } + }, + { defer: true }, + ), + ) + + const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" }) + command.register(() => [ { id: "session.new", @@ -226,6 +258,66 @@ export default function Page() { slash: "agent", onSelect: () => local.agent.move(1), }, + { + id: "session.undo", + title: "Undo", + description: "Undo the last message", + category: "Session", + keybind: "mod+z", + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status()?.type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + // Find the last user message that's not already reverted + const message = userMessages().findLast((x) => !revert || x.id < revert) + if (!message) return + await sdk.client.session.revert({ sessionID, messageID: message.id }) + // Restore the prompt from the reverted message + const parts = sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts) + prompt.set(restored) + } + // Navigate to the message before the reverted one (which will be the new last visible message) + const priorMessage = userMessages().findLast((x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: "Redo", + description: "Redo the last undone message", + category: "Session", + keybind: "mod+shift+z", + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + // Full unrevert - restore all messages and navigate to last + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + // Navigate to the last message (the one that was at the revert point) + const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) + return + } + // Partial redo - move forward to next message + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + // Navigate to the message before the new revert point + const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }, ]) const handleKeyDown = (event: KeyboardEvent) => { @@ -548,7 +640,7 @@ export default function Page() { <Match when={params.id}> <div class="flex items-start justify-start h-full min-h-0"> <SessionMessageRail - messages={userMessages()} + messages={visibleUserMessages()} current={activeMessage()} onMessageSelect={setActiveMessage} wide={wide()} @@ -556,7 +648,7 @@ export default function Page() { <Show when={activeMessage()}> <SessionTurn sessionID={params.id!} - messageID={activeMessage()?.id!} + messageID={activeMessage()!.id} stepsExpanded={store.stepsExpanded} onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)} classes={{ @@ -564,7 +656,11 @@ export default function Page() { content: "pb-20", container: "w-full " + - (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"), + (wide() + ? "max-w-146 mx-auto px-6" + : visibleUserMessages().length > 1 + ? "pr-6 pl-18" + : "px-6"), }} /> </Show> @@ -718,34 +814,6 @@ export default function Page() { /> </div> </Show> - <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto"> - {/* <FileTree path="" onFileClick={ handleTabClick} /> */} - </div> - <div class="hidden shrink-0 w-56 p-2"> - <Show - when={local.file.changes().length} - fallback={<div class="px-2 text-xs text-text-muted">No changes</div>} - > - <ul class=""> - <For each={local.file.changes()}> - {(path) => ( - <li> - <button - onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })} - class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element" - > - <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" /> - <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span> - <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0"> - {getDirectory(path)} - </span> - </button> - </li> - )} - </For> - </ul> - </Show> - </div> </div> <Show when={layout.terminal.opened()}> <div diff --git a/packages/desktop/src/utils/prompt.ts b/packages/desktop/src/utils/prompt.ts new file mode 100644 index 000000000..45c5ce1f3 --- /dev/null +++ b/packages/desktop/src/utils/prompt.ts @@ -0,0 +1,47 @@ +import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2" +import type { Prompt, FileAttachmentPart } from "@/context/prompt" + +/** + * Extract prompt content from message parts for restoring into the prompt input. + * This is used by undo to restore the original user prompt. + */ +export function extractPromptFromParts(parts: Part[]): Prompt { + const result: Prompt = [] + let position = 0 + + for (const part of parts) { + if (part.type === "text") { + const textPart = part as TextPart + if (!textPart.synthetic && textPart.text) { + result.push({ + type: "text", + content: textPart.text, + start: position, + end: position + textPart.text.length, + }) + position += textPart.text.length + } + } else if (part.type === "file") { + const filePart = part as FilePart + if (filePart.source?.type === "file") { + const path = filePart.source.path + const content = "@" + path + const attachment: FileAttachmentPart = { + type: "file", + path, + content, + start: position, + end: position + content.length, + } + result.push(attachment) + position += content.length + } + } + } + + if (result.length === 0) { + result.push({ type: "text", content: "", start: 0, end: 0 }) + } + + return result +} |
