{t().title}
--
cgit v1.2.3
From 3a14ca044ca521d1ab24c04da8e9e3bab9e2de58 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 04:43:01 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/local.tsx | 1 +
1 file changed, 1 insertion(+)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 56154c5ba..6ec9778cc 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setEphemeral("model", agent.current().name, model ?? fallbackModel())
+ if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
--
cgit v1.2.3
From c36f3b9dbe5547576545a77679b8898c205a0c30 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 05:50:10 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/notification.tsx | 4 +++-
packages/ui/src/assets/audio/nope-01.aac | Bin 0 -> 6316 bytes
packages/ui/src/assets/audio/nope-02.aac | Bin 0 -> 7431 bytes
packages/ui/src/assets/audio/nope-03.aac | Bin 0 -> 6688 bytes
packages/ui/src/assets/audio/nope-04.aac | Bin 0 -> 5573 bytes
packages/ui/src/assets/audio/nope-05.aac | Bin 0 -> 6316 bytes
6 files changed, 3 insertions(+), 1 deletion(-)
create mode 100644 packages/ui/src/assets/audio/nope-01.aac
create mode 100644 packages/ui/src/assets/audio/nope-02.aac
create mode 100644 packages/ui/src/assets/audio/nope-03.aac
create mode 100644 packages/ui/src/assets/audio/nope-04.aac
create mode 100644 packages/ui/src/assets/audio/nope-05.aac
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
index 744e4fdf3..c18b12796 100644
--- a/packages/desktop/src/context/notification.tsx
+++ b/packages/desktop/src/context/notification.tsx
@@ -5,6 +5,7 @@ import { useGlobalSDK } from "./global-sdk"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
+import errorSound from "@opencode-ai/ui/audio/error-3.aac"
type NotificationBase = {
directory?: string
@@ -29,6 +30,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
name: "Notification",
init: () => {
const idlePlayer = makeAudioPlayer(idleSound)
+ const errorPlayer = makeAudioPlayer(errorSound)
const globalSDK = useGlobalSDK()
const [store, setStore] = makePersisted(
@@ -65,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
break
}
case "session.error": {
+ errorPlayer.play()
const session = event.properties.sessionID ?? "global"
- // errorPlayer.play()
setStore("list", store.list.length, {
...base,
type: "error",
diff --git a/packages/ui/src/assets/audio/nope-01.aac b/packages/ui/src/assets/audio/nope-01.aac
new file mode 100644
index 000000000..9fb614d08
Binary files /dev/null and b/packages/ui/src/assets/audio/nope-01.aac differ
diff --git a/packages/ui/src/assets/audio/nope-02.aac b/packages/ui/src/assets/audio/nope-02.aac
new file mode 100644
index 000000000..75603cc16
Binary files /dev/null and b/packages/ui/src/assets/audio/nope-02.aac differ
diff --git a/packages/ui/src/assets/audio/nope-03.aac b/packages/ui/src/assets/audio/nope-03.aac
new file mode 100644
index 000000000..1fe459a16
Binary files /dev/null and b/packages/ui/src/assets/audio/nope-03.aac differ
diff --git a/packages/ui/src/assets/audio/nope-04.aac b/packages/ui/src/assets/audio/nope-04.aac
new file mode 100644
index 000000000..b731a2a07
Binary files /dev/null and b/packages/ui/src/assets/audio/nope-04.aac differ
diff --git a/packages/ui/src/assets/audio/nope-05.aac b/packages/ui/src/assets/audio/nope-05.aac
new file mode 100644
index 000000000..4534191b6
Binary files /dev/null and b/packages/ui/src/assets/audio/nope-05.aac differ
--
cgit v1.2.3
From c0d009d5f33c368f61ebe9a87460b1fbf5801d33 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 05:54:44 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/notification.tsx | 2 +-
packages/desktop/src/pages/layout.tsx | 9 +++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
index c18b12796..705551944 100644
--- a/packages/desktop/src/context/notification.tsx
+++ b/packages/desktop/src/context/notification.tsx
@@ -5,7 +5,7 @@ import { useGlobalSDK } from "./global-sdk"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
-import errorSound from "@opencode-ai/ui/audio/error-3.aac"
+import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
type NotificationBase = {
directory?: string
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 08d24dc6f..df162c187 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { Spinner } from "@opencode-ai/ui/spinner"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session, Project } from "@opencode-ai/sdk/v2/client"
@@ -287,6 +288,11 @@ export default function Layout(props: ParentProps) {
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",
+ )
async function archive(session: Session) {
await globalSDK.client.session.update({
directory: session.directory,
@@ -319,6 +325,9 @@ export default function Layout(props: ParentProps) {
+
+
+
--
cgit v1.2.3
From 5fbcb203f5a2cab13c2f7468430b25be4989063b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 06:34:08 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/command.tsx | 1 +
packages/desktop/src/pages/layout.tsx | 150 +++++++++++++++++++++++++++----
2 files changed, 134 insertions(+), 17 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
index 26b03f980..dcc4b6007 100644
--- a/packages/desktop/src/context/command.tsx
+++ b/packages/desktop/src/context/command.tsx
@@ -139,6 +139,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
+ filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index df162c187..bb2302503 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -36,6 +36,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
+import { useCommand } from "@/context/command"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -52,6 +53,137 @@ export default function Layout(props: ParentProps) {
const navigate = useNavigate()
const providers = useProviders()
const dialog = useDialog()
+ const command = useCommand()
+
+ const currentSessions = createMemo(() => {
+ if (!params.dir) return []
+ const directory = base64Decode(params.dir)
+ return globalSync.child(directory)[0].session ?? []
+ })
+
+ function navigateSessionByOffset(offset: number) {
+ const projects = layout.projects.list()
+ if (projects.length === 0) return
+
+ const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
+ const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
+
+ // If we're not in any project, navigate to the first/last project based on direction
+ if (projectIndex === -1) {
+ const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
+ if (targetProject) navigateToProject(targetProject.worktree)
+ return
+ }
+
+ const sessions = currentSessions()
+ const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
+
+ // Calculate target index within current project
+ let targetIndex: number
+ if (sessionIndex === -1) {
+ // Not on a session - go to first session for "next", last session for "prev"
+ targetIndex = offset > 0 ? 0 : sessions.length - 1
+ } else {
+ targetIndex = sessionIndex + offset
+ }
+
+ // If target is within bounds, navigate to that session
+ if (targetIndex >= 0 && targetIndex < sessions.length) {
+ navigateToSession(sessions[targetIndex])
+ return
+ }
+
+ // Navigate to adjacent project
+ const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
+ const nextProject = projects[nextProjectIndex]
+ if (!nextProject) return
+
+ const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? []
+ if (nextProjectSessions.length === 0) {
+ // Navigate to the project's new session page if no sessions
+ navigateToProject(nextProject.worktree)
+ return
+ }
+
+ // If going down (offset > 0), go to first session; if going up (offset < 0), go to last session
+ const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+ navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
+ }
+
+ async function archiveSession(session: Session) {
+ const [store, setStore] = globalSync.child(session.directory)
+ const sessions = store.session ?? []
+ const index = sessions.findIndex((s) => s.id === session.id)
+ // Get next session (prefer next, then prev) before removing
+ const nextSession = sessions[index + 1] ?? sessions[index - 1]
+
+ await globalSDK.client.session.update({
+ directory: session.directory,
+ sessionID: session.id,
+ time: { archived: Date.now() },
+ })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ if (session.id === params.id) {
+ if (nextSession) {
+ navigate(`/${params.dir}/session/${nextSession.id}`)
+ } else {
+ navigate(`/${params.dir}/session`)
+ }
+ }
+ }
+
+ command.register(() => [
+ {
+ id: "sidebar.toggle",
+ title: "Toggle sidebar",
+ category: "View",
+ keybind: "mod+b",
+ onSelect: () => layout.sidebar.toggle(),
+ },
+ ...(platform.openDirectoryPickerDialog
+ ? [
+ {
+ id: "project.open",
+ title: "Open project",
+ category: "Project",
+ keybind: "mod+o",
+ onSelect: () => chooseProject(),
+ },
+ ]
+ : []),
+ {
+ id: "session.previous",
+ title: "Previous session",
+ category: "Session",
+ keybind: "alt+arrowup",
+ disabled: !params.dir,
+ onSelect: () => navigateSessionByOffset(-1),
+ },
+ {
+ id: "session.next",
+ title: "Next session",
+ category: "Session",
+ keybind: "alt+arrowdown",
+ disabled: !params.dir,
+ onSelect: () => navigateSessionByOffset(1),
+ },
+ {
+ id: "session.archive",
+ title: "Archive session",
+ category: "Session",
+ keybind: "mod+shift+backspace",
+ disabled: !params.dir || !params.id,
+ onSelect: () => {
+ const session = currentSessions().find((s) => s.id === params.id)
+ if (session) archiveSession(session)
+ },
+ },
+ ])
function connectProvider() {
dialog.replace(() => )
@@ -293,22 +425,6 @@ export default function Layout(props: ParentProps) {
session.id !== params.id &&
globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy",
)
- async function archive(session: Session) {
- await globalSDK.client.session.update({
- directory: session.directory,
- sessionID: session.id,
- time: { archived: Date.now() },
- })
- setStore(
- produce((draft) => {
- const match = Binary.search(draft.session, session.id, (s) => s.id)
- if (match.found) draft.session.splice(match.index, 1)
- }),
- )
- if (session.id === params.id) {
- navigate(`/${params.dir}/session`)
- }
- }
return (
{/* */}
- archive(session)} />
+ archiveSession(session)} />
--
cgit v1.2.3
From df2ebfac7d3dca6c2262258e6ee85a3c22cc53c3 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 06:52:54 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/layout.tsx | 15 +++++++
packages/desktop/src/pages/layout.tsx | 6 +++
packages/desktop/src/pages/session.tsx | 69 ++++++++++++++++++++++++++++-
packages/ui/src/components/session-turn.tsx | 20 +++++++--
4 files changed, 106 insertions(+), 4 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index af71c6a00..604f7c5d1 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
state: "pane" as "pane" | "tab",
},
+ steps: {
+ expanded: false,
+ },
sessionTabs: {} as Record
,
}),
{
@@ -161,6 +164,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
+ steps: {
+ expanded: createMemo(() => store.steps?.expanded ?? false),
+ toggle() {
+ setStore("steps", "expanded", (x) => !x)
+ },
+ expand() {
+ setStore("steps", "expanded", true)
+ },
+ collapse() {
+ setStore("steps", "expanded", false)
+ },
+ },
tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index bb2302503..53078e01b 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -156,6 +156,12 @@ export default function Layout(props: ParentProps) {
},
]
: []),
+ {
+ id: "provider.connect",
+ title: "Connect provider",
+ category: "Provider",
+ onSelect: () => connectProvider(),
+ },
{
id: "session.previous",
title: "Previous session",
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 5c74f2d2e..d0c3bf7de 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
+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"
@@ -70,6 +71,25 @@ export default function Page() {
setMessageStore("messageId", message?.id)
}
+ function navigateMessageByOffset(offset: number) {
+ const messages = userMessages()
+ if (messages.length === 0) return
+
+ const current = activeMessage()
+ const currentIndex = current ? messages.findIndex((m) => m.id === current.id) : -1
+
+ let targetIndex: number
+ if (currentIndex === -1) {
+ targetIndex = offset > 0 ? 0 : messages.length - 1
+ } else {
+ targetIndex = currentIndex + offset
+ }
+
+ if (targetIndex < 0 || targetIndex >= messages.length) return
+
+ setActiveMessage(messages[targetIndex])
+ }
+
const last = createMemo(
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
@@ -118,7 +138,7 @@ export default function Page() {
title: "New session",
description: "Create a new session",
category: "Session",
- keybind: "mod+n",
+ keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
@@ -163,6 +183,49 @@ export default function Page() {
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
+ {
+ id: "steps.toggle",
+ title: "Toggle steps",
+ description: "Show or hide the steps",
+ category: "View",
+ keybind: "mod+e",
+ slash: "steps",
+ onSelect: () => layout.steps.toggle(),
+ },
+ {
+ id: "message.previous",
+ title: "Previous message",
+ description: "Go to the previous user message",
+ category: "Session",
+ keybind: "mod+arrowup",
+ disabled: !params.id,
+ onSelect: () => navigateMessageByOffset(-1),
+ },
+ {
+ id: "message.next",
+ title: "Next message",
+ description: "Go to the next user message",
+ category: "Session",
+ keybind: "mod+arrowdown",
+ disabled: !params.id,
+ onSelect: () => navigateMessageByOffset(1),
+ },
+ {
+ id: "model.choose",
+ title: "Choose model",
+ description: "Select a different model",
+ category: "Model",
+ slash: "model",
+ onSelect: () => dialog.replace(() => ),
+ },
+ {
+ id: "agent.cycle",
+ title: "Cycle agent",
+ description: "Switch to the next agent",
+ category: "Agent",
+ slash: "agent",
+ onSelect: () => local.agent.move(1),
+ },
])
// Handle keyboard events that aren't commands
@@ -492,6 +555,10 @@ export default function Page() {
+ expanded ? layout.steps.expand() : layout.steps.collapse()
+ }
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 54dd01091..807092d03 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -24,6 +24,8 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
+ stepsExpanded?: boolean
+ onStepsExpandedChange?: (expanded: boolean) => void
classes?: {
root?: string
content?: string
@@ -222,10 +224,17 @@ export function SessionTurn(
const [store, setStore] = createStore({
status: rawStatus(),
- stepsExpanded: working(),
+ stepsExpanded: props.stepsExpanded ?? working(),
duration: duration(),
})
+ // Sync with controlled prop
+ createEffect(() => {
+ if (props.stepsExpanded !== undefined) {
+ setStore("stepsExpanded", props.stepsExpanded)
+ }
+ })
+
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
@@ -262,6 +271,7 @@ export function SessionTurn(
const isWorking = working()
if (prev && !isWorking && !state.userScrolled) {
setStore("stepsExpanded", false)
+ props.onStepsExpandedChange?.(false)
}
return isWorking
}, working())
@@ -278,7 +288,7 @@ export function SessionTurn(
-
+
@@ -298,7 +308,11 @@ export function SessionTurn(
data-slot="session-turn-collapsible-trigger-content"
variant="ghost"
size="small"
- onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
+ onClick={() => {
+ const next = !store.stepsExpanded
+ setStore("stepsExpanded", next)
+ props.onStepsExpandedChange?.(next)
+ }}
>
--
cgit v1.2.3
From 5e37a902ce0ad209cedb0a85e997f1964064424a Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 06:59:01 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/layout.tsx | 15 ---------------
packages/desktop/src/pages/session.tsx | 10 +++++-----
2 files changed, 5 insertions(+), 20 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 604f7c5d1..af71c6a00 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -46,9 +46,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
state: "pane" as "pane" | "tab",
},
- steps: {
- expanded: false,
- },
sessionTabs: {} as Record,
}),
{
@@ -164,18 +161,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
- steps: {
- expanded: createMemo(() => store.steps?.expanded ?? false),
- toggle() {
- setStore("steps", "expanded", (x) => !x)
- },
- expand() {
- setStore("steps", "expanded", true)
- },
- collapse() {
- setStore("steps", "expanded", false)
- },
- },
tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d0c3bf7de..d49779587 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -115,6 +115,7 @@ export default function Page() {
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
+ stepsExpanded: false,
})
let inputRef!: HTMLDivElement
@@ -190,7 +191,8 @@ export default function Page() {
category: "View",
keybind: "mod+e",
slash: "steps",
- onSelect: () => layout.steps.toggle(),
+ disabled: !params.id,
+ onSelect: () => setStore("stepsExpanded", (x) => !x),
},
{
id: "message.previous",
@@ -555,10 +557,8 @@ export default function Page() {
- expanded ? layout.steps.expand() : layout.steps.collapse()
- }
+ stepsExpanded={store.stepsExpanded}
+ onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
--
cgit v1.2.3
From ff6864a7ca3772e6f2702d585c6bb64a40bd6cce Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 07:05:50 -0600
Subject: feat(desktop): custom commands
---
packages/desktop/src/components/prompt-input.tsx | 74 +++++++++++++++++++++---
packages/desktop/src/context/global-sync.tsx | 4 ++
2 files changed, 69 insertions(+), 9 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 51c4e24d2..87f91104c 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -61,6 +61,7 @@ interface SlashCommand {
title: string
description?: string
keybind?: string
+ type: "builtin" | "custom"
}
export const PromptInput: Component = (props) => {
@@ -208,8 +209,8 @@ export const PromptInput: Component = (props) => {
})
// Get slash commands from registered commands (only those with explicit slash trigger)
- const slashCommands = createMemo(() =>
- command.options
+ const slashCommands = createMemo(() => {
+ const builtin = command.options
.filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
.map((opt) => ({
id: opt.id,
@@ -217,15 +218,46 @@ export const PromptInput: Component = (props) => {
title: opt.title,
description: opt.description,
keybind: opt.keybind,
- })),
- )
+ type: "builtin" as const,
+ }))
+
+ const custom = sync.data.command.map((cmd) => ({
+ id: `custom.${cmd.name}`,
+ trigger: cmd.name,
+ title: cmd.name,
+ description: cmd.description,
+ type: "custom" as const,
+ }))
+
+ return [...custom, ...builtin]
+ })
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
- // Since slash commands only trigger from start, just clear the input
+ setStore("popover", null)
+
+ if (cmd.type === "custom") {
+ // For custom commands, insert the command text so user can add arguments
+ const text = `/${cmd.trigger} `
+ editorRef.innerHTML = ""
+ editorRef.textContent = text
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+ // Set cursor at end
+ requestAnimationFrame(() => {
+ editorRef.focus()
+ const range = document.createRange()
+ const sel = window.getSelection()
+ range.selectNodeContents(editorRef)
+ range.collapse(false)
+ sel?.removeAllRanges()
+ sel?.addRange(range)
+ })
+ return
+ }
+
+ // For built-in commands, clear input and execute immediately
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
- setStore("popover", null)
command.trigger(cmd.id, "slash")
}
@@ -571,6 +603,23 @@ export const PromptInput: Component = (props) => {
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ // Check if this is a custom command
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1) // Remove leading "/"
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ sdk.client.session.command({
+ sessionID: existing.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent: local.agent.current()!.name,
+ model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
+ })
+ return
+ }
+ }
+
sdk.client.session.prompt({
sessionID: existing.id,
agent: local.agent.current()!.name,
@@ -641,9 +690,16 @@ export const PromptInput: Component = (props) => {
{cmd.description}
-
- {formatKeybind(cmd.keybind!)}
-
+
+
+
+ custom
+
+
+
+ {formatKeybind(cmd.keybind!)}
+
+
)}
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 8151a2c6f..b90dde34f 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -13,6 +13,7 @@ import {
type SessionStatus,
type ProviderListResponse,
type ProviderAuthResponse,
+ type Command,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -24,6 +25,7 @@ import { onMount } from "solid-js"
type State = {
ready: boolean
agent: Agent[]
+ command: Command[]
project: string
provider: ProviderListResponse
config: Config
@@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
+ command: [],
session: [],
session_status: {},
session_diff: {},
@@ -118,6 +121,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+ command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
--
cgit v1.2.3
From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 07:14:03 -0600
Subject: chore: cleanup
---
packages/desktop/src/components/prompt-input.tsx | 21 ++++++---------------
packages/desktop/src/context/command.tsx | 17 -----------------
packages/desktop/src/pages/session.tsx | 19 +++----------------
packages/ui/src/components/session-turn.tsx | 1 -
4 files changed, 9 insertions(+), 49 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 87f91104c..9be09507a 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -77,7 +77,6 @@ export const PromptInput: Component
= (props) => {
const command = useCommand()
let editorRef!: HTMLDivElement
- // Session-derived state
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))
@@ -183,10 +182,10 @@ export const PromptInput: Component = (props) => {
}
onMount(() => {
- editorRef?.addEventListener("paste", handlePaste)
+ editorRef.addEventListener("paste", handlePaste)
})
onCleanup(() => {
- editorRef?.removeEventListener("paste", handlePaste)
+ editorRef.removeEventListener("paste", handlePaste)
})
createEffect(() => {
@@ -208,7 +207,6 @@ export const PromptInput: Component = (props) => {
onSelect: handleFileSelect,
})
- // Get slash commands from registered commands (only those with explicit slash trigger)
const slashCommands = createMemo(() => {
const builtin = command.options
.filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
@@ -237,12 +235,10 @@ export const PromptInput: Component = (props) => {
setStore("popover", null)
if (cmd.type === "custom") {
- // For custom commands, insert the command text so user can add arguments
const text = `/${cmd.trigger} `
editorRef.innerHTML = ""
editorRef.textContent = text
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
- // Set cursor at end
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
@@ -255,7 +251,6 @@ export const PromptInput: Component = (props) => {
return
}
- // For built-in commands, clear input and execute immediately
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash")
@@ -287,7 +282,7 @@ export const PromptInput: Component = (props) => {
}
editorRef.innerHTML = ""
- currentParts.forEach((part: ContentPart) => {
+ currentParts.forEach((part) => {
if (part.type === "text") {
editorRef.appendChild(document.createTextNode(part.content))
} else if (part.type === "file") {
@@ -374,7 +369,7 @@ export const PromptInput: Component = (props) => {
const cursorPosition = getCursorPosition(editorRef)
const currentPrompt = prompt.current()
- const rawText = currentPrompt.map((p: ContentPart) => p.content).join("")
+ const rawText = currentPrompt.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
@@ -498,7 +493,6 @@ export const PromptInput: Component = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
- // Handle popover navigation
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
@@ -510,7 +504,6 @@ export const PromptInput: Component = (props) => {
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
- // Skip history navigation when modifier keys are pressed (used for other commands)
if (event.altKey || event.ctrlKey || event.metaKey) return
const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
if (!collapsed) return
@@ -554,7 +547,7 @@ export const PromptInput: Component = (props) => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
- const text = currentPrompt.map((part: ContentPart) => part.content).join("")
+ const text = currentPrompt.map((part) => part.content).join("")
if (text.trim().length === 0) {
if (working()) abort()
return
@@ -574,7 +567,7 @@ export const PromptInput: Component = (props) => {
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = currentPrompt.filter(
- (part: ContentPart) => part.type === "file",
+ (part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
const attachmentParts = attachments.map((attachment) => {
@@ -603,7 +596,6 @@ export const PromptInput: Component = (props) => {
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
- // Check if this is a custom command
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1) // Remove leading "/"
@@ -639,7 +631,6 @@ export const PromptInput: Component = (props) => {
return (
- {/* Popover for file mentions and slash commands */}
void
}
@@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
- // Check for command palette keybind (mod+shift+p)
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
@@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return
}
- // Check registered command keybinds
for (const option of options()) {
if (option.disabled) continue
if (!option.keybind) continue
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d49779587..9e743e48f 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -49,7 +49,6 @@ export default function Page() {
const params = useParams()
const navigate = useNavigate()
- // Session-specific derived state
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -132,7 +131,6 @@ export default function Page() {
}
})
- // Register commands for this page
command.register(() => [
{
id: "session.new",
@@ -230,28 +228,17 @@ export default function Page() {
},
])
- // Handle keyboard events that aren't commands
const handleKeyDown = (event: KeyboardEvent) => {
- // Don't interfere with terminal
// @ts-expect-error
- if (document.activeElement?.dataset?.component === "terminal") {
- return
- }
-
- // Don't interfere with dialogs
- if (dialog.stack.length > 0) {
- return
- }
+ if (document.activeElement?.dataset?.component === "terminal") return
+ if (dialog.stack.length > 0) return
const focused = document.activeElement === inputRef
if (focused) {
- if (event.key === "Escape") {
- inputRef?.blur()
- }
+ if (event.key === "Escape") inputRef?.blur()
return
}
- // Focus input when typing characters
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 807092d03..f905abbd1 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -228,7 +228,6 @@ export function SessionTurn(
duration: duration(),
})
- // Sync with controlled prop
createEffect(() => {
if (props.stepsExpanded !== undefined) {
setStore("stepsExpanded", props.stepsExpanded)
--
cgit v1.2.3
From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 09:34:00 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/components/prompt-input.tsx | 191 +++++++++++++++++---
packages/desktop/src/context/global-sync.tsx | 40 ++++-
packages/desktop/src/context/local.tsx | 2 +-
packages/desktop/src/context/prompt.tsx | 14 +-
packages/desktop/src/pages/layout.tsx | 211 +++++++++++++++--------
packages/desktop/src/pages/session.tsx | 148 +++++++++++-----
packages/desktop/src/utils/prompt.ts | 47 +++++
packages/ui/src/components/message-part.css | 76 +++++++-
packages/ui/src/components/message-part.tsx | 96 ++++++++++-
packages/ui/src/components/session-turn.tsx | 10 +-
10 files changed, 676 insertions(+), 159 deletions(-)
create mode 100644 packages/desktop/src/utils/prompt.ts
(limited to 'packages/desktop/src/context')
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
= (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (props) => {
type: "text",
text,
},
- ...attachmentParts,
+ ...fileAttachmentParts,
+ ...imageAttachmentParts,
],
})
}
@@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => {
+
{
@@ -706,7 +845,7 @@ export const PromptInput: Component
= (props) => {
"[&>[data-type=file]]:text-icon-info-active": true,
}}
/>
-
+
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
@@ -735,7 +874,7 @@ export const PromptInput: Component = (props) => {
@@ -755,7 +894,7 @@ export const PromptInput: Component = (props) => {
>
{
- 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()
+ 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
+ }): 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 (
+ <>
+
+
+ {(child) => (
+
+ )}
+
+ >
+ )
+ }
+
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()
+ 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) {
- props.parts
- ?.filter((p) => p.type === "text" && !(p as TextPart).synthetic)
- ?.map((p) => (p as TextPart).text)
- ?.join(""),
+ const textPart = createMemo(
+ () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
+ )
+
+ const text = createMemo(() => textPart()?.text || "")
+
+ const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
+
+ const attachments = createMemo(() =>
+ files()?.filter((f) => {
+ const mime = f.mime
+ return mime.startsWith("image/") || mime === "application/pdf"
+ }),
+ )
+
+ const inlineFiles = createMemo(() =>
+ files().filter((f) => {
+ const mime = f.mime
+ return !mime.startsWith("image/") && mime !== "application/pdf" && f.source?.text?.start !== undefined
+ }),
+ )
+
+ return (
+
+
0}>
+
+
+ {(file) => (
+
+
+
+
+ }
+ >
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function HighlightedText(props: { text: string; references: FilePart[] }) {
+ const segments = createMemo(() => {
+ const text = props.text
+ const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0))
+
+ const result: { text: string; highlight?: boolean }[] = []
+ let lastIndex = 0
+
+ for (const ref of refs) {
+ const start = ref.source?.text?.start
+ const end = ref.source?.text?.end
+
+ if (start === undefined || end === undefined || start < lastIndex) continue
+
+ if (start > lastIndex) {
+ result.push({ text: text.slice(lastIndex, start) })
+ }
+
+ result.push({ text: text.slice(start, end), highlight: true })
+ lastIndex = end
+ }
+
+ if (lastIndex < text.length) {
+ result.push({ text: text.slice(lastIndex) })
+ }
+
+ return result
+ })
+
+ return (
+
+ {(segment) => {segment.text}}
+
)
- return {text()}
}
export function Part(props: MessagePartProps) {
@@ -303,9 +384,8 @@ ToolRegistry.register({
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f905abbd1..2df324eef 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -62,11 +62,15 @@ export function SessionTurn(
function handleScroll() {
if (!scrollRef) return
- // prevents scroll loops
- if (working() && scrollRef.scrollTop < 100) return
- setState("scrollY", scrollRef.scrollTop)
if (state.autoScrolling) return
const { scrollTop, scrollHeight, clientHeight } = scrollRef
+ // prevents scroll loops - only update scrollY if we have meaningful scroll room
+ // the gap clamp shrinks by 0.48px per pixel scrolled, hitting min at ~71px scroll
+ // we need at least that much scroll headroom beyond the current scroll position
+ const scrollRoom = scrollHeight - clientHeight
+ if (scrollRoom > 100) {
+ setState("scrollY", scrollTop)
+ }
const atBottom = scrollHeight - scrollTop - clientHeight < 50
if (!atBottom && working()) {
setState("userScrolled", true)
--
cgit v1.2.3
From 8ce0966987a0de46d64307c02f729a290f2f3cf3 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 10:13:29 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/context/global-sync.tsx | 4 ++--
packages/desktop/src/context/notification.tsx | 21 +++++++++++++++++----
packages/desktop/src/pages/layout.tsx | 21 +++++++++++++++++++--
3 files changed, 38 insertions(+), 8 deletions(-)
(limited to 'packages/desktop/src/context')
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index bebce64d7..77640c090 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -100,7 +100,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
async function loadSessions(directory: string) {
globalSDK.client.session.list({ directory }).then((x) => {
- const oneHourAgo = Date.now() - 60 * 60 * 1000
+ const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.slice()
.filter((s) => !s.time.archived)
@@ -109,7 +109,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
const sessions = nonArchived.filter((s, i) => {
if (i < 5) return true
const updated = new Date(s.time.updated).getTime()
- return updated > oneHourAgo
+ return updated > fourHoursAgo
})
const [, setStore] = child(directory)
setStore("session", sessions)
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
index 705551944..9843066ea 100644
--- a/packages/desktop/src/context/notification.tsx
+++ b/packages/desktop/src/context/notification.tsx
@@ -2,6 +2,8 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSDK } from "./global-sdk"
+import { useGlobalSync } from "./global-sync"
+import { Binary } from "@opencode-ai/util/binary"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
@@ -32,6 +34,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const idlePlayer = makeAudioPlayer(idleSound)
const errorPlayer = makeAudioPlayer(errorSound)
const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
@@ -57,22 +60,32 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
switch (event.type) {
case "session.idle": {
+ const sessionID = event.properties.sessionID
+ const [syncStore] = globalSync.child(directory)
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+ const isChild = match.found && syncStore.session[match.index].parentID
+ if (isChild) break
idlePlayer.play()
- const session = event.properties.sessionID
setStore("list", store.list.length, {
...base,
type: "turn-complete",
- session,
+ session: sessionID,
})
break
}
case "session.error": {
+ const sessionID = event.properties.sessionID
+ if (sessionID) {
+ const [syncStore] = globalSync.child(directory)
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+ const isChild = match.found && syncStore.session[match.index].parentID
+ if (isChild) break
+ }
errorPlayer.play()
- const session = event.properties.sessionID ?? "global"
setStore("list", store.list.length, {
...base,
type: "error",
- session,
+ session: sessionID ?? "global",
error: "error" in event.properties ? event.properties.error : undefined,
})
break
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 6632abe3a..d10eadea9 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -44,6 +44,16 @@ export default function Layout(props: ParentProps) {
activeDraggable: undefined as string | undefined,
})
+ let scrollContainerRef: HTMLDivElement | undefined
+
+ function scrollToSession(sessionId: string) {
+ if (!scrollContainerRef) return
+ const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
+ if (element) {
+ element.scrollIntoView({ block: "center", behavior: "smooth" })
+ }
+ }
+
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
@@ -111,7 +121,9 @@ export default function Layout(props: ParentProps) {
// If target is within bounds, navigate to that session
if (targetIndex >= 0 && targetIndex < sessions.length) {
- navigateToSession(sessions[targetIndex])
+ const session = sessions[targetIndex]
+ navigateToSession(session)
+ queueMicrotask(() => scrollToSession(session.id))
return
}
@@ -130,6 +142,7 @@ export default function Layout(props: ParentProps) {
// If going down (offset > 0), go to first session; if going up (offset < 0), go to last session
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
+ queueMicrotask(() => scrollToSession(targetSession.id))
}
async function archiveSession(session: Session) {
@@ -418,6 +431,7 @@ export default function Layout(props: ParentProps) {
return (
<>