mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 13:15:21 +00:00
feat(app): simplify inbox rows and keep Recent and the current session in view (#61445)
This commit is contained in:
parent
9d002c27a7
commit
826381e44d
12 changed files with 809 additions and 192 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)))",
|
||||
|
|
|
|||
163
packages/app/src/shell/titlebar/inbox-card.stories.tsx
Normal file
163
packages/app/src/shell/titlebar/inbox-card.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
217
packages/app/src/shell/titlebar/inbox-list.stories.tsx
Normal file
217
packages/app/src/shell/titlebar/inbox-list.stories.tsx
Normal 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 } }
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue