From 202b14353dd0e7c70e386b44529a173e174402bd Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 15:27:31 -0400 Subject: [PATCH] wip --- .../workspace-refactor-pr-description.md | 44 +++ packages/opencode/src/cli/cmd/tui/app.tsx | 17 - .../cmd/tui/component/dialog-session-list.tsx | 162 ++++++--- .../tui/component/dialog-workspace-create.tsx | 119 +++++++ .../tui/component/dialog-workspace-list.tsx | 319 ------------------ .../workspace/dialog-session-list.tsx | 151 --------- .../src/cli/cmd/tui/ui/dialog-select.tsx | 7 + 7 files changed, 293 insertions(+), 526 deletions(-) create mode 100644 packages/opencode/specs/workspace-refactor-pr-description.md create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx delete mode 100644 packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx diff --git a/packages/opencode/specs/workspace-refactor-pr-description.md b/packages/opencode/specs/workspace-refactor-pr-description.md new file mode 100644 index 0000000000..c72a0f5ec3 --- /dev/null +++ b/packages/opencode/specs/workspace-refactor-pr-description.md @@ -0,0 +1,44 @@ +## Summary + +This refactor moves the TUI toward a single source of truth for project and workspace state, and aligns event handling around a global event stream instead of per-directory subscriptions. + +The main goal is to make workspace switching and session loading behave consistently across the TUI, while simplifying the data flow between the frontend, worker transport, and backend. + +## Why + +The previous shape was splitting related state across multiple places: + +- some workspace/path state lived in sync state +- some UI behavior depended on route state +- event consumers were effectively relying on instance-scoped subscriptions + +That made workspace-aware behavior harder to reason about and more fragile when switching contexts. + +This change centralizes the active project/workspace/path state, makes sync react to that state instead of owning it, and updates the event pipeline so the backend emits richer global events and the TUI filters them based on the current context. The intent is to make the system easier to evolve as workspace support expands. + +## What Changed + +- centralized active project/workspace/path state in the TUI +- made sync derive from that state and re-bootstrap when the active workspace changes +- switched TUI event consumption to a global event stream filtered client-side +- propagated workspace/project metadata through the backend event path and runtime context +- updated the SDK/OpenAPI contract to reflect the richer global event shape +- added targeted TUI tests around workspace-driven sync behavior and event filtering + +## Risk + +This touches several cross-cutting paths, so the main risks are around behavior rather than typing: + +- workspace changes may still expose subtle ordering/race issues if older async bootstrap work finishes after newer state is selected +- event filtering is now more centralized, which is good, but also means mistakes there can hide or misroute UI updates +- session state now depends more heavily on the active workspace context being correct at the right time +- backend/frontend assumptions about global event metadata need to stay aligned, or certain updates may quietly stop appearing in the TUI + +Overall, the biggest risk is regressions during workspace transitions rather than steady-state usage. + +## Validation + +- added focused tests for reactive sync behavior on workspace changes +- added focused tests for `useEvent()` filtering behavior +- ran `bun typecheck` +- ran targeted TUI tests for the new coverage diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2f8d1f7bbb..23bfdd7173 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -36,7 +36,6 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -465,22 +464,6 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.replace(() => ) }, }, - ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - ? [ - { - title: "Manage workspaces", - value: "workspace.list", - category: "Workspace", - suggested: true, - slash: { - name: "workspaces", - }, - onSelect: () => { - dialog.replace(() => ) - }, - }, - ] - : []), { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb..73d37f9e3d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -1,17 +1,50 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" +import { RGBA } from "@opentui/core" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { createMemo, createResource, createSignal, onMount } from "solid-js" import { Locale } from "@/util/locale" import { useKeybind } from "../context/keybind" -import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" -import { DialogSessionRename } from "./dialog-session-rename" -import { useKV } from "../context/kv" +import { useTheme } from "../context/theme" import { createDebouncedSignal } from "../util/signal" +import { useToast } from "../ui/toast" +import { DialogSessionRename } from "./dialog-session-rename" +import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" +const color = [ + RGBA.fromHex("#ff7a90"), + RGBA.fromHex("#f8c555"), + RGBA.fromHex("#70d6a3"), + RGBA.fromHex("#57c7ff"), + RGBA.fromHex("#bb9af7"), + RGBA.fromHex("#ff9e64"), +] + +const shape = ["■", "◆", "▲", "▶", "▼", "◀", "●", "◉", "◈", "◊"] +const action = "__workspace_new__" + +function hash(text: string) { + let sum = 0 + for (const char of text) { + sum = (sum * 31 + char.charCodeAt(0)) >>> 0 + } + return sum +} + +function mark(id?: string) { + if (!id) { + return + } + const sum = hash(id) + return { + fg: color[sum % color.length]!, + text: shape[sum % shape.length]!, + } +} + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() @@ -19,44 +52,60 @@ export function DialogSessionList() { const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() - + const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [searchResults] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) + const load = async (search?: string) => { + const result = await sdk.client.session.list({ + roots: true, + ...(search ? { search, limit: 30 } : {}), + }) return result.data ?? [] + } + + const [listed, listedActions] = createResource(async () => load()) + const [found] = createResource(search, async (query) => { + if (!query) return undefined + return load(query) }) - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const current = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => found() ?? listed() ?? sync.data.session) const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) + return [ + { + title: "+ New workspace session", + value: action, + category: "Actions", + description: "Create a new workspace, then open a session there", + }, + ...sessions() + .filter((item) => item.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((item) => { + const badge = mark(item.workspaceID) + const date = new Date(item.time.updated) + let category = date.toDateString() + if (category === today) { + category = "Today" + } + const deleting = toDelete() === item.id + const status = sync.data.session_status?.[item.id] + const working = status?.type === "busy" + return { + title: deleting ? `Press ${keybind.print("session_delete")} again to confirm` : item.title, + bg: deleting ? theme.error : undefined, + value: item.id, + category, + footer: Locale.time(item.time.updated), + gutter: working ? : undefined, + margin: badge ? {badge.text} : undefined, + } + }), + ] }) onMount(() => { @@ -68,12 +117,29 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} - current={currentSessionID()} + current={current()} onFilter={setSearch} onMove={() => { setToDelete(undefined) }} onSelect={(option) => { + if (option.value === action) { + dialog.replace(() => ( + + openWorkspaceSession({ + dialog, + route, + sdk, + sync, + toast, + workspaceID, + }) + } + /> + )) + return + } route.navigate({ type: "session", sessionID: option.value, @@ -85,10 +151,27 @@ export function DialogSessionList() { keybind: keybind.all.session_delete?.[0], title: "delete", onTrigger: async (option) => { + if (option.value === action) return if (toDelete() === option.value) { - sdk.client.session.delete({ - sessionID: option.value, - }) + const deleted = await sdk.client.session + .delete({ + sessionID: option.value, + }) + .then(() => true) + .catch(() => false) + if (!deleted) { + toast.show({ + message: "Failed to delete session", + variant: "error", + }) + setToDelete(undefined) + return + } + listedActions.mutate((items) => items?.filter((item) => item.id !== option.value)) + sync.set( + "session", + sync.data.session.filter((item) => item.id !== option.value), + ) setToDelete(undefined) return } @@ -99,6 +182,7 @@ export function DialogSessionList() { keybind: keybind.all.session_rename?.[0], title: "rename", onTrigger: async (option) => { + if (option.value === action) return dialog.replace(() => ) }, }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx new file mode 100644 index 0000000000..db3b51009e --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -0,0 +1,119 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useRoute } from "@tui/context/route" +import { useSync } from "@tui/context/sync" +import { createMemo, createSignal, onMount } from "solid-js" +import { setTimeout as sleep } from "node:timers/promises" +import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" + +function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { + return createOpencodeClient({ + baseUrl: sdk.url, + fetch: sdk.fetch, + directory: sync.path.directory || sdk.directory, + experimental_workspaceID: workspaceID, + }) +} + +export async function openWorkspaceSession(input: { + dialog: ReturnType + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + workspaceID: string +}) { + const client = scoped(input.sdk, input.sync, input.workspaceID) + while (true) { + const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) + if (!result) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + if (result.response.status >= 500 && result.response.status < 600) { + await sleep(1000) + continue + } + if (!result.data) { + input.toast.show({ + message: "Failed to create workspace session", + variant: "error", + }) + return + } + input.route.navigate({ + type: "session", + sessionID: result.data.id, + }) + input.dialog.clear() + return + } +} + +export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { + const dialog = useDialog() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const [creating, setCreating] = createSignal() + + onMount(() => { + dialog.setSize("medium") + }) + + const options = createMemo(() => { + const type = creating() + if (type) { + return [ + { + title: `Creating ${type} workspace...`, + value: "creating" as const, + description: "This can take a while for remote environments", + }, + ] + } + return [ + { + title: "Worktree", + value: "worktree" as const, + description: "Create a local git worktree", + }, + ] + }) + + const create = async (type: string) => { + if (creating()) return + setCreating(type) + + const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined) + const workspace = result?.data + if (!workspace) { + setCreating(undefined) + toast.show({ + message: "Failed to create workspace", + variant: "error", + }) + return + } + await sync.workspace.sync() + await props.onSelect(workspace.id) + setCreating(undefined) + } + + return ( + { + if (option.value === "creating") return + void create(option.value) + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx deleted file mode 100644 index 037cebb729..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ /dev/null @@ -1,319 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useProject } from "@tui/context/project" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2" -import { useSDK } from "../context/sdk" -import { useToast } from "../ui/toast" -import { useKeybind } from "../context/keybind" -import { DialogSessionList } from "./workspace/dialog-session-list" -import { setTimeout as sleep } from "node:timers/promises" - -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID?: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} - -async function openWorkspace(input: { - dialog: ReturnType - route: ReturnType - sdk: ReturnType - sync: ReturnType - toast: ReturnType - workspaceID: string - forceCreate?: boolean -}) { - const cacheSession = (session: Session) => { - input.sync.set( - "session", - [...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) => - a.id.localeCompare(b.id), - ), - ) - } - - const client = scoped(input.sdk, input.sync, input.workspaceID) - const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 }) - const session = listed?.data?.[0] - if (session?.id) { - cacheSession(session) - input.route.navigate({ - type: "session", - sessionID: session.id, - }) - input.dialog.clear() - return - } - let created: Session | undefined - while (!created) { - const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - if (result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to open workspace", - variant: "error", - }) - return - } - created = result.data - } - cacheSession(created) - input.route.navigate({ - type: "session", - sessionID: created.id, - }) - input.dialog.clear() -} - -function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise }) { - const dialog = useDialog() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const [creating, setCreating] = createSignal() - - onMount(() => { - dialog.setSize("medium") - }) - - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } - return [ - { - title: "Worktree", - value: "worktree" as const, - description: "Create a local git worktree", - }, - ] - }) - - const createWorkspace = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => { - console.log(err) - return undefined - }) - console.log(JSON.stringify(result, null, 2)) - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: "Failed to create workspace", - variant: "error", - }) - return - } - await sync.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - - return ( - { - if (option.value === "creating") return - void createWorkspace(option.value) - }} - /> - ) -} - -export function DialogWorkspaceList() { - const dialog = useDialog() - const project = useProject() - const route = useRoute() - const sync = useSync() - const sdk = useSDK() - const toast = useToast() - const keybind = useKeybind() - const [toDelete, setToDelete] = createSignal() - const [counts, setCounts] = createSignal>({}) - - const open = (workspaceID: string, forceCreate?: boolean) => - openWorkspace({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - forceCreate, - }) - - async function selectWorkspace(workspaceID: string | null) { - if (workspaceID == null) { - project.workspace.set(undefined) - if (localCount() > 0) { - dialog.replace(() => ) - return - } - route.navigate({ - type: "home", - }) - dialog.clear() - return - } - const count = counts()[workspaceID] - if (count && count > 0) { - dialog.replace(() => ) - return - } - - if (count === 0) { - await open(workspaceID) - return - } - const client = scoped(sdk, sync, workspaceID) - const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined) - if (listed?.data?.length) { - dialog.replace(() => ) - return - } - await open(workspaceID) - } - - const currentWorkspaceID = createMemo(() => project.workspace.current()) - - const localCount = createMemo( - () => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length, - ) - - let run = 0 - createEffect(() => { - const workspaces = sync.data.workspaceList - const next = ++run - if (!workspaces.length) { - setCounts({}) - return - } - setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined]))) - void Promise.all( - workspaces.map(async (workspace) => { - const client = scoped(sdk, sync, workspace.id) - const result = await client.session.list({ roots: true }).catch(() => undefined) - return [workspace.id, result ? (result.data?.length ?? 0) : null] as const - }), - ).then((entries) => { - if (run !== next) return - setCounts(Object.fromEntries(entries)) - }) - }) - - const options = createMemo(() => [ - { - title: "Local", - value: null, - category: "Workspace", - description: "Use the local machine", - footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`, - }, - ...sync.data.workspaceList.map((workspace) => { - const count = counts()[workspace.id] - return { - title: - toDelete() === workspace.id - ? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again` - : workspace.id, - value: workspace.id, - category: workspace.type, - description: workspace.branch ? `Branch ${workspace.branch}` : undefined, - footer: - count === undefined - ? "Loading sessions..." - : count === null - ? "Sessions unavailable" - : `${count} session${count === 1 ? "" : "s"}`, - } - }), - { - title: "+ New workspace", - value: "__create__", - category: "Actions", - description: "Create a new workspace", - }, - ]) - - onMount(() => { - dialog.setSize("large") - void sync.workspace.sync() - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - setToDelete(undefined) - if (option.value === "__create__") { - dialog.replace(() => open(workspaceID, true)} />) - return - } - void selectWorkspace(option.value) - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (option.value === "__create__" || option.value === null) return - if (toDelete() !== option.value) { - setToDelete(option.value) - return - } - const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined) - setToDelete(undefined) - if (result?.error) { - toast.show({ - message: "Failed to delete workspace", - variant: "error", - }) - return - } - if (currentWorkspaceID() === option.value) { - project.workspace.set(undefined) - route.navigate({ - type: "home", - }) - } - await sync.workspace.sync() - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx deleted file mode 100644 index 326f094a56..0000000000 --- a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" -import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" -import { Locale } from "@/util/locale" -import { useKeybind } from "../../context/keybind" -import { useTheme } from "../../context/theme" -import { useSDK } from "../../context/sdk" -import { DialogSessionRename } from "../dialog-session-rename" -import { useKV } from "../../context/kv" -import { createDebouncedSignal } from "../../util/signal" -import { Spinner } from "../spinner" -import { useToast } from "../../ui/toast" - -export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) { - const dialog = useDialog() - const route = useRoute() - const sync = useSync() - const keybind = useKeybind() - const { theme } = useTheme() - const sdk = useSDK() - const kv = useKV() - const toast = useToast() - const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) - - const [listed, listedActions] = createResource( - () => props.workspaceID, - async (workspaceID) => { - if (!workspaceID) return undefined - const result = await sdk.client.session.list({ roots: true }) - return result.data ?? [] - }, - ) - - const [searchResults] = createResource(search, async (query) => { - if (!query || props.localOnly) return undefined - const result = await sdk.client.session.list({ - search: query, - limit: 30, - ...(props.workspaceID ? { roots: true } : {}), - }) - return result.data ?? [] - }) - - const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - - const sessions = createMemo(() => { - if (searchResults()) return searchResults()! - if (props.workspaceID) return listed() ?? [] - if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID) - return sync.data.session - }) - - const options = createMemo(() => { - const today = new Date().toDateString() - return sessions() - .filter((x) => { - if (x.parentID !== undefined) return false - if (props.workspaceID && listed()) return true - if (props.workspaceID) return x.workspaceID === props.workspaceID - if (props.localOnly) return !x.workspaceID - return true - }) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => { - const date = new Date(x.time.updated) - let category = date.toDateString() - if (category === today) { - category = "Today" - } - const isDeleting = toDelete() === x.id - const status = sync.data.session_status?.[x.id] - const isWorking = status?.type === "busy" - return { - title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, - bg: isDeleting ? theme.error : undefined, - value: x.id, - category, - footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, - } - }) - }) - - onMount(() => { - dialog.setSize("large") - }) - - return ( - { - setToDelete(undefined) - }} - onSelect={(option) => { - route.navigate({ - type: "session", - sessionID: option.value, - }) - dialog.clear() - }} - keybind={[ - { - keybind: keybind.all.session_delete?.[0], - title: "delete", - onTrigger: async (option) => { - if (toDelete() === option.value) { - const deleted = await sdk.client.session - .delete({ - sessionID: option.value, - }) - .then(() => true) - .catch(() => false) - setToDelete(undefined) - if (!deleted) { - toast.show({ - message: "Failed to delete session", - variant: "error", - }) - return - } - if (props.workspaceID) { - listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value)) - return - } - sync.set( - "session", - sync.data.session.filter((session) => session.id !== option.value), - ) - return - } - setToDelete(option.value) - }, - }, - { - keybind: keybind.all.session_rename?.[0], - title: "rename", - onTrigger: async (option) => { - dialog.replace(() => ) - }, - }, - ]} - /> - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 46821cccec..c4e855933e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,6 +42,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element + margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -312,6 +313,7 @@ export function DialogSelect(props: DialogSelectProps) { { setStore("input", "mouse") }} @@ -335,6 +337,11 @@ export function DialogSelect(props: DialogSelectProps) { paddingRight={3} gap={1} > + + + {option.margin} + +