summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 10:54:41 -0600
committerAdam <[email protected]>2026-01-22 11:06:51 -0600
commitde6582b38b046f679d8a10b9def8bb4e780a1b19 (patch)
treec559828e5185ef14a34358b84ab7d76df47b960b
parentfc53abe589cb8d86f8b4fd7df0c6b25aa4914602 (diff)
downloadopencode-de6582b38b046f679d8a10b9def8bb4e780a1b19.tar.gz
opencode-de6582b38b046f679d8a10b9def8bb4e780a1b19.zip
feat(app): delete sessions
-rw-r--r--packages/app/src/context/global-sync.tsx14
-rw-r--r--packages/app/src/i18n/en.ts6
-rw-r--r--packages/app/src/pages/layout.tsx132
3 files changed, 137 insertions, 15 deletions
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 18eacbd60..4964ef6e4 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -475,6 +475,20 @@ function createGlobalSync() {
)
break
}
+ case "session.deleted": {
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ if (result.found) {
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ if (event.properties.info.parentID) break
+ setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
case "session.diff":
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index f2ba88c59..8cb0c87ef 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -450,6 +450,7 @@ export const dict = {
"common.learnMore": "Learn more",
"common.rename": "Rename",
"common.reset": "Reset",
+ "common.archive": "Archive",
"common.delete": "Delete",
"common.close": "Close",
"common.edit": "Edit",
@@ -627,6 +628,11 @@ export const dict = {
"settings.permissions.tool.doom_loop.title": "Doom Loop",
"settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input",
+ "session.delete.failed.title": "Failed to delete session",
+ "session.delete.title": "Delete session",
+ "session.delete.confirm": 'Delete session "{{name}}"?',
+ "session.delete.button": "Delete session",
+
"workspace.new": "New workspace",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 41a76b4d6..685398f7d 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -819,6 +819,49 @@ export default function Layout(props: ParentProps) {
}
}
+ async function deleteSession(session: Session) {
+ const [store, setStore] = globalSync.child(session.directory)
+ const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
+ const index = sessions.findIndex((s) => s.id === session.id)
+ const nextSession = sessions[index + 1] ?? sessions[index - 1]
+
+ const result = await globalSDK.client.session
+ .delete({ directory: session.directory, sessionID: session.id })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: errorMessage(err),
+ })
+ return false
+ })
+
+ if (!result) return
+
+ setStore(
+ produce((draft) => {
+ const removed = new Set<string>([session.id])
+ const collect = (parentID: string) => {
+ for (const item of draft.session) {
+ if (item.parentID !== parentID) continue
+ removed.add(item.id)
+ collect(item.id)
+ }
+ }
+ collect(session.id)
+ draft.session = draft.session.filter((s) => !removed.has(s.id))
+ }),
+ )
+
+ if (session.id === params.id) {
+ if (nextSession) {
+ navigate(`/${params.dir}/session/${nextSession.id}`)
+ } else {
+ navigate(`/${params.dir}/session`)
+ }
+ }
+ }
+
command.register(() => {
const commands: CommandOption[] = [
{
@@ -1145,6 +1188,33 @@ export default function Layout(props: ParentProps) {
})
}
+ function DialogDeleteSession(props: { session: Session }) {
+ const handleDelete = async () => {
+ await deleteSession(props.session)
+ dialog.close()
+ }
+
+ return (
+ <Dialog title={language.t("session.delete.title")} fit>
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+ <div class="flex flex-col gap-1">
+ <span class="text-14-regular text-text-strong">
+ {language.t("session.delete.confirm", { name: props.session.title })}
+ </span>
+ </div>
+ <div class="flex justify-end gap-2">
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+ {language.t("common.cancel")}
+ </Button>
+ <Button variant="primary" size="large" onClick={handleDelete}>
+ {language.t("session.delete.button")}
+ </Button>
+ </div>
+ </div>
+ </Dialog>
+ )
+ }
+
function DialogDeleteWorkspace(props: { directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
@@ -1485,6 +1555,8 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
+ const [menuOpen, setMenuOpen] = createSignal(false)
+ const [pendingRename, setPendingRename] = createSignal(false)
const messageLabel = (message: Message) => {
const parts = sessionStore.part[message.id] ?? []
@@ -1495,7 +1567,7 @@ export default function Layout(props: ParentProps) {
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
- class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+ class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menuOpen() ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
onClick={() => setHoverSession(undefined)}
@@ -1588,21 +1660,51 @@ export default function Layout(props: ParentProps) {
</HoverCard>
</Show>
<div
- class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
+ class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
+ classList={{
+ "opacity-100 pointer-events-auto": menuOpen(),
+ "opacity-0 pointer-events-none": !menuOpen(),
+ "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
+ "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
+ }}
>
- <TooltipKeybind
- placement={props.mobile ? "bottom" : "right"}
- title={language.t("command.session.archive")}
- keybind={command.keybind("session.archive")}
- gutter={8}
- >
- <IconButton
- icon="archive"
- variant="ghost"
- onClick={() => archiveSession(props.session)}
- aria-label={language.t("command.session.archive")}
- />
- </TooltipKeybind>
+ <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
+ <Tooltip value={language.t("common.moreOptions")} placement="top">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ aria-label={language.t("common.moreOptions")}
+ />
+ </Tooltip>
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content
+ onCloseAutoFocus={(event) => {
+ if (!pendingRename()) return
+ event.preventDefault()
+ setPendingRename(false)
+ openEditor(`session:${props.session.id}`, props.session.title)
+ }}
+ >
+ <DropdownMenu.Item
+ onSelect={() => {
+ setPendingRename(true)
+ setMenuOpen(false)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
+ <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
</div>
</div>
)