diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts
index 1e1feea7b56d..25fc425970a2 100644
--- a/packages/app/e2e/regression/tabs.spec.ts
+++ b/packages/app/e2e/regression/tabs.spec.ts
@@ -1521,36 +1521,188 @@ test("opening a session from the inbox keeps the list's scroll position and rows
const sessions = Array.from({ length: 24 }, (_, index) => ({
id: `ses_inbox_${String(index).padStart(2, "0")}`,
title: `Inbox session ${index}`,
- time: { created: T0, updated: T0 + index, idle: T0 + index },
+ directory: index === 14 ? "C:/OpenCode/Inbox/worktrees/unopened" : "C:/OpenCode/Inbox",
+ time: { created: T0, updated: T0 + 100 - index, idle: T0 + index },
}))
await mockWorkspace(page, {
name: "Inbox",
sessions,
+ vcs: { current: "dev", default: "dev" },
seed: {
settings: { appearance: { tabLayout: "inbox" } },
tabs: [],
- // Every seeded session finished a run after the inbox was first used, so Recent lists them all.
- storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true, since: 0 } },
+ storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
},
})
await page.goto("/")
const list = page.locator('[data-slot="sidebar-inbox-scroll"]')
const items = list.locator('[data-slot="sidebar-inbox-item"]')
- const target = items.filter({ hasText: "Inbox session 0" })
- await expect(items).toHaveCount(sessions.length)
+ const target = items.filter({ hasText: "Inbox session 14" })
+ await expect(items).toHaveCount(10)
+ await expect(items).toContainText(Array.from({ length: 10 }, (_, index) => `Inbox session ${23 - index}`))
+ await expect(list.getByText("Recent", { exact: true })).toBeVisible()
await list.evaluate((element) => element.scrollTo(0, element.scrollHeight))
await expect(target).toBeInViewport({ ratio: 1 })
+ // Git metadata must load for an unopened worktree, not only after selecting its session.
+ await target.locator('[data-component="tooltip-v2-trigger"]:has(use[href="#opencode-v2-icon-branch"])').hover()
+ await expect(page.getByRole("tooltip")).toHaveText("dev")
const offset = await list.evaluate((element) => element.scrollTop)
expect(offset).toBeGreaterThan(0)
const row = await target.elementHandle()
await target.click()
- await expectPath(page, sessionHref("ses_inbox_00"))
+ await expectPath(page, sessionHref("ses_inbox_14"))
await expect(target).toHaveAttribute("data-state", "active")
// Opening a session loads it into the client, which updates every section.
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
expect(await target.evaluate((element, before) => element === before, row)).toBe(true)
expect(await list.evaluate((element) => element.scrollTop)).toBe(offset)
})
+
+test("the inbox Archived dialog searches only archived sessions and restores the selected session", async ({
+ page,
+}) => {
+ await page.clock.install({ time: T0 + 41 * 60_000 })
+ await mockWorkspace(page, {
+ name: "ArchivedInbox",
+ sessions: [
+ { id: "ses_archive_active", title: "Active inbox session", time: { created: T0, updated: T0, idle: T0 } },
+ ...["First archived session", "Second archived session"].map((title, index) => ({
+ id: `ses_archive_${index}`,
+ title,
+ time: { created: T0 - 100, updated: T0 + index, idle: index ? T0 - 100 : T0, archived: T0 + 10 },
+ })),
+ ],
+ seed: {
+ settings: { appearance: { tabLayout: "inbox" } },
+ tabs: [],
+ storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
+ },
+ })
+ await page.goto("/")
+
+ const inbox = page.locator('[data-slot="sidebar-inbox"]')
+
+ const archived = inbox.getByRole("button", { name: "Archived", exact: true })
+
+ const rows = inbox.locator('[data-slot="sidebar-inbox-item"]')
+
+ await expect(rows).toHaveCount(1)
+ await expect(rows).toContainText("Active inbox session")
+ await expect(archived).toBeVisible()
+ await archived.focus()
+ await archived.press("Enter")
+
+ const dialog = page.getByRole("dialog")
+
+ const search = dialog.getByRole("combobox")
+
+ await expect(search).toBeFocused()
+ await expect(search).toHaveAttribute("placeholder", "Search sessions")
+ await expect(dialog.getByText("Archived sessions", { exact: true })).toBeVisible()
+ await expect(dialog.getByRole("option")).toHaveCount(2)
+ await expect(dialog.getByRole("option", { name: /First archived session/ })).toBeVisible()
+ await expect(dialog.getByRole("option", { name: /Second archived session/ })).toBeVisible()
+ await search.fill("Active inbox session")
+ await expect(dialog.getByText("No results found", { exact: true })).toBeVisible()
+ await search.fill("First archived")
+ await expect(dialog.getByRole("option")).toHaveCount(1)
+ await expect(dialog.getByRole("option", { name: /First archived session/ })).toHaveAttribute("aria-selected", "true")
+ await search.press("Enter")
+ await expect(dialog).toHaveCount(0)
+ await expectPath(page, sessionHref("ses_archive_0"))
+ await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
+ await expect(rows.filter({ hasText: "First archived session" })).toHaveAttribute("data-state", "active")
+})
+
+test("opening a working session expands its shelf and still allows manual collapse", async ({ page }) => {
+ await openSession(page, {
+ name: "WorkingInbox",
+ sessions: [{ id: "ses_working_selected", title: "Selected working session" }],
+ sessionStatus: { ses_working_selected: { type: "running" } },
+ seed: {
+ settings: { appearance: { tabLayout: "inbox" } },
+ storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: false } },
+ },
+ })
+
+ const inbox = page.locator('[data-slot="sidebar-inbox"]')
+
+ const shelf = inbox.locator('[data-slot="sidebar-inbox-working"]')
+
+ const toggle = shelf.getByRole("button", { name: "Working", exact: true })
+
+ const row = shelf.locator('[data-slot="sidebar-inbox-item"]')
+
+ await expect(toggle).toHaveAttribute("aria-expanded", "true")
+ await expect(row).toHaveAttribute("data-state", "active")
+ await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
+ await toggle.click()
+ await expect(toggle).toHaveAttribute("aria-expanded", "false")
+ await expect(row).toHaveCount(0)
+ await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
+ await expectPath(page, sessionHref("ses_working_selected"))
+ await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
+ await toggle.press("Enter")
+ await expect(toggle).toHaveAttribute("aria-expanded", "true")
+ await expect(row).toHaveAttribute("data-state", "active")
+ await expect(toggle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
+})
+
+test("sessions opened from Home and Search get a temporary Current session row without duplicating Recent", async ({
+ page,
+}) => {
+ await mockWorkspace(page, {
+ name: "CurrentInbox",
+ sessions: [
+ { id: "ses_current_home", title: "Older Home session", time: { created: T0, updated: T0, idle: T0 } },
+ { id: "ses_current_search", title: "Older Search session", time: { created: T0, updated: T0, idle: T0 } },
+ { id: "ses_current_recent", title: "Recent inbox session", time: { created: T0, updated: T0 + 2, idle: T0 + 2 } },
+ ...Array.from({ length: 9 }, (_, index) => ({
+ id: `ses_current_extra_${index}`,
+ title: `Other recent session ${index}`,
+ time: { created: T0, updated: T0 + 3 + index, idle: T0 + 3 + index },
+ })),
+ ],
+ seed: {
+ settings: { appearance: { tabLayout: "inbox" } },
+ tabs: [],
+ storage: { "opencode.global.dat:sidebar.inbox": { workingExpanded: true } },
+ },
+ })
+ await page.goto("/")
+
+ const inbox = page.locator('[data-slot="sidebar-inbox"]')
+
+ const rows = inbox.locator('[data-slot="sidebar-inbox-item"]')
+
+ await expect(rows).toHaveCount(10)
+ await page.locator('[data-component="home-session-row"]').filter({ hasText: "Older Home session" }).click()
+ await expectPath(page, sessionHref("ses_current_home"))
+ await expect(rows.filter({ hasText: "Older Home session" })).toHaveAttribute("data-state", "active")
+ await expect(inbox.getByText("Current session", { exact: true })).toHaveCount(0)
+ await expect(rows).toHaveCount(11)
+ await rows.filter({ hasText: "Recent inbox session" }).click()
+ await expectPath(page, sessionHref("ses_current_recent"))
+ await expect(rows).toHaveCount(10)
+ await expect(rows.filter({ hasText: "Recent inbox session" })).toHaveAttribute("data-state", "active")
+ await page.getByRole("button", { name: "Search sessions", exact: true }).click()
+
+ const dialog = page.getByRole("dialog")
+
+ const search = dialog.getByRole("combobox")
+
+ await expect(search).toBeFocused()
+ await search.fill("Older Search session")
+ await expect(dialog.getByRole("option")).toHaveCount(1)
+ await search.press("Enter")
+ await expectPath(page, sessionHref("ses_current_search"))
+ await expect(rows.filter({ hasText: "Older Search session" })).toHaveAttribute("data-state", "active")
+ await expect(rows.filter({ hasText: "Older Home session" })).toHaveCount(0)
+ await expect(rows).toHaveCount(11)
+ await page.getByRole("button", { name: "Home", exact: true }).click()
+ await expectPath(page, "/")
+ await expect(rows).toHaveCount(10)
+})
diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts
index 37ee25b02aca..66ff7f213dcc 100644
--- a/packages/app/e2e/utils/mock-api.ts
+++ b/packages/app/e2e/utils/mock-api.ts
@@ -30,6 +30,7 @@ const JsonPayload = Schema.Unknown.pipe(HttpApiSchema.asJson())
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
parentID: Schema.optional(Schema.String),
+ archived: Schema.optional(Schema.String),
search: Schema.optional(Schema.String),
order: Schema.optional(Schema.String),
cursor: Schema.optional(Schema.String),
diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts
index c769bf0da132..eaa3096f9685 100644
--- a/packages/app/e2e/utils/mock-server.ts
+++ b/packages/app/e2e/utils/mock-server.ts
@@ -1113,6 +1113,10 @@ function mockHandlers(
return session.parentID === ctx.query.parentID
})
+ .filter(
+ (session) =>
+ !!currentSession(session, config.directory).time.archived === (ctx.query.archived === "true"),
+ )
.filter((session) =>
ctx.query.search === undefined
? true
@@ -1286,10 +1290,20 @@ function mockHandlers(
},
sessionRename: (ctx) =>
Effect.sync(() => {
- const title = Option.getOrUndefined(decodeSessionRename(ctx.payload))?.title
+ const update = Option.getOrUndefined(decodeSessionUpdate(ctx.payload))
+
const session = config.sessions.find((item) => item.id === ctx.params.sessionID)
- if (session && title !== undefined) session.title = title
+ if (!session || !update) return
+
+ if (update.title !== undefined) session.title = update.title
+
+ if (update.archived !== undefined) {
+ session.time = {
+ ...currentSession(session, config.directory).time,
+ archived: update.archived ? Date.now() : undefined,
+ }
+ }
}).pipe(Effect.andThen(noContent)),
sessionCommand: (ctx) => {
const recordCommand = config.onCommand
@@ -1512,7 +1526,9 @@ const decodeSessionCreate = Schema.decodeUnknownOption(
Schema.Struct({ id: lenient(Schema.String), title: lenient(Schema.String), parentID: lenient(Schema.String) }),
)
-const decodeSessionRename = Schema.decodeUnknownOption(Schema.Struct({ title: Schema.String }))
+const decodeSessionUpdate = Schema.decodeUnknownOption(
+ Schema.Struct({ title: Schema.optionalKey(Schema.String), archived: Schema.optionalKey(Schema.Boolean) }),
+)
const decodeRevertStage = Schema.decodeUnknownOption(Schema.Struct({ messageID: Schema.String }))
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index c55e0b1f8552..7ba206de4bf6 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -763,6 +763,7 @@ export const dict = {
"sidebar.inbox.section.working": "Working",
"sidebar.inbox.section.recent": "Recent",
"sidebar.inbox.section.archived": "Archived",
+ "sidebar.inbox.archived.title": "Archived sessions",
"sidebar.inbox.unarchive": "Unarchive",
"sidebar.inbox.status.approval": "Approval",
"sidebar.inbox.status.input": "Question",
diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts
index 4f4426a6e262..3f30fc66198d 100644
--- a/packages/app/src/shell/commands/palette.ts
+++ b/packages/app/src/shell/commands/palette.ts
@@ -242,6 +242,7 @@ type SessionSearchState = {
export function createServerSessionEntries(props: {
server: ServerConnection.Key
+ archived?: boolean
known?: () => SessionInfo[]
/** Hides sessions deleted since they were loaded. */
apply?: (sessions: SessionInfo[]) => SessionInfo[]
@@ -276,7 +277,7 @@ export function createServerSessionEntries(props: {
const category = search ? props.category() : props.recentCategory()
return sessions.flatMap((session) => {
- if (session.time.archived) return []
+ if (!!session.time.archived !== !!props.archived) return []
const project = projectForSession(session, projects)
diff --git a/packages/app/src/shell/commands/session-search.tsx b/packages/app/src/shell/commands/session-search.tsx
index b14c0633b3cf..fb5c9cc0ffc6 100644
--- a/packages/app/src/shell/commands/session-search.tsx
+++ b/packages/app/src/shell/commands/session-search.tsx
@@ -6,10 +6,12 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { createKnownServerSessions, createServerSessionEntries, type CommandPaletteEntry } from "./palette"
import { CommandPaletteView } from "./dialog"
+import { errorMessage } from "@/shell/layout/helpers"
+import { showToast } from "@/shell/notifications/toast"
const RECENT_LIMIT = 10
-export function DialogSessionSearch() {
+export function DialogSessionSearch(props: { archived?: boolean }) {
const dialog = useDialog()
const global = useGlobal()
const language = useLanguage()
@@ -25,15 +27,19 @@ export function DialogSessionSearch() {
const load = createServerSessionEntries({
server: ServerConnection.key(conn),
- known: createKnownServerSessions(ctx),
+ archived: props.archived,
+ known: props.archived
+ ? () => ctx.data.session.list().filter((session) => !session.parentID)
+ : createKnownServerSessions(ctx),
apply: ctx.data.session.apply,
projects: ctx.projects.list,
load: (search, signal) =>
- ctx.sdk.api.session.list({ parentID: null, search, archived: false, limit: 50 }, { signal }),
+ ctx.sdk.api.session.list({ parentID: null, search, archived: !!props.archived, limit: 50 }, { signal }),
get: (sessionID, signal) => ctx.sdk.api.session.get({ sessionID }, { signal }),
untitled: () => language.t("command.session.new"),
- category: () => language.t("command.category.session"),
- recentCategory: () => language.t("palette.group.recentSessions"),
+ category: () => language.t(props.archived ? "sidebar.inbox.archived.title" : "command.category.session"),
+ recentCategory: () =>
+ language.t(props.archived ? "sidebar.inbox.archived.title" : "palette.group.recentSessions"),
recentLimit: RECENT_LIMIT,
})
@@ -75,10 +81,32 @@ export function DialogSessionSearch() {
const server = item.server
const directory = item.project?.worktree ?? item.directory
const conn = servers.find((conn) => ServerConnection.key(conn) === server)
- dialog.close()
- if (conn && directory) global.ensureServerCtx(conn).projects.touch(directory)
- void startTransition(() => tabs.select(tabs.addSessionTab({ server, sessionId })))
+ const open = () => {
+ dialog.close()
+
+ if (conn && directory) global.ensureServerCtx(conn).projects.touch(directory)
+ void startTransition(() => tabs.select(tabs.addSessionTab({ server, sessionId })))
+ }
+
+ if (!props.archived) return open()
+
+ if (!conn) return
+
+ const ctx = global.ensureServerCtx(conn)
+
+ void ctx.sdk.api.session
+ .update({ sessionID: sessionId, archived: false })
+ .then(async () => {
+ ctx.data.session.remember(await ctx.sdk.api.session.get({ sessionID: sessionId }))
+ open()
+ })
+ .catch((cause) =>
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(cause, language.t("common.requestFailed")),
+ }),
+ )
}
return (
diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx
index 073859bb4ad8..c5716e983f21 100644
--- a/packages/app/src/shell/shell.tsx
+++ b/packages/app/src/shell/shell.tsx
@@ -210,6 +210,7 @@ export default function Layout(props: ParentProps) {
"--session-header-inset-end": verticalTabs() ? "10px" : "12px",
"--sidebar-inset-start": "11px",
"--sidebar-inset-end": "3px",
+ "--sidebar-divider-bleed": preferences.appearance.tabLayout() === "inbox" ? "var(--shell-inline-inset)" : "0px",
// A bottom mobile titlebar leaves main's top edge to the safe area. Native Windows chrome supplies the gap;
// retain outer-outline clearance.
"--shell-top-inset": bottomTitlebar()
@@ -248,7 +249,7 @@ export default function Layout(props: ParentProps) {
setState("tabsPanel", element)}
data-slot="vertical-tabs-panel"
- class="absolute inset-0 overflow-hidden [contain:layout_style_paint]"
+ class="absolute inset-0 overflow-clip [contain:layout_style_paint] [overflow-clip-margin:var(--sidebar-divider-bleed,0px)]"
classList={{ "[content-visibility:hidden]": !sidebarMotion.present() && !peek.shown() }}
>
{/* Under a peek's contents, so they dim over the sidebar's own background, as when docking, rather than
@@ -263,7 +264,7 @@ export default function Layout(props: ParentProps) {
setState("tabsMount", element)}
data-slot="vertical-tabs-mount"
- class="absolute inset-y-0 end-0 contain-strict will-change-[opacity] flex min-h-0 flex-col bg-v2-background-bg-deep ps-[var(--sidebar-inset-start)] pe-[var(--sidebar-inset-end)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
+ class="absolute inset-y-0 end-0 contain-strict [overflow-clip-margin:var(--sidebar-divider-bleed,0px)] will-change-[opacity] flex min-h-0 flex-col bg-v2-background-bg-deep ps-[var(--sidebar-inset-start)] pe-[var(--sidebar-inset-end)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]"
style={{
width: `${state.tabsWidth}px`,
"padding-bottom": "max(10px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)))",
diff --git a/packages/app/src/shell/titlebar/inbox-card.stories.tsx b/packages/app/src/shell/titlebar/inbox-card.stories.tsx
new file mode 100644
index 000000000000..0a20fda4ebf9
--- /dev/null
+++ b/packages/app/src/shell/titlebar/inbox-card.stories.tsx
@@ -0,0 +1,163 @@
+import { Button } from "@opencode/ui/button"
+import { createEffect, For, on, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { InboxSessionCard } from "./inbox"
+import type { InboxStatus } from "./inbox-model"
+
+const now = Date.UTC(2026, 10, 3, 12)
+
+function SessionCardStory(props: {
+ status: InboxStatus
+ selected: boolean
+ archived: boolean
+ worktree: boolean
+ remote: boolean
+ branch: string
+ title: string
+ project: string
+ projectKnown: boolean
+ ageMinutes: number
+ width: number
+}) {
+ const [state, setState] = createStore({ selected: props.selected, archived: props.archived, removed: false })
+
+ createEffect(
+ on(
+ [
+ () => props.status,
+ () => props.title,
+ () => props.project,
+ () => props.projectKnown,
+ () => props.selected,
+ () => props.archived,
+ ],
+ () => setState({ selected: props.selected, archived: props.archived, removed: false }),
+ ),
+ )
+
+ return (
+
+
+ Session deleted}
+ >
+ setState("selected", true)}
+ onArchive={() => setState("archived", true)}
+ onUnarchive={() => setState("archived", false)}
+ onDelete={() => setState("removed", true)}
+ />
+
+
+
setState({ selected: props.selected, archived: props.archived, removed: false })}
+ >
+ Reset session
+
+
+ Hover or Tab to inspect. Click to select; right-click for actions.
+
+
+ )
+}
+
+export default {
+ title: "App/Inbox/Session card",
+ id: "app-inbox-session-card",
+ render: SessionCardStory,
+ args: {
+ status: "done",
+ selected: false,
+ archived: false,
+ worktree: true,
+ remote: false,
+ branch: "dev",
+ title: "Tooltip status on latest dev",
+ project: "opencode",
+ projectKnown: true,
+ ageMinutes: 41,
+ width: 280,
+ },
+ argTypes: {
+ status: { control: "select", options: ["done", "ready", "working", "approval", "input", "failed"] },
+ width: { control: { type: "range", min: 136, max: 640, step: 8 } },
+ ageMinutes: { control: { type: "range", min: 0, max: 10_080, step: 1 } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "The real inbox card. Hover for metadata tooltips, Tab for focus, click to select, and right-click for local archive/delete actions.",
+ },
+ },
+ },
+}
+
+export const Unread = {}
+
+export const Selected = { args: { selected: true } }
+
+export const Read = { args: { status: "ready" } }
+
+export const Working = { args: { status: "working", ageMinutes: 2 } }
+
+export const Approval = { args: { status: "approval", ageMinutes: 12 } }
+
+export const Question = { args: { status: "input", title: "Choose a response format", ageMinutes: 3 } }
+
+export const Failed = { args: { status: "failed", title: "Repair deployment pipeline" } }
+
+export const Archived = { args: { archived: true, status: "ready" } }
+
+export const NoGitMetadata = { args: { branch: "", worktree: false, project: "notes" } }
+
+export const UnknownProject = { args: { projectKnown: false, worktree: false } }
+
+export const Untitled = { args: { title: "" } }
+
+export const RemoteServer = { args: { remote: true } }
+
+export const LongValues = {
+ args: {
+ title: "Investigate synchronization across distributed sessions and remote worktrees",
+ project: "enterprise-platform-infrastructure",
+ branch: "feature/inbox-pagination-and-worktree-identities",
+ remote: true,
+ },
+}
+
+export const Narrow = { args: { width: 136, remote: true } }
+
+export const AllStates = {
+ render: (args: Parameters
[0]) => (
+
+
+ {(status) => (
+
+ {status}
+
+
+ )}
+
+
+ ),
+}
diff --git a/packages/app/src/shell/titlebar/inbox-list.stories.tsx b/packages/app/src/shell/titlebar/inbox-list.stories.tsx
new file mode 100644
index 000000000000..7730c8f32bc8
--- /dev/null
+++ b/packages/app/src/shell/titlebar/inbox-list.stories.tsx
@@ -0,0 +1,217 @@
+import { Button } from "@opencode/ui/button"
+import { createEffect, createMemo, For } from "solid-js"
+import { createStore } from "solid-js/store"
+import { InboxSessionCard, InboxSessionList } from "./inbox"
+import type { InboxStatus } from "./inbox-model"
+
+const now = Date.UTC(2026, 10, 3, 12)
+
+const titles = [
+ "Tooltip status on latest dev",
+ "Cross-platform synchronization",
+ "User feedback analysis",
+ "Add button component",
+ "Investigate synchronization across distributed sessions and remote worktrees",
+ "Review keyboard navigation",
+]
+
+function SessionListStory(props: {
+ currentCount: number
+ needsCount: number
+ recentCount: number
+ workingCount: number
+ archived: boolean
+ workingExpanded: boolean
+ selectedSession: string
+ width: number
+ height: number
+}) {
+ const [state, setState] = createStore({
+ selected: props.selectedSession,
+ expanded: props.workingExpanded,
+ archived: props.archived,
+ removed: new Array(),
+ action: "",
+ })
+
+ createEffect(() => setState("selected", props.selectedSession))
+ createEffect(() => setState("expanded", props.workingExpanded))
+ createEffect(() => setState("archived", props.archived))
+
+ const sessions = (section: "current" | "needs" | "recent" | "working", count: number) =>
+ Array.from({ length: count }, (_, index) => {
+ const status = (): InboxStatus => {
+ if (section === "current") return "ready"
+
+ if (section === "working") return "working"
+
+ if (section === "needs") return index % 2 === 0 ? "approval" : "input"
+
+ if (index % 3 === 0) return "done"
+
+ return index % 3 === 1 ? "ready" : "failed"
+ }
+
+ return { id: `${section}-${index}`, index, status: status(), title: titles[index % titles.length] ?? "" }
+ }).filter((session) => !state.removed.includes(session.id))
+
+ const current = createMemo(() => sessions("current", props.currentCount))
+
+ const needs = createMemo(() => sessions("needs", props.needsCount))
+
+ const recent = createMemo(() => sessions("recent", props.recentCount))
+
+ const working = createMemo(() => sessions("working", props.workingCount))
+
+ const remove = (id: string, action: string) => {
+ setState("removed", (removed) => [...removed, id])
+ setState("action", action)
+ }
+
+ const rows = (list: typeof recent) => (
+
+ {(session) => (
+ setState("selected", session.id)}
+ onArchive={() => {
+ remove(session.id, `Archived ${session.title}`)
+ setState("archived", true)
+ }}
+ onUnarchive={() => {}}
+ onDelete={() => remove(session.id, `Deleted ${session.title}`)}
+ />
+ )}
+
+ )
+
+ return (
+
+
+ setState("expanded", !state.expanded)}
+ onArchived={() => setState("action", "Archived sessions requested (local story only)")}
+ current={rows(current)}
+ needs={rows(needs)}
+ recent={rows(recent)}
+ working={rows(working)}
+ />
+
+
+
+ setState({
+ selected: props.selectedSession,
+ expanded: props.workingExpanded,
+ archived: props.archived,
+ removed: [],
+ action: "",
+ })
+ }
+ >
+ Reset list
+
+ {state.action}
+
+
+ Click to select; right-click to archive or delete. Working collapses. All actions stay local to this story.
+
+
+ )
+}
+
+export default {
+ title: "App/Inbox/Session lists",
+ id: "app-inbox-session-lists",
+ render: SessionListStory,
+ args: {
+ currentCount: 0,
+ needsCount: 2,
+ recentCount: 6,
+ workingCount: 2,
+ archived: true,
+ workingExpanded: true,
+ selectedSession: "recent-1",
+ width: 280,
+ height: 640,
+ },
+ argTypes: {
+ currentCount: { control: { type: "range", min: 0, max: 1, step: 1 } },
+ needsCount: { control: { type: "range", min: 0, max: 12, step: 1 } },
+ recentCount: { control: { type: "range", min: 0, max: 10, step: 1 } },
+ workingCount: { control: { type: "range", min: 0, max: 12, step: 1 } },
+ selectedSession: {
+ control: "select",
+ options: ["current-0", "recent-1", "recent-0", "needs-0", "working-0", "none"],
+ },
+ width: { control: { type: "range", min: 136, max: 640, step: 8 } },
+ height: { control: { type: "range", min: 240, max: 900, step: 20 } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "The production inbox list and cards with local fixtures. Click to select, collapse Working, right-click to archive or delete, and reset without touching real sessions. Archived records an action rather than opening the live search dialog.",
+ },
+ },
+ },
+}
+
+export const Mixed = {}
+
+export const CurrentSession = { args: { currentCount: 1, selectedSession: "current-0" } }
+
+export const CurrentOnly = {
+ args: {
+ currentCount: 1,
+ needsCount: 0,
+ recentCount: 0,
+ workingCount: 0,
+ selectedSession: "current-0",
+ archived: false,
+ },
+}
+
+export const RecentOnly = { args: { needsCount: 0, workingCount: 0 } }
+
+export const NeedsAttention = { args: { needsCount: 4, recentCount: 0, workingCount: 0, archived: false } }
+
+export const WorkingOnly = {
+ args: { needsCount: 0, recentCount: 0, workingCount: 3, selectedSession: "working-0", archived: false },
+}
+
+export const WorkingCollapsed = { args: { workingExpanded: false, selectedSession: "working-0" } }
+
+export const Empty = { args: { needsCount: 0, recentCount: 0, workingCount: 0, archived: false } }
+
+export const Dense = { args: { needsCount: 4, recentCount: 10, workingCount: 5 } }
+
+export const Narrow = { args: { width: 136 } }
+
+export const ShortSidebar = { args: { height: 360 } }
diff --git a/packages/app/src/shell/titlebar/inbox-model.ts b/packages/app/src/shell/titlebar/inbox-model.ts
index 9da8a12c3d30..99cae17f2a48 100644
--- a/packages/app/src/shell/titlebar/inbox-model.ts
+++ b/packages/app/src/shell/titlebar/inbox-model.ts
@@ -218,12 +218,14 @@ export function createSessionInbox(options: { archived?: boolean } = {}) {
while (queue.length) {
await Promise.all(
- queue.splice(0, BRANCH_BATCH).map((entry) =>
- entry.ctx.data.location.vcs
- .sync(entry.session.location)
+ queue.splice(0, BRANCH_BATCH).map((entry) => {
+ const location = entry.ctx.data.location
+
+ return (location.info(entry.session.location) ? Promise.resolve() : location.syncInfo(entry.session.location))
+ .then(() => location.vcs.sync(entry.session.location))
.catch(() => undefined)
- .then(() => setVcs("settled", locationKey(entry), true)),
- ),
+ .then(() => setVcs("settled", locationKey(entry), true))
+ }),
)
}
@@ -236,12 +238,6 @@ export function createSessionInbox(options: { archived?: boolean } = {}) {
if (queued.has(key)) return
- if (!entry.ctx.data.location.info(entry.session.location)) {
- setVcs("settled", key, true)
-
- return
- }
-
queued.add(key)
queue.push(entry)
})
diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx
index 9147ea01e09d..1e8c463a860a 100644
--- a/packages/app/src/shell/titlebar/inbox.tsx
+++ b/packages/app/src/shell/titlebar/inbox.tsx
@@ -2,6 +2,7 @@ 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 { ActivityRing } from "@opencode/ui/activity-ring"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
@@ -13,21 +14,23 @@ 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 type { LocalProject } from "@/shell/state/layout"
import { sessionTabTitle } from "./tab-title"
import { sessionTitle } from "@/session/title"
import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model"
import { compareWorking, workingSince } from "./inbox-working"
-// `since` is when the inbox was first used; Recent only lists sessions that finished a run after it.
+const RECENT_LIMIT = 10
+
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 servers = useServers()
const inbox = createSessionInbox({ archived: true })
const [state, setState, , ready] = persisted(Persist.global("sidebar.inbox"), InboxState, {
@@ -37,27 +40,39 @@ export function SidebarInbox() {
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))
-
- // Archived sessions, such as scripted `opencode run --archived` runs, only show in the Archived section.
+ // Archived sessions are available through the Archived search dialog, not in the sidebar list.
const active = list.filter((entry) => !inboxArchived(entry))
+ const needs = active.filter((entry) => entry.status === "approval" || entry.status === "input")
+
+ const recent = active
+ .filter((entry) => !inboxBusy(entry.status) && entry.session.time.idle !== undefined)
+ .toSorted((a, b) => (b.session.time.idle ?? 0) - (a.session.time.idle ?? 0))
+ .slice(0, RECENT_LIMIT)
+
+ // `time.updated` moves with every message of a run, so keep Working ordered by run start.
+ const working = active.filter((entry) => entry.status === "working").toSorted(compareWorking)
+
+ const current = list.find((entry) => inbox.selected(entry))
+
return {
- needs: active.filter((entry) => entry.status === "approval" || entry.status === "input"),
- inbox: finished.filter((entry) => !inboxArchived(entry)),
- archived: finished.filter(inboxArchived),
- // `time.updated` moves with every message of a run, so order by when each run started to keep rows in place.
- working: active.filter((entry) => entry.status === "working").toSorted(compareWorking),
+ needs,
+ inbox: recent,
+ current:
+ current && ![...needs, ...recent, ...working].some((entry) => entry.key === current.key) ? [current] : [],
+ archived: list.some(inboxArchived),
+ working,
}
})
+ const selectedWorking = createMemo(() => sections().working.find((entry) => inbox.selected(entry))?.key)
+
+ // Expand when a working session becomes current, not on each update or manual collapse.
+ createEffect(() => {
+ if (!ready() || !selectedWorking()) return
+ setState("workingExpanded", true)
+ })
+
// The client only refreshes `time.idle` for loaded sessions when a run ends, so load every working one.
createEffect(() => {
sections().working.forEach((entry) => {
@@ -66,16 +81,11 @@ export function SidebarInbox() {
})
})
- // 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])
+ inbox.loadBranches([...visible.needs, ...visible.current, ...visible.working, ...visible.inbox])
})
function DeleteDialog(props: { entry: InboxEntry }) {
@@ -113,11 +123,21 @@ export function SidebarInbox() {
{(key) => (
{(entry) => (
-
)
+ return (
+ setState("workingExpanded", !state.workingExpanded)}
+ onArchived={async () => {
+ const { DialogSessionSearch } = await import("@/shell/commands/session-search")
+
+ void dialog.show(() => )
+ }}
+ needs={rows(() => sections().needs)}
+ current={rows(() => sections().current)}
+ recent={rows(() => sections().inbox)}
+ working={rows(() => sections().working)}
+ />
+ )
+}
+
+export function InboxSessionList(props: {
+ needsCount: number
+ currentCount: number
+ recentCount: number
+ workingCount: number
+ archived: boolean
+ workingExpanded: boolean
+ onToggleWorking: () => void
+ onArchived: () => void
+ needs: JSX.Element
+ current: JSX.Element
+ recent: JSX.Element
+ working: JSX.Element
+}) {
+ const language = useLanguage()
+
return (
-
-
0}>
-
-
- {rows(() => sections().needs)}
+
+
0}>
+ {props.current}
+
+
0}>
+
0}
+ when={props.recentCount > 0}
fallback={
-
+
{language.t("sidebar.inbox.empty")}
}
>
- 0}>
+
-
- {rows(() => sections().inbox)}
+
{props.recent}
+
- 0}>
-
-
- {rows(() => sections().archived)}
+
+
+
+ {language.t("sidebar.inbox.section.archived")}
+
{/* Working sessions sit in a shelf pinned below the scrolling list. */}
- 0}>
-
+
0}>
+
+
setState("workingExpanded", !state.workingExpanded)}
+ expanded={props.workingExpanded}
+ shimmer={!props.workingExpanded}
+ onToggle={props.onToggleWorking}
/>
-
- {/* A collapsed shelf still shows the session that is open. */}
- {rows(() => sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}
-
+
+ {props.working}
+
)
}
-function SectionHeader(props: { label: string; count?: number; expanded?: boolean; onToggle?: () => void }) {
+function SectionHeader(props: { label: string; expanded?: boolean; shimmer?: boolean; onToggle?: () => void }) {
const content = (): JSX.Element => (
<>
- {props.label}
-
- {props.count}
-
+
+
+
+
+
@@ -211,7 +274,7 @@ function SectionHeader(props: { label: string; count?: number; expanded?: boolea
+
{content()}
}
@@ -220,7 +283,7 @@ function SectionHeader(props: { label: string; count?: number; expanded?: boolea
{content()}
@@ -230,11 +293,15 @@ function SectionHeader(props: { label: string; count?: number; expanded?: boolea
)
}
-function InboxRow(props: {
- entry: InboxEntry
+export function InboxSessionCard(props: {
+ project?: LocalProject
+ directory: string
+ title?: string
+ status: InboxStatus
+ server?: string
+ since: number | undefined
selected: boolean
now: number
- branchLoading: boolean
branch: string | undefined
worktree: boolean
archived: boolean
@@ -244,13 +311,7 @@ function InboxRow(props: {
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 status = () => props.status
const compact = (seconds: number) => {
if (seconds < 3600) return language.plural("sidebar.inbox.elapsed.minutes", Math.floor(seconds / 60))
@@ -260,27 +321,15 @@ function InboxRow(props: {
return language.plural("sidebar.inbox.elapsed.days", Math.floor(seconds / 86_400))
}
- // Without the current run's start yet, say only that the session is working.
- const working = () => {
- const since = workingSince(props.entry.session)
-
- if (since === undefined) return language.t("sidebar.inbox.status.working")
-
- const seconds = Math.max(0, Math.floor((props.now - since) / 1000))
- const duration = seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds)
-
- return language.t("sidebar.inbox.status.workingFor", { duration })
- }
-
- // Ready rows show their age instead of a status.
const statusLabel = (value: InboxStatus) =>
value === "ready" ? undefined : language.t(`sidebar.inbox.status.${value}`)
const age = () => {
- const seconds = Math.max(
- 0,
- Math.floor((props.now - (props.entry.session.time.idle ?? props.entry.session.time.updated)) / 1000),
- )
+ if (props.since === undefined) return
+
+ const seconds = Math.max(0, Math.floor((props.now - props.since) / 1000))
+
+ if (status() === "working" && seconds < 60) return language.plural("sidebar.inbox.elapsed.seconds", seconds)
return seconds < 60 ? language.t("sidebar.inbox.time.now") : compact(seconds)
}
@@ -293,7 +342,7 @@ function InboxRow(props: {
data-slot="sidebar-inbox-item"
data-status={status()}
data-state={props.selected ? "active" : undefined}
- class="group flex w-full shrink-0 flex-col gap-0.5 rounded-[6px] px-2 py-1.5 text-start hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none focus-visible:bg-v2-overlay-simple-overlay-hover data-[state=active]:bg-v2-background-bg-layer-02"
+ class="group flex h-[54px] w-full shrink-0 flex-col gap-1.5 rounded-[6px] p-2 text-start hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none focus-visible:bg-v2-overlay-simple-overlay-hover data-[state=active]:bg-v2-overlay-simple-overlay-pressed"
classList={{
"transition-opacity hover:opacity-100 focus-visible:opacity-100": !props.selected,
"opacity-70": status() === "working" && !props.selected,
@@ -301,83 +350,75 @@ function InboxRow(props: {
}}
onClick={props.onOpen}
>
-
+
-
+
-
- {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.title, language.t("session.tab.session"))}
-
- {sessionTabTitle(props.entry.session.title, language.t("session.tab.session"))}
-
-
-
-
-
+
+
+ {(name) => (
+
+
+
+ )}
+
+
+
+ {displayName(props.project ?? { worktree: props.directory })}
+
+
+
+
+
-
-
- {/* The worktree icon stands in for the branch icon to mark sessions outside the project checkout. */}
-
-
- {(name) => (
-
- {name()}
-
- )}
-
-
-
-
+
{(name) => (
-
-
+
+
)}
-
+
+
+ {statusLabel(status())}
+
+ }
+ >
+
+
+
+ {/* Announce only the state, not each age update. */}
+
+ {(label) => (
+
+ {label()}
+
+ )}
+
+
+
diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx
index 3a3f3ec673e1..dd2e5d058327 100644
--- a/packages/app/src/shell/titlebar/titlebar.tsx
+++ b/packages/app/src/shell/titlebar/titlebar.tsx
@@ -520,7 +520,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-search"
- class="group flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint"
+ class="group flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={() => command.trigger("sessions.search")}
>