feat(app): bring back the experimental Inbox sidebar (#57312)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-20 06:18:46 +00:00
committed by GitHub
Unverified
parent 781a9f66e4
commit 20ffb20946
7 changed files with 587 additions and 28 deletions
+11
View File
@@ -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",
@@ -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;
@@ -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() {
<Match when={mode.id === "side"}>
<SideTabsPreview />
</Match>
<Match when={mode.id === "inbox"}>
<InboxPreview />
</Match>
</Switch>
</div>
<div data-slot="interface-mode-copy">
<span data-slot="interface-mode-title">
<span data-slot="interface-mode-radio" />
<span id={`${id}-${mode.id}-title`}>{language.t(`settings.general.row.interfaceMode.${mode.id}`)}</span>
<Show when={mode.id === "inbox"}>
<span data-slot="interface-mode-badge">
{language.t("settings.general.row.interfaceMode.experimental")}
</span>
</Show>
</span>
<span id={`${id}-${mode.id}`} data-slot="interface-mode-description">
{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 (
<div data-slot="preview-body">
<div data-slot="preview-rail">
<For each={inboxSections}>
{(section, sectionIndex) => (
<>
<span data-slot="preview-rail-heading" />
<For each={section.rows}>
{(row, index) => (
<span data-slot="preview-tab" data-active={sectionIndex() === 1 && index() === 0 ? "" : undefined}>
<i data-status={row.status} />
<span style={{ width: `${row.width}%` }} />
</span>
)}
</For>
</>
)}
</For>
</div>
<PreviewWorkspace />
</div>
)
}
function PreviewWorkspace() {
return (
<div data-slot="preview-body">
+2 -7
View File
@@ -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"]),
})
+80 -6
View File
@@ -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<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((session) => session.id !== sessionID),
)
queryClient.setQueryData<SessionInfo[]>(["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<SessionInfo[]>(
[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")),
}),
)
},
}
}
+381
View File
@@ -0,0 +1,381 @@
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { Tooltip } from "@opencode/ui/tooltip"
import { displayName } from "@opencode/ui/project-avatar"
import { useServers } from "@/runtime/server/registry"
import { useLanguage } from "@/runtime/i18n/language"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { sessionTabTitle } from "./tab-title"
import { sessionTitle } from "@/session/title"
import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model"
// `since` is when the inbox was first used; Recent only lists sessions that finished a run after it.
const InboxState = Persistence.struct({
workingExpanded: Schema.Boolean,
since: Persistence.optional(Schema.Number),
})
/** The experimental Inbox interface mode: the session inbox in place of vertical tabs. */
export function SidebarInbox() {
const language = useLanguage()
const dialog = useDialog()
const inbox = createSessionInbox({ archived: true })
const [state, setState, , ready] = persisted(Persist.global("sidebar.inbox"), InboxState, {
workingExpanded: true,
})
const sections = createMemo(() => {
const list = inbox.entries()
// The most recently finished session comes first.
const finished = list
.filter((entry) => {
const since = state.since
return !inboxBusy(entry.status) && since !== undefined && (entry.session.time.idle ?? 0) >= since
})
.toSorted((a, b) => (b.session.time.idle ?? 0) - (a.session.time.idle ?? 0))
return {
needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"),
inbox: finished.filter((entry) => !inboxArchived(entry)),
archived: finished.filter(inboxArchived),
working: list.filter((entry) => entry.status === "working"),
}
})
// The client only refreshes `time.idle` for loaded sessions when a run ends, so load every working one.
createEffect(() => {
sections().working.forEach((entry) => {
if (entry.ctx.data.session.get(entry.session.id)) return
void entry.ctx.data.session.sync(entry.session.id).catch(() => undefined)
})
})
// The inbox starts empty and fills as sessions finish runs.
createEffect(() => {
if (ready() && state.since === undefined) setState("since", Date.now())
})
// Git info is not stored on the session, so eagerly load it for every shown row.
createEffect(() => {
const visible = sections()
inbox.loadBranches([...visible.needs, ...visible.working, ...visible.inbox, ...visible.archived])
})
function DeleteDialog(props: { entry: InboxEntry }) {
const confirm = async () => {
await inbox.remove(props.entry)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", {
name: sessionTitle(props.entry.session.title) ?? language.t("command.session.new"),
})}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" autofocus onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
const rows = (list: InboxEntry[]) => (
<For each={list.map((entry) => entry.key)}>
{(key) => (
<Show when={inbox.byKey().get(key)}>
{(entry) => (
<InboxRow
entry={entry()}
selected={inbox.selected(entry())}
now={inbox.now()}
branchLoading={inbox.branchLoading(entry())}
branch={inbox.branch(entry())}
worktree={inbox.inWorktree(entry())}
archived={inboxArchived(entry())}
onOpen={() => inbox.open(entry())}
onArchive={() => void inbox.archive(entry())}
onUnarchive={() => void inbox.unarchive(entry())}
onDelete={() => dialog.show(() => <DeleteDialog entry={entry()} />)}
/>
)}
</Show>
)}
</For>
)
return (
<div data-slot="sidebar-inbox" class="flex min-h-0 flex-1 flex-col">
<div data-slot="sidebar-inbox-scroll" class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-1">
<Show when={sections().needs.length > 0}>
<div class="flex shrink-0 flex-col gap-px pb-3">
<SectionHeader label={language.t("sidebar.inbox.section.needsAttention")} count={sections().needs.length} />
{rows(sections().needs)}
</div>
</Show>
<Show
when={sections().inbox.length > 0}
fallback={
<Show
when={
sections().needs.length === 0 && sections().working.length === 0 && sections().archived.length === 0
}
>
<div class="px-1.5 py-2 text-[13px] leading-4 text-v2-text-text-faint">
{language.t("sidebar.inbox.empty")}
</div>
</Show>
}
>
<Show when={sections().needs.length > 0}>
<SectionHeader label={language.t("sidebar.inbox.section.recent")} />
</Show>
{rows(sections().inbox)}
</Show>
<Show when={sections().archived.length > 0}>
<div class="flex shrink-0 flex-col gap-px pt-3">
<SectionHeader label={language.t("sidebar.inbox.section.archived")} />
{rows(sections().archived)}
</div>
</Show>
</div>
{/* Working sessions sit in a shelf pinned below the scrolling list. */}
<Show when={sections().working.length > 0}>
<div
data-slot="sidebar-inbox-working"
class="flex max-h-[40%] shrink-0 flex-col gap-px border-t border-v2-border-border-muted pt-1.5"
>
<SectionHeader
label={language.t("sidebar.inbox.section.working")}
count={sections().working.length}
expanded={state.workingExpanded}
onToggle={() => setState("workingExpanded", !state.workingExpanded)}
/>
<div class="flex min-h-0 flex-col gap-px overflow-y-auto">
{/* A collapsed shelf still shows the session that is open. */}
{rows(sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}
</div>
</div>
</Show>
</div>
)
}
function SectionHeader(props: { label: string; count?: number; expanded?: boolean; onToggle?: () => void }) {
const content = (): JSX.Element => (
<>
<span class="min-w-0 truncate">{props.label}</span>
<Show when={props.count}>
<span class="shrink-0 tabular-nums text-v2-text-text-faint">{props.count}</span>
</Show>
<Show when={props.onToggle}>
<Icon
name="chevron-down"
size="small"
class="ms-auto shrink-0 transition-transform"
classList={{ "-rotate-90 rtl:rotate-90": !props.expanded }}
/>
</Show>
</>
)
return (
<Show
when={props.onToggle}
fallback={
<div class="flex h-6 shrink-0 items-center gap-1.5 px-1.5 text-[12px] leading-4 font-medium text-v2-text-text-muted">
{content()}
</div>
}
>
{(toggle) => (
<button
type="button"
aria-expanded={props.expanded}
class="flex h-6 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-start text-[12px] leading-4 font-medium text-v2-text-text-muted hover:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-overlay-simple-overlay-hover"
onClick={toggle()}
>
{content()}
</button>
)}
</Show>
)
}
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 (
<Menu.Context modal={false}>
<Menu.Context.Trigger
as="button"
type="button"
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-1.5 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"
classList={{
"transition-opacity hover:opacity-100 focus-visible:opacity-100": !props.selected,
"opacity-70": status() === "working" && !props.selected,
"opacity-50": props.archived && !props.selected,
}}
onClick={props.onOpen}
>
<span class="flex h-4 w-full min-w-0 items-center gap-1.5 text-[12px] leading-4 text-v2-text-text-faint">
<span class="flex size-4 shrink-0 items-center justify-center">
<SessionTabAvatarView
project={project()}
directory={props.entry.session.location.directory}
unread={false}
/>
</span>
<span class="min-w-0 truncate">
{displayName(project() ?? { worktree: props.entry.session.location.directory })}
</span>
<span class="ms-auto shrink-0 tabular-nums" role={status() === "ready" ? undefined : "status"}>
<Show when={status() !== "ready"} fallback={age()}>
<span
classList={{
"text-v2-state-fg-warning": status() === "approval" || status() === "input",
"text-v2-state-fg-info": status() === "working",
"text-v2-state-fg-danger": status() === "failed",
"text-v2-state-fg-success": status() === "done",
}}
>
<Show
when={status() === "working"}
fallback={language.t(`sidebar.inbox.status.${status() as Exclude<InboxStatus, "ready">}`)}
>
{/* Announce only the status: the ticking duration would be re-read every second. */}
<span class="sr-only">{language.t("sidebar.inbox.status.working")}</span>
{/* Keep the status colour under the sweep. */}
<span
aria-hidden="true"
class="[&_[data-component=text-shimmer]]:[--text-shimmer-base-color:var(--v2-state-fg-info)]"
>
<TextShimmer text={language.t("sidebar.inbox.status.workingFor", { duration: elapsed() })} active />
</span>
</Show>
</span>
</Show>
</span>
</span>
<span
dir="auto"
class="w-full min-w-0 truncate text-[13px] leading-4"
// Rows recede once read so unread results stand out.
classList={{
"font-medium text-v2-text-text-base": unread(),
"text-v2-text-text-muted": !unread(),
}}
>
{sessionTabTitle(props.entry.session.title, language.t("session.tab.session"))}
</span>
<Show when={props.branchLoading || props.branch || props.worktree || tag()}>
<span class="flex h-4 w-full min-w-0 items-center gap-1 text-[12px] leading-4 text-v2-text-text-faint">
<Show when={props.branchLoading}>
<span
aria-hidden="true"
class="h-2.5 w-24 max-w-full animate-pulse rounded-full bg-v2-background-bg-layer-03"
/>
</Show>
<Show when={!props.branchLoading && (props.branch || props.worktree)}>
<span class="flex min-w-0 items-center gap-1">
{/* The worktree icon stands in for the branch icon to mark sessions outside the project checkout. */}
<Icon name={props.worktree ? "outline-worktree" : "branch"} size="small" class="shrink-0" />
<Show when={props.branch}>
{(name) => (
<span dir="ltr" class="min-w-0 truncate">
{name()}
</span>
)}
</Show>
</span>
</Show>
<Show when={tag()}>
{(name) => (
<Tooltip placement="right" value={name()} class="ms-auto flex shrink-0">
<Icon name="server" size="small" aria-label={name()} />
</Tooltip>
)}
</Show>
</span>
</Show>
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content>
<Show
when={props.archived}
fallback={<Menu.Item onSelect={props.onArchive}>{language.t("common.archive")}</Menu.Item>}
>
<Menu.Item onSelect={props.onUnarchive}>{language.t("sidebar.inbox.unarchive")}</Menu.Item>
</Show>
<Menu.Separator />
<Menu.Item onSelect={props.onDelete}>{language.t("common.delete")}…</Menu.Item>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
)
}
+22 -14
View File
@@ -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 } }) {
</div>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
<Show
when={settings.appearance.tabLayout() === "inbox"}
fallback={
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
currentTab={currentTab()}
onNavigate={(tab, el) => {
tabs.select(tab)
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
}}
onClose={(tab) => {
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
if (index !== -1) tabsStoreActions.closeTab(index)
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
}
>
<SidebarInbox />
</Show>
</div>
<Show when={titlebarItems.ids().length > 0}>
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">