mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): bring back the experimental Inbox sidebar (#57312)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
781a9f66e4
commit
20ffb20946
@@ -647,9 +647,16 @@ export const dict = {
|
||||
|
||||
"sidebar.settings": "Settings",
|
||||
"sidebar.help": "Help",
|
||||
"sidebar.inbox.empty": "Sessions you work on will appear here",
|
||||
"sidebar.inbox.section.needsAttention": "Needs attention",
|
||||
"sidebar.inbox.section.working": "Working",
|
||||
"sidebar.inbox.section.recent": "Recent",
|
||||
"sidebar.inbox.section.archived": "Archived",
|
||||
"sidebar.inbox.unarchive": "Unarchive",
|
||||
"sidebar.inbox.status.approval": "Approval",
|
||||
"sidebar.inbox.status.input": "Question",
|
||||
"sidebar.inbox.status.working": "Working",
|
||||
"sidebar.inbox.status.workingFor": "Working {{duration}}",
|
||||
"sidebar.inbox.status.failed": "Failed",
|
||||
"sidebar.inbox.status.done": "Done",
|
||||
"sidebar.inbox.elapsed.seconds.one": "{{count}}s",
|
||||
@@ -773,6 +780,10 @@ export const dict = {
|
||||
"settings.general.row.interfaceMode.top.description": "Browser-style tabs across the top of the window.",
|
||||
"settings.general.row.interfaceMode.side": "Side tabs",
|
||||
"settings.general.row.interfaceMode.side.description": "Browser-style tabs in a sidebar.",
|
||||
"settings.general.row.interfaceMode.inbox": "Inbox",
|
||||
"settings.general.row.interfaceMode.inbox.description":
|
||||
"Every session across your servers, sorted by what needs you next.",
|
||||
"settings.general.row.interfaceMode.experimental": "Experimental",
|
||||
"settings.appearance.row.reduceMotion.title": "Reduce motion",
|
||||
"settings.appearance.row.reduceMotion.description": "Reduce animations or match your system",
|
||||
"settings.appearance.row.reduceMotion.system": "System",
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
border: 4px solid var(--v2-background-bg-accent);
|
||||
}
|
||||
|
||||
[data-slot="interface-mode-badge"] {
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
|
||||
[data-slot="interface-mode-description"] {
|
||||
padding-inline-start: 22px;
|
||||
font-size: 13px;
|
||||
@@ -161,6 +171,35 @@
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-slot="preview-rail-heading"] {
|
||||
width: 30%;
|
||||
height: 3px;
|
||||
margin: 4px 3px 1px;
|
||||
border-radius: 1.5px;
|
||||
background-color: var(--preview-ink-strong);
|
||||
opacity: 0.6;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="preview-tab"] i[data-status] {
|
||||
border-radius: 50%;
|
||||
|
||||
&[data-status="approval"] {
|
||||
background-color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
&[data-status="done"] {
|
||||
background-color: var(--v2-state-fg-success);
|
||||
}
|
||||
|
||||
&[data-status="working"] {
|
||||
background-color: var(--v2-state-fg-info);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="preview-main"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Match, Switch, createUniqueId } from "solid-js"
|
||||
import { For, Match, Show, Switch, createUniqueId } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { type TabLayout, useSettings } from "@/settings/model"
|
||||
import "./interface-mode.css"
|
||||
@@ -6,6 +6,7 @@ import "./interface-mode.css"
|
||||
const modes = [
|
||||
{ id: "top", layout: "horizontal" },
|
||||
{ id: "side", layout: "vertical" },
|
||||
{ id: "inbox", layout: "inbox" },
|
||||
] as const satisfies { id: string; layout: TabLayout }[]
|
||||
|
||||
export function InterfaceModeSetting() {
|
||||
@@ -39,12 +40,20 @@ export function InterfaceModeSetting() {
|
||||
<Match when={mode.id === "side"}>
|
||||
<SideTabsPreview />
|
||||
</Match>
|
||||
<Match when={mode.id === "inbox"}>
|
||||
<InboxPreview />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div data-slot="interface-mode-copy">
|
||||
<span data-slot="interface-mode-title">
|
||||
<span data-slot="interface-mode-radio" />
|
||||
<span id={`${id}-${mode.id}-title`}>{language.t(`settings.general.row.interfaceMode.${mode.id}`)}</span>
|
||||
<Show when={mode.id === "inbox"}>
|
||||
<span data-slot="interface-mode-badge">
|
||||
{language.t("settings.general.row.interfaceMode.experimental")}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span id={`${id}-${mode.id}`} data-slot="interface-mode-description">
|
||||
{language.t(`settings.general.row.interfaceMode.${mode.id}.description`)}
|
||||
@@ -94,6 +103,48 @@ function SideTabsPreview() {
|
||||
)
|
||||
}
|
||||
|
||||
// Mirrors the inbox: sessions that need you, then finished work, then sessions still running.
|
||||
const inboxSections = [
|
||||
{ rows: [{ width: 68, status: "approval" }] },
|
||||
{
|
||||
rows: [
|
||||
{ width: 56, status: "done" },
|
||||
{ width: 72, status: "done" },
|
||||
],
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
{ width: 48, status: "working" },
|
||||
{ width: 62, status: "working" },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
function InboxPreview() {
|
||||
return (
|
||||
<div data-slot="preview-body">
|
||||
<div data-slot="preview-rail">
|
||||
<For each={inboxSections}>
|
||||
{(section, sectionIndex) => (
|
||||
<>
|
||||
<span data-slot="preview-rail-heading" />
|
||||
<For each={section.rows}>
|
||||
{(row, index) => (
|
||||
<span data-slot="preview-tab" data-active={sectionIndex() === 1 && index() === 0 ? "" : undefined}>
|
||||
<i data-status={row.status} />
|
||||
<span style={{ width: `${row.width}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<PreviewWorkspace />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewWorkspace() {
|
||||
return (
|
||||
<div data-slot="preview-body">
|
||||
|
||||
@@ -132,13 +132,8 @@ const appearanceSchema = Persistence.struct({
|
||||
mono: Schema.String,
|
||||
sans: Schema.String,
|
||||
terminal: Schema.String,
|
||||
// "inbox" was an experimental vertical layout; its sessions now live on Home, so it reads back as vertical tabs.
|
||||
tabLayout: Schema.Literals(["horizontal", "vertical", "inbox"]).pipe(
|
||||
Schema.decodeTo(Schema.Literals(["horizontal", "vertical"]), {
|
||||
decode: SchemaGetter.transform((value) => (value === "inbox" ? "vertical" : value)),
|
||||
encode: SchemaGetter.transform((value) => value),
|
||||
}),
|
||||
),
|
||||
// "inbox" is experimental: the vertical layout with a server-wide session inbox in place of tabs.
|
||||
tabLayout: Schema.Literals(["horizontal", "vertical", "inbox"]),
|
||||
sidebarOpen: Persistence.optional(Schema.Boolean),
|
||||
reduceMotion: Schema.Literals(["system", "on", "off"]),
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { sessionBusy, sessionRequests } from "@/shell/layout/project-avatar-state"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { parseHomeSessionIndex } from "@/home/sessions/index"
|
||||
import { sessionFormRequest, sessionPermissionRequest } from "@/session/requests/session-request-tree"
|
||||
import { sessionFormRequest, sessionPermissionRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
|
||||
import { notifySessionTabsRemoved } from "./session-events"
|
||||
import { errorMessage } from "@/shell/layout/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
@@ -24,6 +25,9 @@ const BRANCH_BATCH = 4
|
||||
// Root sessions fetched per server.
|
||||
const INDEX_LIMIT = 200
|
||||
|
||||
// The index excludes archived sessions, so a view that lists them loads the newest archived ones separately.
|
||||
const ARCHIVED_LIMIT = 50
|
||||
|
||||
export type InboxStatus = "approval" | "input" | "working" | "failed" | "done" | "ready"
|
||||
|
||||
export type InboxEntry = {
|
||||
@@ -37,9 +41,10 @@ export type InboxEntry = {
|
||||
|
||||
/**
|
||||
* Root sessions across every visible server with the state a person acts on: waiting for an answer, working,
|
||||
* finished and unread, or read. Home lists them.
|
||||
* finished and unread, or read. Home and the experimental Inbox sidebar list them; only the sidebar loads archived
|
||||
* sessions.
|
||||
*/
|
||||
export function createSessionInbox() {
|
||||
export function createSessionInbox(options: { archived?: boolean } = {}) {
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const settings = useSettings()
|
||||
@@ -59,6 +64,9 @@ export function createSessionInbox() {
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.filter((session) => session.id !== sessionID),
|
||||
)
|
||||
queryClient.setQueryData<SessionInfo[]>(["inbox-archived-sessions", conn], (current) =>
|
||||
current?.filter((session) => session.id !== sessionID),
|
||||
)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -85,6 +93,22 @@ export function createSessionInbox() {
|
||||
})),
|
||||
}))
|
||||
|
||||
const archivedQueries = useQueries(() => ({
|
||||
queries: connections().map((item) => ({
|
||||
queryKey: ["inbox-archived-sessions", item.conn] as const,
|
||||
enabled: !!options.archived && item.ctx.sdk.connection.status() === "connected",
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
item.ctx.sdk.api.session
|
||||
.list({ limit: ARCHIVED_LIMIT, order: "desc", parentID: null, archived: true }, { signal })
|
||||
.then((response) => response.data),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
select: selectSessions,
|
||||
})),
|
||||
}))
|
||||
|
||||
const entries = createMemo(() =>
|
||||
connections()
|
||||
.flatMap((item, index) => {
|
||||
@@ -116,9 +140,11 @@ export function createSessionInbox() {
|
||||
|
||||
// Locally known sessions come last so live archive and unarchive events win over cached indexes.
|
||||
const sessions = new Map(
|
||||
[...(queries[index]?.data?.() ?? []), ...data.session.list()].map(
|
||||
(session) => [session.id, session] as const,
|
||||
),
|
||||
[
|
||||
...(queries[index]?.data?.() ?? []),
|
||||
...(options.archived ? (archivedQueries[index]?.data?.() ?? []) : []),
|
||||
...data.session.list(),
|
||||
].map((session) => [session.id, session] as const),
|
||||
)
|
||||
|
||||
return data.session.apply([...sessions.values()].filter((session) => !session.parentID)).map(
|
||||
@@ -232,6 +258,17 @@ export function createSessionInbox() {
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
})
|
||||
|
||||
// Move the session between the cached indexes so it changes section before either refetches.
|
||||
const move = (entry: InboxEntry, archivedAt: number | undefined) => {
|
||||
const session = { ...entry.session, time: { ...entry.session.time, archived: archivedAt } }
|
||||
|
||||
forget(entry.conn, entry.session.id)
|
||||
queryClient.setQueryData<SessionInfo[]>(
|
||||
[archivedAt === undefined ? "home-sessions" : "inbox-archived-sessions", entry.conn],
|
||||
(current) => current && [session, ...current],
|
||||
)
|
||||
}
|
||||
|
||||
// The pending request anywhere in the session's family, for views that answer it inline.
|
||||
const family = (entry: InboxEntry) =>
|
||||
[...new Set([entry.session.id, ...entry.ctx.data.session.family(entry.session.id)])].flatMap((id) => {
|
||||
@@ -271,6 +308,43 @@ export function createSessionInbox() {
|
||||
if (!entry.ctx.data.session.get(entry.session.id)) entry.ctx.data.session.remember(entry.session)
|
||||
entry.ctx.notification.session.markViewed(entry.session.id)
|
||||
},
|
||||
archive: (entry: InboxEntry) =>
|
||||
entry.ctx.sdk.api.session
|
||||
.update({ sessionID: entry.session.id, archived: true })
|
||||
.then(() => {
|
||||
move(entry, Date.now())
|
||||
notifySessionTabsRemoved({
|
||||
server: entry.server,
|
||||
directory: entry.session.location.directory,
|
||||
sessionIDs: [entry.session.id],
|
||||
})
|
||||
})
|
||||
.catch(requestFailed),
|
||||
unarchive: (entry: InboxEntry) =>
|
||||
entry.ctx.sdk.api.session
|
||||
.update({ sessionID: entry.session.id, archived: false })
|
||||
.then(() => move(entry, undefined))
|
||||
.catch(requestFailed),
|
||||
remove: (entry: InboxEntry) => {
|
||||
const ids = sessionTreeIDs(entry.ctx.data.session.list(), entry.session.id)
|
||||
|
||||
return entry.ctx.data.session
|
||||
.remove(entry.session.id)
|
||||
.then(() => {
|
||||
forget(entry.conn, entry.session.id)
|
||||
notifySessionTabsRemoved({
|
||||
server: entry.server,
|
||||
directory: entry.session.location.directory,
|
||||
sessionIDs: ids,
|
||||
})
|
||||
})
|
||||
.catch((cause) =>
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(cause, language.t("session.delete.failed.title")),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { sessionTitle } from "@/session/title"
|
||||
import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model"
|
||||
|
||||
// `since` is when the inbox was first used; Recent only lists sessions that finished a run after it.
|
||||
const InboxState = Persistence.struct({
|
||||
workingExpanded: Schema.Boolean,
|
||||
since: Persistence.optional(Schema.Number),
|
||||
})
|
||||
|
||||
/** The experimental Inbox interface mode: the session inbox in place of vertical tabs. */
|
||||
export function SidebarInbox() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const inbox = createSessionInbox({ archived: true })
|
||||
|
||||
const [state, setState, , ready] = persisted(Persist.global("sidebar.inbox"), InboxState, {
|
||||
workingExpanded: true,
|
||||
})
|
||||
|
||||
const sections = createMemo(() => {
|
||||
const list = inbox.entries()
|
||||
// The most recently finished session comes first.
|
||||
const finished = list
|
||||
.filter((entry) => {
|
||||
const since = state.since
|
||||
|
||||
return !inboxBusy(entry.status) && since !== undefined && (entry.session.time.idle ?? 0) >= since
|
||||
})
|
||||
.toSorted((a, b) => (b.session.time.idle ?? 0) - (a.session.time.idle ?? 0))
|
||||
|
||||
return {
|
||||
needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"),
|
||||
inbox: finished.filter((entry) => !inboxArchived(entry)),
|
||||
archived: finished.filter(inboxArchived),
|
||||
working: list.filter((entry) => entry.status === "working"),
|
||||
}
|
||||
})
|
||||
|
||||
// The client only refreshes `time.idle` for loaded sessions when a run ends, so load every working one.
|
||||
createEffect(() => {
|
||||
sections().working.forEach((entry) => {
|
||||
if (entry.ctx.data.session.get(entry.session.id)) return
|
||||
void entry.ctx.data.session.sync(entry.session.id).catch(() => undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// The inbox starts empty and fills as sessions finish runs.
|
||||
createEffect(() => {
|
||||
if (ready() && state.since === undefined) setState("since", Date.now())
|
||||
})
|
||||
|
||||
// Git info is not stored on the session, so eagerly load it for every shown row.
|
||||
createEffect(() => {
|
||||
const visible = sections()
|
||||
|
||||
inbox.loadBranches([...visible.needs, ...visible.working, ...visible.inbox, ...visible.archived])
|
||||
})
|
||||
|
||||
function DeleteDialog(props: { entry: InboxEntry }) {
|
||||
const confirm = async () => {
|
||||
await inbox.remove(props.entry)
|
||||
dialog.close()
|
||||
}
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", {
|
||||
name: sessionTitle(props.entry.session.title) ?? language.t("command.session.new"),
|
||||
})}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" autofocus onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = (list: InboxEntry[]) => (
|
||||
<For each={list.map((entry) => entry.key)}>
|
||||
{(key) => (
|
||||
<Show when={inbox.byKey().get(key)}>
|
||||
{(entry) => (
|
||||
<InboxRow
|
||||
entry={entry()}
|
||||
selected={inbox.selected(entry())}
|
||||
now={inbox.now()}
|
||||
branchLoading={inbox.branchLoading(entry())}
|
||||
branch={inbox.branch(entry())}
|
||||
worktree={inbox.inWorktree(entry())}
|
||||
archived={inboxArchived(entry())}
|
||||
onOpen={() => inbox.open(entry())}
|
||||
onArchive={() => void inbox.archive(entry())}
|
||||
onUnarchive={() => void inbox.unarchive(entry())}
|
||||
onDelete={() => dialog.show(() => <DeleteDialog entry={entry()} />)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-slot="sidebar-inbox" class="flex min-h-0 flex-1 flex-col">
|
||||
<div data-slot="sidebar-inbox-scroll" class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-1">
|
||||
<Show when={sections().needs.length > 0}>
|
||||
<div class="flex shrink-0 flex-col gap-px pb-3">
|
||||
<SectionHeader label={language.t("sidebar.inbox.section.needsAttention")} count={sections().needs.length} />
|
||||
{rows(sections().needs)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={sections().inbox.length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={
|
||||
sections().needs.length === 0 && sections().working.length === 0 && sections().archived.length === 0
|
||||
}
|
||||
>
|
||||
<div class="px-1.5 py-2 text-[13px] leading-4 text-v2-text-text-faint">
|
||||
{language.t("sidebar.inbox.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={sections().needs.length > 0}>
|
||||
<SectionHeader label={language.t("sidebar.inbox.section.recent")} />
|
||||
</Show>
|
||||
{rows(sections().inbox)}
|
||||
</Show>
|
||||
<Show when={sections().archived.length > 0}>
|
||||
<div class="flex shrink-0 flex-col gap-px pt-3">
|
||||
<SectionHeader label={language.t("sidebar.inbox.section.archived")} />
|
||||
{rows(sections().archived)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Working sessions sit in a shelf pinned below the scrolling list. */}
|
||||
<Show when={sections().working.length > 0}>
|
||||
<div
|
||||
data-slot="sidebar-inbox-working"
|
||||
class="flex max-h-[40%] shrink-0 flex-col gap-px border-t border-v2-border-border-muted pt-1.5"
|
||||
>
|
||||
<SectionHeader
|
||||
label={language.t("sidebar.inbox.section.working")}
|
||||
count={sections().working.length}
|
||||
expanded={state.workingExpanded}
|
||||
onToggle={() => setState("workingExpanded", !state.workingExpanded)}
|
||||
/>
|
||||
<div class="flex min-h-0 flex-col gap-px overflow-y-auto">
|
||||
{/* A collapsed shelf still shows the session that is open. */}
|
||||
{rows(sections().working.filter((entry) => state.workingExpanded || inbox.selected(entry)))}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader(props: { label: string; count?: number; expanded?: boolean; onToggle?: () => void }) {
|
||||
const content = (): JSX.Element => (
|
||||
<>
|
||||
<span class="min-w-0 truncate">{props.label}</span>
|
||||
<Show when={props.count}>
|
||||
<span class="shrink-0 tabular-nums text-v2-text-text-faint">{props.count}</span>
|
||||
</Show>
|
||||
<Show when={props.onToggle}>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="ms-auto shrink-0 transition-transform"
|
||||
classList={{ "-rotate-90 rtl:rotate-90": !props.expanded }}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.onToggle}
|
||||
fallback={
|
||||
<div class="flex h-6 shrink-0 items-center gap-1.5 px-1.5 text-[12px] leading-4 font-medium text-v2-text-text-muted">
|
||||
{content()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(toggle) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={props.expanded}
|
||||
class="flex h-6 w-full shrink-0 items-center gap-1.5 rounded-[6px] px-1.5 text-start text-[12px] leading-4 font-medium text-v2-text-text-muted hover:text-v2-text-text-base focus-visible:outline-none focus-visible:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={toggle()}
|
||||
>
|
||||
{content()}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow(props: {
|
||||
entry: InboxEntry
|
||||
selected: boolean
|
||||
now: number
|
||||
branchLoading: boolean
|
||||
branch: string | undefined
|
||||
worktree: boolean
|
||||
archived: boolean
|
||||
onOpen: () => void
|
||||
onArchive: () => void
|
||||
onUnarchive: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const servers = useServers()
|
||||
const project = createMemo(() => props.entry.ctx.projects.forSession(props.entry.session))
|
||||
const status = () => props.entry.status
|
||||
const unread = () => status() !== "ready" && status() !== "working"
|
||||
|
||||
// Sessions on the current server are not marked.
|
||||
const tag = () => servers.tag(props.entry.conn)
|
||||
|
||||
const compact = (seconds: number) => {
|
||||
if (seconds < 3600) return language.plural("sidebar.inbox.elapsed.minutes", Math.floor(seconds / 60))
|
||||
|
||||
if (seconds < 86_400) return language.plural("sidebar.inbox.elapsed.hours", Math.floor(seconds / 3600))
|
||||
|
||||
return language.plural("sidebar.inbox.elapsed.days", Math.floor(seconds / 86_400))
|
||||
}
|
||||
|
||||
const elapsed = () => {
|
||||
// `time.updated` moves when a prompt is enqueued, so it marks when the current run was requested.
|
||||
const seconds = Math.max(0, Math.floor((props.now - props.entry.session.time.updated) / 1000))
|
||||
|
||||
return seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds)
|
||||
}
|
||||
|
||||
const age = () => {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.floor((props.now - (props.entry.session.time.idle ?? props.entry.session.time.updated)) / 1000),
|
||||
)
|
||||
|
||||
return seconds < 60 ? language.t("sidebar.inbox.time.now") : compact(seconds)
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Context modal={false}>
|
||||
<Menu.Context.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-slot="sidebar-inbox-item"
|
||||
data-status={status()}
|
||||
data-state={props.selected ? "active" : undefined}
|
||||
class="group flex w-full shrink-0 flex-col gap-0.5 rounded-[6px] px-1.5 py-1.5 text-start hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none focus-visible:bg-v2-overlay-simple-overlay-hover data-[state=active]:bg-v2-background-bg-layer-02"
|
||||
classList={{
|
||||
"transition-opacity hover:opacity-100 focus-visible:opacity-100": !props.selected,
|
||||
"opacity-70": status() === "working" && !props.selected,
|
||||
"opacity-50": props.archived && !props.selected,
|
||||
}}
|
||||
onClick={props.onOpen}
|
||||
>
|
||||
<span class="flex h-4 w-full min-w-0 items-center gap-1.5 text-[12px] leading-4 text-v2-text-text-faint">
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<SessionTabAvatarView
|
||||
project={project()}
|
||||
directory={props.entry.session.location.directory}
|
||||
unread={false}
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0 truncate">
|
||||
{displayName(project() ?? { worktree: props.entry.session.location.directory })}
|
||||
</span>
|
||||
<span class="ms-auto shrink-0 tabular-nums" role={status() === "ready" ? undefined : "status"}>
|
||||
<Show when={status() !== "ready"} fallback={age()}>
|
||||
<span
|
||||
classList={{
|
||||
"text-v2-state-fg-warning": status() === "approval" || status() === "input",
|
||||
"text-v2-state-fg-info": status() === "working",
|
||||
"text-v2-state-fg-danger": status() === "failed",
|
||||
"text-v2-state-fg-success": status() === "done",
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={status() === "working"}
|
||||
fallback={language.t(`sidebar.inbox.status.${status() as Exclude<InboxStatus, "ready">}`)}
|
||||
>
|
||||
{/* Announce only the status: the ticking duration would be re-read every second. */}
|
||||
<span class="sr-only">{language.t("sidebar.inbox.status.working")}</span>
|
||||
{/* Keep the status colour under the sweep. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="[&_[data-component=text-shimmer]]:[--text-shimmer-base-color:var(--v2-state-fg-info)]"
|
||||
>
|
||||
<TextShimmer text={language.t("sidebar.inbox.status.workingFor", { duration: elapsed() })} active />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
dir="auto"
|
||||
class="w-full min-w-0 truncate text-[13px] leading-4"
|
||||
// Rows recede once read so unread results stand out.
|
||||
classList={{
|
||||
"font-medium text-v2-text-text-base": unread(),
|
||||
"text-v2-text-text-muted": !unread(),
|
||||
}}
|
||||
>
|
||||
{sessionTabTitle(props.entry.session.title, language.t("session.tab.session"))}
|
||||
</span>
|
||||
<Show when={props.branchLoading || props.branch || props.worktree || tag()}>
|
||||
<span class="flex h-4 w-full min-w-0 items-center gap-1 text-[12px] leading-4 text-v2-text-text-faint">
|
||||
<Show when={props.branchLoading}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="h-2.5 w-24 max-w-full animate-pulse rounded-full bg-v2-background-bg-layer-03"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!props.branchLoading && (props.branch || props.worktree)}>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{/* The worktree icon stands in for the branch icon to mark sessions outside the project checkout. */}
|
||||
<Icon name={props.worktree ? "outline-worktree" : "branch"} size="small" class="shrink-0" />
|
||||
<Show when={props.branch}>
|
||||
{(name) => (
|
||||
<span dir="ltr" class="min-w-0 truncate">
|
||||
{name()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tag()}>
|
||||
{(name) => (
|
||||
<Tooltip placement="right" value={name()} class="ms-auto flex shrink-0">
|
||||
<Icon name="server" size="small" aria-label={name()} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Context.Trigger>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content>
|
||||
<Show
|
||||
when={props.archived}
|
||||
fallback={<Menu.Item onSelect={props.onArchive}>{language.t("common.archive")}</Menu.Item>}
|
||||
>
|
||||
<Menu.Item onSelect={props.onUnarchive}>{language.t("sidebar.inbox.unarchive")}</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={props.onDelete}>{language.t("common.delete")}…</Menu.Item>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { WindowsAppMenu } from "./windows-menu"
|
||||
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
|
||||
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
|
||||
import { macTrafficLightsBaseWidth, macVerticalTrafficLights, SidebarToggle } from "@/shell/sidebar-toggle"
|
||||
import { SidebarInbox } from "@/shell/titlebar/inbox"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/shell/titlebar/session-events"
|
||||
@@ -824,21 +825,28 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
</div>
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
<Show
|
||||
when={settings.appearance.tabLayout() === "inbox"}
|
||||
fallback={
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab()}
|
||||
onNavigate={(tab, el) => {
|
||||
tabs.select(tab)
|
||||
el?.scrollIntoView({ behavior: "instant", block: "nearest" })
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SidebarInbox />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titlebarItems.ids().length > 0}>
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 flex w-full shrink-0 flex-col">
|
||||
|
||||
Reference in New Issue
Block a user