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)} + /> + +
+ + + 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)} + /> +
+
+ + {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}> +
+ +
{props.needs}
0} + when={props.recentCount > 0} fallback={ - +
{language.t("sidebar.inbox.empty")}
} > - 0}> +
- - {rows(() => sections().inbox)} +
{props.recent}
+
- 0}> -
- - {rows(() => sections().archived)} + +
+
{/* Working sessions sit in a shelf pinned below the scrolling list. */} - 0}> -
+ 0}> +
+
) } -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