From 20ffb20946a2bd26407dae307c872ec79684ffa6 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
<219766164+opencode-agent[bot]@users.noreply.github.com>
Date: Tue, 20 Oct 2026 06:18:46 +0000
Subject: [PATCH] feat(app): bring back the experimental Inbox sidebar (#57312)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
---
packages/app/src/runtime/i18n/en.ts | 11 +
.../src/settings/general/interface-mode.css | 39 ++
.../src/settings/general/interface-mode.tsx | 53 ++-
packages/app/src/settings/model.tsx | 9 +-
.../app/src/shell/titlebar/inbox-model.ts | 86 +++-
packages/app/src/shell/titlebar/inbox.tsx | 381 ++++++++++++++++++
packages/app/src/shell/titlebar/titlebar.tsx | 36 +-
7 files changed, 587 insertions(+), 28 deletions(-)
create mode 100644 packages/app/src/shell/titlebar/inbox.tsx
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index cb3fbba1f6b..94e4fa7133c 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -647,9 +647,16 @@ export const dict = {
"sidebar.settings": "Settings",
"sidebar.help": "Help",
+ "sidebar.inbox.empty": "Sessions you work on will appear here",
+ "sidebar.inbox.section.needsAttention": "Needs attention",
+ "sidebar.inbox.section.working": "Working",
+ "sidebar.inbox.section.recent": "Recent",
+ "sidebar.inbox.section.archived": "Archived",
+ "sidebar.inbox.unarchive": "Unarchive",
"sidebar.inbox.status.approval": "Approval",
"sidebar.inbox.status.input": "Question",
"sidebar.inbox.status.working": "Working",
+ "sidebar.inbox.status.workingFor": "Working {{duration}}",
"sidebar.inbox.status.failed": "Failed",
"sidebar.inbox.status.done": "Done",
"sidebar.inbox.elapsed.seconds.one": "{{count}}s",
@@ -773,6 +780,10 @@ export const dict = {
"settings.general.row.interfaceMode.top.description": "Browser-style tabs across the top of the window.",
"settings.general.row.interfaceMode.side": "Side tabs",
"settings.general.row.interfaceMode.side.description": "Browser-style tabs in a sidebar.",
+ "settings.general.row.interfaceMode.inbox": "Inbox",
+ "settings.general.row.interfaceMode.inbox.description":
+ "Every session across your servers, sorted by what needs you next.",
+ "settings.general.row.interfaceMode.experimental": "Experimental",
"settings.appearance.row.reduceMotion.title": "Reduce motion",
"settings.appearance.row.reduceMotion.description": "Reduce animations or match your system",
"settings.appearance.row.reduceMotion.system": "System",
diff --git a/packages/app/src/settings/general/interface-mode.css b/packages/app/src/settings/general/interface-mode.css
index 28356773ed1..e78230a9a85 100644
--- a/packages/app/src/settings/general/interface-mode.css
+++ b/packages/app/src/settings/general/interface-mode.css
@@ -74,6 +74,16 @@
border: 4px solid var(--v2-background-bg-accent);
}
+ [data-slot="interface-mode-badge"] {
+ padding: 0 5px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: var(--line-height-compact);
+ color: var(--v2-text-text-muted);
+ background-color: var(--v2-background-bg-layer-03);
+ }
+
[data-slot="interface-mode-description"] {
padding-inline-start: 22px;
font-size: 13px;
@@ -161,6 +171,35 @@
background-color: var(--v2-overlay-simple-overlay-pressed);
}
+ [data-slot="preview-rail-heading"] {
+ width: 30%;
+ height: 3px;
+ margin: 4px 3px 1px;
+ border-radius: 1.5px;
+ background-color: var(--preview-ink-strong);
+ opacity: 0.6;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ [data-slot="preview-tab"] i[data-status] {
+ border-radius: 50%;
+
+ &[data-status="approval"] {
+ background-color: var(--v2-state-fg-warning);
+ }
+
+ &[data-status="done"] {
+ background-color: var(--v2-state-fg-success);
+ }
+
+ &[data-status="working"] {
+ background-color: var(--v2-state-fg-info);
+ }
+ }
+
[data-slot="preview-main"] {
display: flex;
min-width: 0;
diff --git a/packages/app/src/settings/general/interface-mode.tsx b/packages/app/src/settings/general/interface-mode.tsx
index a63e0312d4b..6cba5f6637c 100644
--- a/packages/app/src/settings/general/interface-mode.tsx
+++ b/packages/app/src/settings/general/interface-mode.tsx
@@ -1,4 +1,4 @@
-import { For, Match, Switch, createUniqueId } from "solid-js"
+import { For, Match, Show, Switch, createUniqueId } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { type TabLayout, useSettings } from "@/settings/model"
import "./interface-mode.css"
@@ -6,6 +6,7 @@ import "./interface-mode.css"
const modes = [
{ id: "top", layout: "horizontal" },
{ id: "side", layout: "vertical" },
+ { id: "inbox", layout: "inbox" },
] as const satisfies { id: string; layout: TabLayout }[]
export function InterfaceModeSetting() {
@@ -39,12 +40,20 @@ export function InterfaceModeSetting() {
+
+
+
{language.t(`settings.general.row.interfaceMode.${mode.id}`)}
+
+
+ {language.t("settings.general.row.interfaceMode.experimental")}
+
+
{language.t(`settings.general.row.interfaceMode.${mode.id}.description`)}
@@ -94,6 +103,48 @@ function SideTabsPreview() {
)
}
+// Mirrors the inbox: sessions that need you, then finished work, then sessions still running.
+const inboxSections = [
+ { rows: [{ width: 68, status: "approval" }] },
+ {
+ rows: [
+ { width: 56, status: "done" },
+ { width: 72, status: "done" },
+ ],
+ },
+ {
+ rows: [
+ { width: 48, status: "working" },
+ { width: 62, status: "working" },
+ ],
+ },
+] as const
+
+function InboxPreview() {
+ return (
+
+
+
+ {(section, sectionIndex) => (
+ <>
+
+
+ {(row, index) => (
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+ )
+}
+
function PreviewWorkspace() {
return (
diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx
index 9058c8774f8..0a7888ce553 100644
--- a/packages/app/src/settings/model.tsx
+++ b/packages/app/src/settings/model.tsx
@@ -132,13 +132,8 @@ const appearanceSchema = Persistence.struct({
mono: Schema.String,
sans: Schema.String,
terminal: Schema.String,
- // "inbox" was an experimental vertical layout; its sessions now live on Home, so it reads back as vertical tabs.
- tabLayout: Schema.Literals(["horizontal", "vertical", "inbox"]).pipe(
- Schema.decodeTo(Schema.Literals(["horizontal", "vertical"]), {
- decode: SchemaGetter.transform((value) => (value === "inbox" ? "vertical" : value)),
- encode: SchemaGetter.transform((value) => value),
- }),
- ),
+ // "inbox" is experimental: the vertical layout with a server-wide session inbox in place of tabs.
+ tabLayout: Schema.Literals(["horizontal", "vertical", "inbox"]),
sidebarOpen: Persistence.optional(Schema.Boolean),
reduceMotion: Schema.Literals(["system", "on", "off"]),
})
diff --git a/packages/app/src/shell/titlebar/inbox-model.ts b/packages/app/src/shell/titlebar/inbox-model.ts
index e75f964a358..7b964c70a18 100644
--- a/packages/app/src/shell/titlebar/inbox-model.ts
+++ b/packages/app/src/shell/titlebar/inbox-model.ts
@@ -11,7 +11,8 @@ import { useTabs } from "@/shell/tabs/tabs"
import { sessionBusy, sessionRequests } from "@/shell/layout/project-avatar-state"
import { pathKey } from "@/workspaces/path-key"
import { parseHomeSessionIndex } from "@/home/sessions/index"
-import { sessionFormRequest, sessionPermissionRequest } from "@/session/requests/session-request-tree"
+import { sessionFormRequest, sessionPermissionRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
+import { notifySessionTabsRemoved } from "./session-events"
import { errorMessage } from "@/shell/layout/helpers"
import { showToast } from "@/shell/notifications/toast"
@@ -24,6 +25,9 @@ const BRANCH_BATCH = 4
// Root sessions fetched per server.
const INDEX_LIMIT = 200
+// The index excludes archived sessions, so a view that lists them loads the newest archived ones separately.
+const ARCHIVED_LIMIT = 50
+
export type InboxStatus = "approval" | "input" | "working" | "failed" | "done" | "ready"
export type InboxEntry = {
@@ -37,9 +41,10 @@ export type InboxEntry = {
/**
* Root sessions across every visible server with the state a person acts on: waiting for an answer, working,
- * finished and unread, or read. Home lists them.
+ * finished and unread, or read. Home and the experimental Inbox sidebar list them; only the sidebar loads archived
+ * sessions.
*/
-export function createSessionInbox() {
+export function createSessionInbox(options: { archived?: boolean } = {}) {
const global = useGlobal()
const servers = useServers()
const settings = useSettings()
@@ -59,6 +64,9 @@ export function createSessionInbox() {
queryClient.setQueryData
(["home-sessions", conn], (current) =>
current?.filter((session) => session.id !== sessionID),
)
+ queryClient.setQueryData(["inbox-archived-sessions", conn], (current) =>
+ current?.filter((session) => session.id !== sessionID),
+ )
}
createEffect(() => {
@@ -85,6 +93,22 @@ export function createSessionInbox() {
})),
}))
+ const archivedQueries = useQueries(() => ({
+ queries: connections().map((item) => ({
+ queryKey: ["inbox-archived-sessions", item.conn] as const,
+ enabled: !!options.archived && item.ctx.sdk.connection.status() === "connected",
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ item.ctx.sdk.api.session
+ .list({ limit: ARCHIVED_LIMIT, order: "desc", parentID: null, archived: true }, { signal })
+ .then((response) => response.data),
+ retry: false,
+ staleTime: 30_000,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ select: selectSessions,
+ })),
+ }))
+
const entries = createMemo(() =>
connections()
.flatMap((item, index) => {
@@ -116,9 +140,11 @@ export function createSessionInbox() {
// Locally known sessions come last so live archive and unarchive events win over cached indexes.
const sessions = new Map(
- [...(queries[index]?.data?.() ?? []), ...data.session.list()].map(
- (session) => [session.id, session] as const,
- ),
+ [
+ ...(queries[index]?.data?.() ?? []),
+ ...(options.archived ? (archivedQueries[index]?.data?.() ?? []) : []),
+ ...data.session.list(),
+ ].map((session) => [session.id, session] as const),
)
return data.session.apply([...sessions.values()].filter((session) => !session.parentID)).map(
@@ -232,6 +258,17 @@ export function createSessionInbox() {
description: errorMessage(cause, language.t("common.requestFailed")),
})
+ // Move the session between the cached indexes so it changes section before either refetches.
+ const move = (entry: InboxEntry, archivedAt: number | undefined) => {
+ const session = { ...entry.session, time: { ...entry.session.time, archived: archivedAt } }
+
+ forget(entry.conn, entry.session.id)
+ queryClient.setQueryData(
+ [archivedAt === undefined ? "home-sessions" : "inbox-archived-sessions", entry.conn],
+ (current) => current && [session, ...current],
+ )
+ }
+
// The pending request anywhere in the session's family, for views that answer it inline.
const family = (entry: InboxEntry) =>
[...new Set([entry.session.id, ...entry.ctx.data.session.family(entry.session.id)])].flatMap((id) => {
@@ -271,6 +308,43 @@ export function createSessionInbox() {
if (!entry.ctx.data.session.get(entry.session.id)) entry.ctx.data.session.remember(entry.session)
entry.ctx.notification.session.markViewed(entry.session.id)
},
+ archive: (entry: InboxEntry) =>
+ entry.ctx.sdk.api.session
+ .update({ sessionID: entry.session.id, archived: true })
+ .then(() => {
+ move(entry, Date.now())
+ notifySessionTabsRemoved({
+ server: entry.server,
+ directory: entry.session.location.directory,
+ sessionIDs: [entry.session.id],
+ })
+ })
+ .catch(requestFailed),
+ unarchive: (entry: InboxEntry) =>
+ entry.ctx.sdk.api.session
+ .update({ sessionID: entry.session.id, archived: false })
+ .then(() => move(entry, undefined))
+ .catch(requestFailed),
+ remove: (entry: InboxEntry) => {
+ const ids = sessionTreeIDs(entry.ctx.data.session.list(), entry.session.id)
+
+ return entry.ctx.data.session
+ .remove(entry.session.id)
+ .then(() => {
+ forget(entry.conn, entry.session.id)
+ notifySessionTabsRemoved({
+ server: entry.server,
+ directory: entry.session.location.directory,
+ sessionIDs: ids,
+ })
+ })
+ .catch((cause) =>
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: errorMessage(cause, language.t("session.delete.failed.title")),
+ }),
+ )
+ },
}
}
diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx
new file mode 100644
index 00000000000..7c97331778f
--- /dev/null
+++ b/packages/app/src/shell/titlebar/inbox.tsx
@@ -0,0 +1,381 @@
+import { Schema } from "effect"
+import { Persistence } from "@/runtime/persistence/schema"
+import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
+import { Button } from "@opencode/ui/button"
+import { useDialog } from "@opencode/ui/context/dialog"
+import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
+import { Icon } from "@opencode/ui/icon"
+import { Menu } from "@opencode/ui/menu"
+import { TextShimmer } from "@opencode/ui/text-shimmer"
+import { Tooltip } from "@opencode/ui/tooltip"
+import { displayName } from "@opencode/ui/project-avatar"
+import { useServers } from "@/runtime/server/registry"
+import { useLanguage } from "@/runtime/i18n/language"
+import { Persist, persisted } from "@/runtime/persistence/storage"
+import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
+import { sessionTabTitle } from "./tab-title"
+import { sessionTitle } from "@/session/title"
+import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model"
+
+// `since` is when the inbox was first used; Recent only lists sessions that finished a run after it.
+const InboxState = Persistence.struct({
+ workingExpanded: Schema.Boolean,
+ since: Persistence.optional(Schema.Number),
+})
+
+/** The experimental Inbox interface mode: the session inbox in place of vertical tabs. */
+export function SidebarInbox() {
+ const language = useLanguage()
+ const dialog = useDialog()
+ const inbox = createSessionInbox({ archived: true })
+
+ const [state, setState, , ready] = persisted(Persist.global("sidebar.inbox"), InboxState, {
+ workingExpanded: true,
+ })
+
+ const sections = createMemo(() => {
+ const list = inbox.entries()
+ // The most recently finished session comes first.
+ const finished = list
+ .filter((entry) => {
+ const since = state.since
+
+ return !inboxBusy(entry.status) && since !== undefined && (entry.session.time.idle ?? 0) >= since
+ })
+ .toSorted((a, b) => (b.session.time.idle ?? 0) - (a.session.time.idle ?? 0))
+
+ return {
+ needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"),
+ inbox: finished.filter((entry) => !inboxArchived(entry)),
+ archived: finished.filter(inboxArchived),
+ working: list.filter((entry) => entry.status === "working"),
+ }
+ })
+
+ // The client only refreshes `time.idle` for loaded sessions when a run ends, so load every working one.
+ createEffect(() => {
+ sections().working.forEach((entry) => {
+ if (entry.ctx.data.session.get(entry.session.id)) return
+ void entry.ctx.data.session.sync(entry.session.id).catch(() => undefined)
+ })
+ })
+
+ // The inbox starts empty and fills as sessions finish runs.
+ createEffect(() => {
+ if (ready() && state.since === undefined) setState("since", Date.now())
+ })
+
+ // Git info is not stored on the session, so eagerly load it for every shown row.
+ createEffect(() => {
+ const visible = sections()
+
+ inbox.loadBranches([...visible.needs, ...visible.working, ...visible.inbox, ...visible.archived])
+ })
+
+ function DeleteDialog(props: { entry: InboxEntry }) {
+ const confirm = async () => {
+ await inbox.remove(props.entry)
+ dialog.close()
+ }
+ return (
+
+
+
+
+
+ dialog.close()}>
+ {language.t("common.cancel")}
+
+
+ {language.t("session.delete.button")}
+
+
+
+ )
+ }
+
+ const rows = (list: InboxEntry[]) => (
+ entry.key)}>
+ {(key) => (
+
+ {(entry) => (
+ inbox.open(entry())}
+ onArchive={() => void inbox.archive(entry())}
+ onUnarchive={() => void inbox.unarchive(entry())}
+ onDelete={() => dialog.show(() => )}
+ />
+ )}
+
+ )}
+
+ )
+
+ return (
+
+
+
0}>
+
+
+ {rows(sections().needs)}
+
+
+
0}
+ fallback={
+
+
+ {language.t("sidebar.inbox.empty")}
+
+
+ }
+ >
+ 0}>
+
+
+ {rows(sections().inbox)}
+
+
0}>
+
+
+ {rows(sections().archived)}
+
+
+
+ {/* Working sessions sit in a shelf pinned below the scrolling list. */}
+
0}>
+
+
setState("workingExpanded", !state.workingExpanded)}
+ />
+
+ {/* A collapsed shelf still shows the session that is open. */}
+ {rows(sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}
+
+
+
+
+ )
+}
+
+function SectionHeader(props: { label: string; count?: number; expanded?: boolean; onToggle?: () => void }) {
+ const content = (): JSX.Element => (
+ <>
+ {props.label}
+
+ {props.count}
+
+
+
+
+ >
+ )
+
+ return (
+
+ {content()}
+
+ }
+ >
+ {(toggle) => (
+
+ {content()}
+
+ )}
+
+ )
+}
+
+function InboxRow(props: {
+ entry: InboxEntry
+ selected: boolean
+ now: number
+ branchLoading: boolean
+ branch: string | undefined
+ worktree: boolean
+ archived: boolean
+ onOpen: () => void
+ onArchive: () => void
+ onUnarchive: () => void
+ onDelete: () => void
+}) {
+ const language = useLanguage()
+ const servers = useServers()
+ const project = createMemo(() => props.entry.ctx.projects.forSession(props.entry.session))
+ const status = () => props.entry.status
+ const unread = () => status() !== "ready" && status() !== "working"
+
+ // Sessions on the current server are not marked.
+ const tag = () => servers.tag(props.entry.conn)
+
+ const compact = (seconds: number) => {
+ if (seconds < 3600) return language.plural("sidebar.inbox.elapsed.minutes", Math.floor(seconds / 60))
+
+ if (seconds < 86_400) return language.plural("sidebar.inbox.elapsed.hours", Math.floor(seconds / 3600))
+
+ return language.plural("sidebar.inbox.elapsed.days", Math.floor(seconds / 86_400))
+ }
+
+ const elapsed = () => {
+ // `time.updated` moves when a prompt is enqueued, so it marks when the current run was requested.
+ const seconds = Math.max(0, Math.floor((props.now - props.entry.session.time.updated) / 1000))
+
+ return seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds)
+ }
+
+ const age = () => {
+ const seconds = Math.max(
+ 0,
+ Math.floor((props.now - (props.entry.session.time.idle ?? props.entry.session.time.updated)) / 1000),
+ )
+
+ return seconds < 60 ? language.t("sidebar.inbox.time.now") : compact(seconds)
+ }
+
+ return (
+
+
+
+
+
+
+
+ {displayName(project() ?? { worktree: props.entry.session.location.directory })}
+
+
+
+
+ }`)}
+ >
+ {/* Announce only the status: the ticking duration would be re-read every second. */}
+ {language.t("sidebar.inbox.status.working")}
+ {/* Keep the status colour under the sweep. */}
+
+
+
+
+
+
+
+
+
+ {sessionTabTitle(props.entry.session.title, language.t("session.tab.session"))}
+
+
+
+
+
+
+
+
+ {/* The worktree icon stands in for the branch icon to mark sessions outside the project checkout. */}
+
+
+ {(name) => (
+
+ {name()}
+
+ )}
+
+
+
+
+ {(name) => (
+
+
+
+ )}
+
+
+
+
+
+
+ {language.t("common.archive")}}
+ >
+ {language.t("sidebar.inbox.unarchive")}
+
+
+ {language.t("common.delete")}…
+
+
+
+ )
+}
diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx
index 732c760d238..073ad6ba8cb 100644
--- a/packages/app/src/shell/titlebar/titlebar.tsx
+++ b/packages/app/src/shell/titlebar/titlebar.tsx
@@ -17,6 +17,7 @@ import { WindowsAppMenu } from "./windows-menu"
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
import { macTrafficLightsBaseWidth, macVerticalTrafficLights, SidebarToggle } from "@/shell/sidebar-toggle"
+import { SidebarInbox } from "@/shell/titlebar/inbox"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/shell/titlebar/session-events"
@@ -824,21 +825,28 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
- {
- tabs.select(tab)
- el?.scrollIntoView({ behavior: "instant", block: "nearest" })
- }}
- onClose={(tab) => {
- const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
+ {
+ tabs.select(tab)
+ el?.scrollIntoView({ behavior: "instant", block: "nearest" })
+ }}
+ onClose={(tab) => {
+ const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
- if (index !== -1) tabsStoreActions.closeTab(index)
- }}
- onReorder={(keys) => tabsStoreActions.reorder(keys)}
- />
+ if (index !== -1) tabsStoreActions.closeTab(index)
+ }}
+ onReorder={(keys) => tabsStoreActions.reorder(keys)}
+ />
+ }
+ >
+
+
0}>