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 ( + + + + + + + + + + ) + } + + 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) => ( + + )} + + ) +} + +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 } }) {