feat(app): simplify inbox rows and keep Recent and the current session in view (#61445)

This commit is contained in:
usrnk1 2026-11-03 19:50:11 +01:00 • committed by GitHub
parent 9d002c27a7
commit 826381e44d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 809 additions and 192 deletions

View file

@ -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)
})

View file

@ -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),

View file

@ -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 }))

View file

@ -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",

View file

@ -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)

View file

@ -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 (

View file

@ -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) {
<div
ref={(element) => 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) {
<div
ref={(element) => 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)))",

View file

@ -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 (
<div class="flex flex-col gap-3" style={{ width: `${props.width}px` }}>
<div class="rounded-[6px] bg-v2-background-bg-deep p-1">
<Show
when={!state.removed}
fallback={<span class="text-[13px] leading-4 text-v2-text-text-muted">Session deleted</span>}
>
<InboxSessionCard
project={
props.projectKnown
? { id: "lab-opencode", name: props.project, worktree: "/workspace/opencode", icon: { color: "blue" } }
: undefined
}
directory={props.worktree ? "/workspace/opencode/worktrees/tooltip-status" : "/workspace/opencode"}
title={props.title}
status={props.status}
selected={state.selected}
archived={state.archived}
server={props.remote ? "Build server · build.example.internal" : undefined}
branch={props.branch || undefined}
worktree={props.worktree}
now={now}
since={now - props.ageMinutes * 60_000}
onOpen={() => setState("selected", true)}
onArchive={() => setState("archived", true)}
onUnarchive={() => setState("archived", false)}
onDelete={() => setState("removed", true)}
/>
</Show>
</div>
<Button
variant="ghost-muted"
size="small"
class="self-start"
onClick={() => setState({ selected: props.selected, archived: props.archived, removed: false })}
>
Reset session
</Button>
<span class="text-[12px] leading-4 text-v2-text-text-muted">
Hover or Tab to inspect. Click to select; right-click for actions.
</span>
</div>
)
}
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<typeof SessionCardStory>[0]) => (
<div class="flex flex-wrap items-start gap-6">
<For each={["done", "ready", "working", "approval", "input", "failed"] satisfies InboxStatus[]}>
{(status) => (
<div class="flex flex-col gap-2">
<span class="text-[13px] leading-4 text-v2-text-text-muted">{status}</span>
<SessionCardStory {...args} status={status} />
</div>
)}
</For>
</div>
),
}

View file

@ -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<string>(),
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) => (
<For each={list()}>
{(session) => (
<InboxSessionCard
project={{
id: session.index % 3 === 2 ? "lab-port-ui" : "lab-opencode",
name: session.index % 3 === 2 ? "port-ui" : "opencode",
worktree: "/workspace/opencode",
icon: { color: session.index % 3 === 2 ? "purple" : "blue" },
}}
directory={session.index % 2 === 0 ? "/workspace/opencode/worktrees/tooltip-status" : "/workspace/opencode"}
title={session.title}
status={session.status}
selected={state.selected === session.id}
archived={false}
server={session.index % 3 === 2 ? "Build server · build.example.internal" : undefined}
branch={session.index % 4 === 3 ? undefined : "dev"}
worktree={session.index % 2 === 0}
now={now}
since={now - (session.index + 1) * 7 * 60_000}
onOpen={() => setState("selected", session.id)}
onArchive={() => {
remove(session.id, `Archived ${session.title}`)
setState("archived", true)
}}
onUnarchive={() => {}}
onDelete={() => remove(session.id, `Deleted ${session.title}`)}
/>
)}
</For>
)
return (
<div class="flex flex-col gap-3">
<div
class="flex min-h-0 flex-col overflow-hidden rounded-[6px] bg-v2-background-bg-deep py-2 ps-[11px] pe-[3px]"
style={{ width: `${props.width}px`, height: `${props.height}px` }}
>
<InboxSessionList
currentCount={current().length}
needsCount={needs().length}
recentCount={recent().length}
workingCount={working().length}
archived={state.archived}
workingExpanded={state.expanded}
onToggleWorking={() => 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)}
/>
</div>
<div class="flex items-center gap-3 text-[12px] leading-4 text-v2-text-text-muted">
<Button
variant="ghost-muted"
size="small"
onClick={() =>
setState({
selected: props.selectedSession,
expanded: props.workingExpanded,
archived: props.archived,
removed: [],
action: "",
})
}
>
Reset list
</Button>
<span aria-live="polite">{state.action}</span>
</div>
<span class="max-w-[360px] text-[12px] leading-4 text-v2-text-text-muted">
Click to select; right-click to archive or delete. Working collapses. All actions stay local to this story.
</span>
</div>
)
}
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 } }

View file

@ -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)
})

View file

@ -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) => (
<Show when={inbox.byKey().get(key)}>
{(entry) => (
<InboxRow
entry={entry()}
<InboxSessionCard
project={entry().ctx.projects.forSession(entry().session)}
directory={entry().session.location.directory}
title={entry().session.title}
status={entry().status}
server={servers.tag(entry().conn)}
since={
entry().status === "working"
? workingSince(entry().session)
: inboxBusy(entry().status)
? (workingSince(entry().session) ?? entry().session.time.updated)
: (entry().session.time.idle ?? entry().session.time.updated)
}
selected={inbox.selected(entry())}
now={inbox.now()}
branchLoading={inbox.branchLoading(entry())}
branch={inbox.branch(entry())}
worktree={inbox.inWorktree(entry())}
archived={inboxArchived(entry())}
@ -132,75 +152,118 @@ export function SidebarInbox() {
</For>
)
return (
<InboxSessionList
needsCount={sections().needs.length}
currentCount={sections().current.length}
recentCount={sections().inbox.length}
workingCount={sections().working.length}
archived={sections().archived}
workingExpanded={state.workingExpanded}
onToggleWorking={() => setState("workingExpanded", !state.workingExpanded)}
onArchived={async () => {
const { DialogSessionSearch } = await import("@/shell/commands/session-search")
void dialog.show(() => <DialogSessionSearch archived />)
}}
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 (
<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 data-slot="sidebar-inbox-scroll" class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pb-1">
<Show when={props.currentCount > 0}>
<div class="flex shrink-0 flex-col gap-0.5 pb-2.5">{props.current}</div>
</Show>
<Show when={props.needsCount > 0}>
<div class="flex shrink-0 flex-col gap-2 pb-2.5">
<SectionHeader label={language.t("sidebar.inbox.section.needsAttention")} />
<div class="flex flex-col gap-0.5">{props.needs}</div>
</div>
</Show>
<Show
when={sections().inbox.length > 0}
when={props.recentCount > 0}
fallback={
<Show
when={
sections().needs.length === 0 && sections().working.length === 0 && sections().archived.length === 0
}
>
<Show when={props.needsCount === 0 && props.currentCount === 0 && props.workingCount === 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}>
<div class="flex shrink-0 flex-col gap-2">
<SectionHeader label={language.t("sidebar.inbox.section.recent")} />
</Show>
{rows(() => sections().inbox)}
<div class="flex flex-col gap-0.5">{props.recent}</div>
</div>
</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)}
<Show when={props.archived}>
<div class="shrink-0 pt-3">
<Button variant="ghost-muted" size="normal" class="w-full justify-start px-2" onClick={props.onArchived}>
{language.t("sidebar.inbox.section.archived")}
</Button>
</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"
>
<Show when={props.workingCount > 0}>
<div data-slot="sidebar-inbox-working" class="relative flex max-h-[40%] shrink-0 flex-col gap-2 pt-[7px]">
<span
aria-hidden="true"
class="pointer-events-none absolute top-0 h-px bg-v2-border-border-muted"
style={{
"inset-inline-start": "calc(1px - var(--sidebar-inset-start, 11px))",
"inset-inline-end": "calc(1px - (var(--sidebar-inset-end, 3px) + var(--sidebar-divider-bleed, 0px)))",
}}
/>
<SectionHeader
label={language.t("sidebar.inbox.section.working")}
count={sections().working.length}
expanded={state.workingExpanded}
onToggle={() => setState("workingExpanded", !state.workingExpanded)}
expanded={props.workingExpanded}
shimmer={!props.workingExpanded}
onToggle={props.onToggleWorking}
/>
<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>
<Show when={props.workingExpanded}>
<div class="flex min-h-0 flex-col gap-0.5 overflow-y-auto">{props.working}</div>
</Show>
</div>
</Show>
</div>
)
}
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 => (
<>
<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>
<span class="min-w-0 truncate">
<Show when={props.shimmer} fallback={props.label}>
<TextShimmer text={props.label} active />
</Show>
</span>
<Show when={props.onToggle}>
<Icon
name="chevron-down"
name="fill-triangle-down"
size="small"
class="ms-auto shrink-0 transition-transform"
class="shrink-0 transition-transform"
classList={{ "-rotate-90 rtl:rotate-90": !props.expanded }}
/>
</Show>
@ -211,7 +274,7 @@ function SectionHeader(props: { label: string; count?: number; expanded?: boolea
<Show
when={props.onToggle}
fallback={
<div class="flex h-6 shrink-0 items-center gap-1.5 px-2 text-[12px] leading-4 font-medium text-v2-text-text-muted">
<div class="flex h-6 shrink-0 items-center gap-1.5 px-2 text-[13px] leading-4 font-medium text-v2-text-text-muted">
{content()}
</div>
}
@ -220,7 +283,7 @@ function SectionHeader(props: { label: string; count?: number; expanded?: boolea
<button
type="button"
aria-expanded={props.expanded}
class="flex h-6 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-2 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"
class="flex h-6 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-2 text-start text-[13px] 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()}
@ -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}
>
<span class="flex h-4 w-full min-w-0 items-center gap-2 text-[12px] leading-4 text-v2-text-text-faint">
<span class="flex h-4 w-full min-w-0 shrink-0 items-center gap-2">
<span class="flex size-4 shrink-0 items-center justify-center">
<SessionTabAvatarView
project={project()}
directory={props.entry.session.location.directory}
unread={false}
/>
<SessionTabAvatarView project={props.project} directory={props.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={statusLabel(status())}>
{/* 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={working()} active />
</span>
</Show>
</span>
</Show>
<span
dir="auto"
class="min-w-0 flex-1 truncate text-[13px] leading-4 font-medium tracking-[-0.04px] text-v2-text-text-base"
>
{sessionTabTitle(props.title, language.t("session.tab.session"))}
</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"
/>
<span class="relative flex h-4 w-full min-w-0 shrink-0 items-center gap-2 ps-6 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted">
<Show when={props.server}>
{(name) => (
<Tooltip placement="right" value={name()} class="absolute start-0 top-0 flex size-4 shrink-0">
<Icon name="server" aria-label={name()} class="text-v2-icon-icon-muted" />
</Tooltip>
)}
</Show>
<span class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-v2-icon-icon-muted">
<span dir="auto" class="min-w-0 truncate text-v2-text-text-muted">
{displayName(props.project ?? { worktree: props.directory })}
</span>
<Show when={props.worktree}>
<Tooltip placement="right" value={props.directory} class="flex shrink-0">
<Icon name="outline-worktree" />
</Tooltip>
</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()}>
<Show when={props.branch}>
{(name) => (
<Tooltip placement="right" value={name()} class="ms-auto flex shrink-0">
<Icon name="server" size="small" aria-label={name()} />
<Tooltip placement="right" value={name()} class="flex shrink-0">
<Icon name="branch" />
</Tooltip>
)}
</Show>
</span>
</Show>
<span
class="ms-auto flex shrink-0 items-center gap-1.5 text-[12px] tabular-nums"
role={status() === "ready" ? undefined : "status"}
>
<Show when={status() !== "ready"}>
<span class="sr-only">{statusLabel(status())}</span>
<Show
when={status() === "working"}
fallback={
<span
aria-hidden="true"
class="size-[7px] shrink-0 rounded-full"
classList={{
"bg-v2-state-fg-warning": status() === "approval" || status() === "input",
"bg-v2-state-fg-danger": status() === "failed",
"bg-v2-background-bg-accent": status() === "done",
}}
/>
}
>
<ActivityRing class="shrink-0 text-v2-icon-icon-muted" />
</Show>
</Show>
{/* Announce only the state, not each age update. */}
<Show when={age()}>
{(label) => (
<span aria-hidden={status() === "ready" ? undefined : "true"} class="text-v2-text-text-faint">
{label()}
</span>
)}
</Show>
</span>
</span>
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content>

View file

@ -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")}
>
<Icon name="magnifying-glass" class="shrink-0" />