summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-15 09:34:00 -0600
committerAdam <[email protected]>2025-12-15 10:22:04 -0600
commit5cf6a1343c6ca088bd2b586197faf7fe58961290 (patch)
treed8001631005d2f4791bfe3a0dd3a0b21003a2516 /packages/desktop/src
parent44d6c5780d41616bf29a749020c9d7f98895407f (diff)
downloadopencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.tar.gz
opencode-5cf6a1343c6ca088bd2b586197faf7fe58961290.zip
wip(desktop): progress
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx191
-rw-r--r--packages/desktop/src/context/global-sync.tsx40
-rw-r--r--packages/desktop/src/context/local.tsx2
-rw-r--r--packages/desktop/src/context/prompt.tsx14
-rw-r--r--packages/desktop/src/pages/layout.tsx211
-rw-r--r--packages/desktop/src/pages/session.tsx148
-rw-r--r--packages/desktop/src/utils/prompt.ts47
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
+}