mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-16 11:20:42 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e32a7975a | ||
|
|
9dbba2fa54 |
No files matched your search
@@ -629,6 +629,28 @@ export const dict = {
|
||||
|
||||
"sidebar.settings": "Settings",
|
||||
"sidebar.help": "Help",
|
||||
"sidebar.mode.tabs": "Tabs",
|
||||
"sidebar.mode.inbox": "Inbox",
|
||||
"sidebar.mode.experimental": "Experimental",
|
||||
"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.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",
|
||||
"sidebar.inbox.elapsed.seconds.other": "{{count}}s",
|
||||
"sidebar.inbox.elapsed.minutes.one": "{{count}}m",
|
||||
"sidebar.inbox.elapsed.minutes.other": "{{count}}m",
|
||||
"sidebar.inbox.elapsed.hours.one": "{{count}}h",
|
||||
"sidebar.inbox.elapsed.hours.other": "{{count}}h",
|
||||
"sidebar.inbox.elapsed.days.one": "{{count}}d",
|
||||
"sidebar.inbox.elapsed.days.other": "{{count}}d",
|
||||
"sidebar.inbox.time.now": "now",
|
||||
"sidebar.project.recentSessions": "Recent sessions",
|
||||
"sidebar.project.clearNotifications": "Clear notifications",
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ const appearanceSchema = Persistence.struct({
|
||||
sans: Schema.String,
|
||||
terminal: Schema.String,
|
||||
tabLayout: Schema.Literals(["horizontal", "vertical"]),
|
||||
// Experimental: vertical sidebar shows open tabs or a server-wide session inbox.
|
||||
sidebarMode: Persistence.optional(Schema.Literals(["tabs", "inbox"])),
|
||||
})
|
||||
|
||||
const permissionsSchema = Persistence.struct({
|
||||
@@ -376,6 +378,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setTabLayout(value: TabLayout) {
|
||||
setStore("appearance", "tabLayout", value)
|
||||
},
|
||||
sidebarMode: withFallback(() => store.appearance?.sidebarMode, "tabs" as const),
|
||||
setSidebarMode(value: "tabs" | "inbox") {
|
||||
setStore("appearance", "sidebarMode", value)
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
get: (action: string) => store.keybinds?.[action],
|
||||
|
||||
@@ -4,6 +4,8 @@ import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
type ServerData = ReturnType<ReturnType<typeof useGlobal>["ensureServerCtx"]>["data"]
|
||||
|
||||
export function useSessionTabAvatarState(
|
||||
server: Accessor<ServerConnection.Key>,
|
||||
sessionId: Accessor<string>,
|
||||
@@ -14,39 +16,15 @@ export function useSessionTabAvatarState(
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const serverCtx = useServerCtx(connection)
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
const requests = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
if (!data) return []
|
||||
if (!data) return { permission: false, form: false }
|
||||
|
||||
if (!root?.()) return data.session.list()
|
||||
const id = sessionId()
|
||||
|
||||
return [...new Set([id, ...data.session.family(id)])].flatMap((id) => {
|
||||
const info = data.session.get(id)
|
||||
|
||||
return info ? [info] : []
|
||||
})
|
||||
return sessionRequests(data, sessionId(), !!root?.(), settings.permissions.autoApprove())
|
||||
})
|
||||
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (settings.permissions.autoApprove()) return false
|
||||
const ctx = serverCtx()
|
||||
|
||||
if (!ctx) return false
|
||||
|
||||
return !!sessionPermissionRequest(sessions(), ctx.data.session.permission.list, sessionId())
|
||||
})
|
||||
|
||||
const hasForms = createMemo(() => {
|
||||
const data = serverCtx()?.data
|
||||
|
||||
if (!data) return false
|
||||
|
||||
return !!sessionFormRequest(sessions(), data.session.form.list, sessionId())
|
||||
})
|
||||
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasForms())
|
||||
const needsAttention = createMemo(() => requests().permission || requests().form)
|
||||
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||
@@ -59,15 +37,37 @@ export function useSessionTabAvatarState(
|
||||
|
||||
if (needsAttention()) return false
|
||||
|
||||
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.
|
||||
const busy = (id: string) =>
|
||||
data.session.status(id) === "running" || data.session.pending.list(id).some((item) => item.type !== "synthetic")
|
||||
|
||||
if (root?.())
|
||||
return data.session.list().some((session) => data.session.root(session.id) === sessionId() && busy(session.id))
|
||||
return data.session
|
||||
.list()
|
||||
.some((session) => data.session.root(session.id) === sessionId() && sessionBusy(data, session.id))
|
||||
|
||||
return busy(sessionId())
|
||||
return sessionBusy(data, sessionId())
|
||||
})
|
||||
|
||||
return { unread, loading }
|
||||
}
|
||||
|
||||
// Pending permission and form requests anywhere in the session (or its family, for roots).
|
||||
export function sessionRequests(data: ServerData, sessionId: string, root: boolean, autoApprove: boolean) {
|
||||
const sessions = root
|
||||
? [...new Set([sessionId, ...data.session.family(sessionId)])].flatMap((id) => {
|
||||
const info = data.session.get(id)
|
||||
|
||||
return info ? [info] : []
|
||||
})
|
||||
: data.session.list()
|
||||
|
||||
return {
|
||||
permission: !autoApprove && !!sessionPermissionRequest(sessions, data.session.permission.list, sessionId),
|
||||
form: !!sessionFormRequest(sessions, data.session.form.list, sessionId),
|
||||
}
|
||||
}
|
||||
|
||||
// Like the TUI, work waiting in the inbox counts as busy; parked synthetic context does not.
|
||||
export function sessionBusy(data: ServerData, sessionId: string) {
|
||||
return (
|
||||
data.session.status(sessionId) === "running" ||
|
||||
data.session.pending.list(sessionId).some((item) => item.type !== "synthetic")
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function SessionTabAvatarView(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Show
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import { isSessionNotFoundError, type SessionInfo } from "@opencode/client/promise"
|
||||
import { useQueries, useQueryClient } from "@tanstack/solid-query"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, startTransition, type JSX } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { sessionBusy, sessionRequests } from "@/shell/layout/project-avatar-state"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { loadHomeSessionIndex, mergeHomeSessionIndex } from "@/home/sessions/index"
|
||||
|
||||
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session.
|
||||
const selectSessions = (sessions: SessionInfo[]) => () => sessions
|
||||
|
||||
// Branch lookups each run git on the server, so load them a few at a time.
|
||||
const BRANCH_BATCH = 4
|
||||
|
||||
// `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),
|
||||
})
|
||||
|
||||
type InboxStatus = "approval" | "input" | "working" | "failed" | "done" | "ready"
|
||||
|
||||
type InboxEntry = {
|
||||
key: string
|
||||
conn: ServerConnection.Any
|
||||
server: ServerConnection.Key
|
||||
ctx: ReturnType<ReturnType<typeof useGlobal>["ensureServerCtx"]>
|
||||
session: SessionInfo
|
||||
status: InboxStatus
|
||||
}
|
||||
|
||||
export function SidebarInbox() {
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const settings = useSettings()
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
|
||||
const [state, setState, , ready] = persisted(Persist.global("sidebar.inbox"), InboxState, {
|
||||
workingExpanded: true,
|
||||
})
|
||||
|
||||
const [now, setNow] = createSignal(Date.now())
|
||||
const timer = setInterval(() => setNow(Date.now()), 1_000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const connections = createMemo(() => servers.visible.map((conn) => ({ conn, ctx: global.ensureServerCtx(conn) })))
|
||||
|
||||
// The inbox stays mounted and rarely refetches the index, so drop sessions the server no longer has.
|
||||
const forget = (conn: ServerConnection.Any, sessionID: string) =>
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.filter((session) => session.id !== sessionID),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
connections().forEach((item) => {
|
||||
onCleanup(item.ctx.data.on("session.deleted", (event) => forget(item.conn, event.data.sessionID)))
|
||||
})
|
||||
})
|
||||
|
||||
// Shares the Home query cache so renames and deletions there refresh the inbox too.
|
||||
const queries = useQueries(() => ({
|
||||
queries: connections().map((item) => ({
|
||||
queryKey: ["home-sessions", item.conn] as const,
|
||||
enabled: item.ctx.sdk.connection.status() === "connected",
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
loadHomeSessionIndex((input, options) => item.ctx.sdk.api.session.list(input, options), signal),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
select: selectSessions,
|
||||
})),
|
||||
}))
|
||||
|
||||
const entries = createMemo(() =>
|
||||
connections()
|
||||
.flatMap((item, index) => {
|
||||
const data = item.ctx.data
|
||||
const notification = item.ctx.notification.session
|
||||
const server = ServerConnection.key(item.conn)
|
||||
|
||||
// A root session counts as working while any session in its family is busy.
|
||||
const busyRoots = new Set(
|
||||
[...data.session.list().map((session) => session.id), ...data.session.active()]
|
||||
.filter((id) => sessionBusy(data, id))
|
||||
.map((id) => data.session.root(id)),
|
||||
)
|
||||
|
||||
const status = (id: string): InboxStatus => {
|
||||
const requests = sessionRequests(data, id, true, settings.permissions.autoApprove())
|
||||
|
||||
if (requests.permission) return "approval"
|
||||
|
||||
if (requests.form) return "input"
|
||||
|
||||
if (busyRoots.has(id)) return "working"
|
||||
|
||||
if (notification.unseenHasError(id)) return "failed"
|
||||
|
||||
if (notification.unseenCount(id) > 0) return "done"
|
||||
|
||||
return "ready"
|
||||
}
|
||||
|
||||
return data.session.apply(mergeHomeSessionIndex(queries[index]?.data?.() ?? [], data.session.list())).map(
|
||||
(session): InboxEntry => ({
|
||||
key: `${server}\0${session.id}`,
|
||||
conn: item.conn,
|
||||
server,
|
||||
ctx: item.ctx,
|
||||
session,
|
||||
status: status(session.id),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.toSorted((a, b) => b.session.time.updated - a.session.time.updated),
|
||||
)
|
||||
|
||||
const sections = createMemo(() => {
|
||||
const list = entries()
|
||||
|
||||
return {
|
||||
needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"),
|
||||
// The most recently finished session comes first.
|
||||
inbox: list
|
||||
.filter((entry) => {
|
||||
const since = state.since
|
||||
|
||||
return !busy(entry.status) && since !== undefined && (entry.session.time.idle ?? 0) >= since
|
||||
})
|
||||
.toSorted((a, b) => (b.session.time.idle ?? 0) - (a.session.time.idle ?? 0)),
|
||||
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())
|
||||
})
|
||||
|
||||
// Rows are keyed by stable strings so live session updates do not remount them.
|
||||
const byKey = createMemo(() => new Map(entries().map((entry) => [entry.key, entry])))
|
||||
|
||||
const active = createMemo(() => {
|
||||
const route = layout.route()
|
||||
|
||||
if (route.type !== "session") return
|
||||
|
||||
return { server: route.server, sessionId: route.sessionId }
|
||||
})
|
||||
|
||||
const selected = (entry: InboxEntry) => {
|
||||
const current = active()
|
||||
|
||||
return (
|
||||
current?.server === entry.server &&
|
||||
(current.sessionId === entry.session.id || entry.ctx.data.session.root(current.sessionId) === entry.session.id)
|
||||
)
|
||||
}
|
||||
|
||||
// Git info is not stored on the session, so eagerly load it for every shown row and mark each location
|
||||
// settled once its load ends.
|
||||
const [vcs, setVcs] = createStore({ settled: {} as Record<string, true> })
|
||||
const queued = new Set<string>()
|
||||
const queue: InboxEntry[] = []
|
||||
let draining = false
|
||||
|
||||
const drain = async () => {
|
||||
if (draining) return
|
||||
draining = true
|
||||
|
||||
while (queue.length) {
|
||||
await Promise.all(
|
||||
queue.splice(0, BRANCH_BATCH).map((entry) =>
|
||||
entry.ctx.data.location.vcs
|
||||
.sync(entry.session.location)
|
||||
.catch(() => undefined)
|
||||
.then(() => setVcs("settled", locationKey(entry), true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
draining = false
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const visible = sections()
|
||||
|
||||
;[...visible.needs, ...visible.working, ...visible.inbox].forEach((entry) => {
|
||||
const key = locationKey(entry)
|
||||
|
||||
if (queued.has(key)) return
|
||||
queued.add(key)
|
||||
queue.push(entry)
|
||||
})
|
||||
void drain()
|
||||
})
|
||||
|
||||
const branchLoading = (entry: InboxEntry) =>
|
||||
!vcs.settled[locationKey(entry)] && !entry.ctx.data.location.vcs.info(entry.session.location)
|
||||
|
||||
const open = (entry: InboxEntry) => {
|
||||
const directory = entry.ctx.projects.forSession(entry.session)?.worktree ?? entry.session.location.directory
|
||||
void entry.ctx.data.session.message.sync(entry.session.id).catch((error: unknown) => {
|
||||
if (isSessionNotFoundError(error)) forget(entry.conn, entry.session.id)
|
||||
})
|
||||
void startTransition(() => {
|
||||
tabs.select(tabs.addSessionTab({ server: entry.server, sessionId: entry.session.id }))
|
||||
entry.ctx.data.session.remember(entry.session)
|
||||
entry.ctx.projects.open(directory)
|
||||
entry.ctx.projects.touch(directory)
|
||||
})
|
||||
}
|
||||
|
||||
const rows = (list: InboxEntry[]) => (
|
||||
<For each={list.map((entry) => entry.key)}>
|
||||
{(key) => (
|
||||
<Show when={byKey().get(key)}>
|
||||
{(entry) => (
|
||||
<InboxRow
|
||||
entry={entry()}
|
||||
selected={selected(entry())}
|
||||
now={now()}
|
||||
branchLoading={branchLoading(entry())}
|
||||
onOpen={() => open(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}>
|
||||
<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>
|
||||
</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 || 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
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const project = createMemo(() => props.entry.ctx.projects.forSession(props.entry.session))
|
||||
const status = () => props.entry.status
|
||||
const unread = () => status() !== "ready" && status() !== "working"
|
||||
|
||||
const worktree = () => inWorktree(props.entry)
|
||||
|
||||
const branch = () => props.entry.ctx.data.location.vcs.info(props.entry.session.location)?.branch.current ?? undefined
|
||||
|
||||
// Only sessions on a remote server are marked.
|
||||
const remote = () => !props.entry.ctx.isLocal
|
||||
|
||||
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 (
|
||||
<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={{
|
||||
"opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100":
|
||||
status() === "working" && !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 || branch() || worktree() || remote()}>
|
||||
<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 && (branch() || 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={worktree() ? "outline-worktree" : "branch"} size="small" class="shrink-0" />
|
||||
<Show when={branch()}>
|
||||
{(name) => (
|
||||
<span dir="ltr" class="min-w-0 truncate">
|
||||
{name()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={remote()}>
|
||||
<Tooltip placement="right" value={serverName(props.entry.conn)} class="ms-auto flex shrink-0">
|
||||
<Icon name="server" size="small" aria-label={serverName(props.entry.conn)} />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function busy(status: InboxStatus) {
|
||||
return status === "working" || status === "approval" || status === "input"
|
||||
}
|
||||
|
||||
function locationKey(entry: InboxEntry) {
|
||||
return `${entry.server}\0${pathKey(entry.session.location.directory)}`
|
||||
}
|
||||
|
||||
function inWorktree(entry: InboxEntry) {
|
||||
const root = entry.ctx.projects.forSession(entry.session)?.worktree
|
||||
|
||||
return !!root && pathKey(root) !== pathKey(entry.session.location.directory)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, For, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { Dynamic, Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
@@ -15,6 +15,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { WindowsAppMenu } from "./windows-menu"
|
||||
import { applyPath, backPath, forwardPath, type HistoryLocation } from "./history"
|
||||
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
|
||||
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"
|
||||
@@ -730,27 +731,66 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
/>
|
||||
<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.sidebarMode() === "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">
|
||||
<div data-slot="vertical-tabs-footer" class="mt-2 mb-2 flex w-full shrink-0 flex-col gap-1">
|
||||
<Show when={titlebarItems.ids().length > 0}>
|
||||
<TitlebarItems items={titlebarItems} vertical />
|
||||
</Show>
|
||||
<div
|
||||
data-slot="vertical-sidebar-mode"
|
||||
role="radiogroup"
|
||||
class="flex h-7 w-full items-center gap-0.5 rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 [app-region:no-drag]"
|
||||
>
|
||||
<For each={["tabs", "inbox"] as const}>
|
||||
{(mode) => (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={mode === "inbox" ? language.t("sidebar.mode.experimental") : undefined}
|
||||
inactive={mode !== "inbox"}
|
||||
class="flex h-full min-w-0 flex-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={settings.appearance.sidebarMode() === mode}
|
||||
data-state={settings.appearance.sidebarMode() === mode ? "active" : undefined}
|
||||
class="flex h-full min-w-0 flex-1 items-center justify-center gap-1 rounded-[4px] px-1.5 text-[12px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=active]:bg-v2-background-bg-layer-02 data-[state=active]:text-v2-text-text-base focus-visible:outline-none"
|
||||
onClick={() => settings.appearance.setSidebarMode(mode)}
|
||||
>
|
||||
<Icon
|
||||
name={mode === "inbox" ? "bullet-list" : "sidebar"}
|
||||
size="small"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{language.t(`sidebar.mode.${mode}`)}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -118,6 +118,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: Session.ID | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly project?: Project.ID | undefined
|
||||
readonly subpath?: RelativePath | undefined
|
||||
@@ -254,6 +255,7 @@ export type SessionUpdateInput = {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}
|
||||
export type SessionUpdateOutput = void
|
||||
export type SessionUpdateOperation<E = never> = (input: SessionUpdateInput) => Effect.Effect<SessionUpdateOutput, E>
|
||||
@@ -570,6 +572,34 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly idle: number }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.archived"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.unarchived"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -366,6 +366,7 @@ const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: Sessi
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
archived: input?.["archived"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
@@ -474,7 +475,12 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
|
||||
preserveEffect<SessionUpdateOutput>()(
|
||||
raw["session.update"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
payload: {
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
permissions: input["permissions"],
|
||||
archived: input["archived"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -543,6 +543,7 @@ export function make(options: ClientOptions) {
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
parentID: input?.["parentID"],
|
||||
archived: input?.["archived"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
@@ -691,7 +692,12 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
body: {
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
permissions: input["permissions"],
|
||||
archived: input["archived"],
|
||||
},
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
|
||||
@@ -623,6 +623,26 @@ export type SessionViewed = {
|
||||
data: { sessionID: string; idle: number }
|
||||
}
|
||||
|
||||
export type SessionArchived = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.archived"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionUnarchived = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.unarchived"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2378,6 +2398,8 @@ export type SessionEventDurable =
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionArchived
|
||||
| SessionUnarchived
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
@@ -2441,6 +2463,8 @@ export type V2Event =
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionArchived
|
||||
| SessionUnarchived
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2823,6 +2847,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2833,6 +2858,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2843,6 +2869,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2853,16 +2880,29 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["parentID"]
|
||||
readonly archived?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
readonly cursor?: string | undefined
|
||||
}["archived"]
|
||||
readonly directory?: {
|
||||
readonly limit?: number | undefined
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2873,6 +2913,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2883,6 +2924,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -2893,6 +2935,7 @@ export type SessionListInput = {
|
||||
readonly order?: "asc" | "desc" | undefined
|
||||
readonly search?: string | undefined
|
||||
readonly parentID?: string | null | undefined
|
||||
readonly archived?: boolean | undefined
|
||||
readonly directory?: string | undefined
|
||||
readonly project?: string | undefined
|
||||
readonly subpath?: string | undefined
|
||||
@@ -4123,6 +4166,7 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly title?: string | undefined
|
||||
@@ -4130,6 +4174,7 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly title?: string | undefined
|
||||
@@ -4137,7 +4182,16 @@ export type SessionUpdateInput = {
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["permissions"]
|
||||
readonly archived?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
readonly archived?: boolean | undefined
|
||||
}["archived"]
|
||||
}
|
||||
|
||||
export type SessionUpdateOutput = void
|
||||
|
||||
@@ -701,6 +701,14 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
|
||||
return
|
||||
case "session.archived":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "time", "archived", event.created)
|
||||
return
|
||||
case "session.unarchived":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "time", "archived", undefined)
|
||||
return
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
@@ -174,6 +174,8 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly archive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly unarchive: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly setMetadata: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
metadata: SessionSchema.Metadata
|
||||
@@ -433,6 +435,8 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
archive: (sessionID) => sessions.forSession(sessionID).archive(),
|
||||
unarchive: (sessionID) => sessions.forSession(sessionID).unarchive(),
|
||||
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
|
||||
@@ -58,7 +58,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
|
||||
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
archived: row.time_archived === null ? undefined : DateTime.makeUnsafe(row.time_archived),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -80,6 +80,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.archived": () => Effect.void,
|
||||
"session.unarchived": () => Effect.void,
|
||||
"session.message.content.updated": (event) =>
|
||||
updateOwnedAssistant(event.data.messageID, (draft) => {
|
||||
draft.content = castDraft(
|
||||
|
||||
@@ -603,6 +603,22 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
yield* bus.project(SessionEvent.Archived, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: event.created, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Unarchived, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: null, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
|
||||
@@ -72,6 +72,16 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const archive = Effect.fn("Session.archive")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (session.time.archived) return
|
||||
yield* bus.publish(SessionEvent.Archived, { sessionID })
|
||||
})
|
||||
const unarchive = Effect.fn("Session.unarchive")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (!session.time.archived) return
|
||||
yield* bus.publish(SessionEvent.Unarchived, { sessionID })
|
||||
})
|
||||
const setMetadata = Effect.fn("Session.setMetadata")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { metadata: SessionSchema.Metadata },
|
||||
@@ -348,6 +358,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
archive,
|
||||
unarchive,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
@@ -372,6 +384,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const archive = operations.archive.bind(undefined, sessionID)
|
||||
const unarchive = operations.unarchive.bind(undefined, sessionID)
|
||||
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
@@ -399,6 +413,8 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
archive,
|
||||
unarchive,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
|
||||
@@ -20,6 +20,7 @@ const ListInputBase = {
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
|
||||
parentID: Schema.NullOr(Session.ID).pipe(Schema.optional),
|
||||
archived: Schema.Boolean.pipe(Schema.optional),
|
||||
anchor: Session.ListAnchor.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
@@ -111,6 +112,8 @@ const layer = Layer.effect(
|
||||
conditions.push(
|
||||
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
|
||||
)
|
||||
if (input.archived !== undefined)
|
||||
conditions.push(input.archived ? isNotNull(SessionTable.time_archived) : isNull(SessionTable.time_archived))
|
||||
if (input.anchor) {
|
||||
conditions.push(
|
||||
order === "asc"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode/core/event/sql"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.archive", () => {
|
||||
it.effect("archives and unarchives through one durable event each", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
expect(created.time.archived).toBeUndefined()
|
||||
|
||||
yield* session.archive(created.id)
|
||||
yield* session.archive(created.id)
|
||||
expect((yield* session.get(created.id)).time.archived).toBeDefined()
|
||||
|
||||
yield* session.unarchive(created.id)
|
||||
yield* session.unarchive(created.id)
|
||||
expect((yield* session.get(created.id)).time.archived).toBeUndefined()
|
||||
|
||||
const types = (yield* db
|
||||
.select({ type: EventTable.type })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.all()).map((event) => event.type)
|
||||
expect(types.filter((type) => type === Bus.versionedType(SessionEvent.Archived.type, 1))).toHaveLength(1)
|
||||
expect(types.filter((type) => type === Bus.versionedType(SessionEvent.Unarchived.type, 1))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters listed sessions by archive state", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const active = yield* session.create({ location })
|
||||
const archived = yield* session.create({ location })
|
||||
yield* session.archive(archived.id)
|
||||
|
||||
const ids = (input?: Session.ListInput) =>
|
||||
session.list(input).pipe(Effect.map((page) => page.data.map((item) => item.id).toSorted()))
|
||||
expect(yield* ids()).toEqual([active.id, archived.id].toSorted())
|
||||
expect(yield* ids({ archived: false })).toEqual([active.id])
|
||||
expect(yield* ids({ archived: true })).toEqual([archived.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_missing_archive")
|
||||
expect(yield* Effect.flip(session.archive(sessionID))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
expect(yield* Effect.flip(session.unarchive(sessionID))).toEqual(new Session.NotFoundError({ sessionID }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -56,6 +56,13 @@ const ParentIDFilter = Schema.Union([
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value): "true" | "false" => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryFields = {
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
@@ -65,6 +72,10 @@ const SessionsQueryFields = {
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
archived: BooleanFromString.pipe(Schema.optional).annotate({
|
||||
description:
|
||||
"Filter by archive state. Use true to return only archived sessions or false to exclude them. Omit to include both.",
|
||||
}),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
@@ -152,13 +163,6 @@ const FormCreatePayload = Schema.Struct({
|
||||
fields: Form.Info.fields.fields,
|
||||
}).annotate({ identifier: "Form.CreatePayload" })
|
||||
|
||||
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
Schema.decodeTo(Schema.Boolean, {
|
||||
decode: SchemaGetter.transform((value) => value === "true"),
|
||||
encode: SchemaGetter.transform((value): "true" | "false" => (value ? "true" : "false")),
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
@@ -363,6 +367,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Session.Metadata.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
archived: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||
description: "Archive the session with true or restore it with false. Repeating the current state is a no-op.",
|
||||
}),
|
||||
}),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
|
||||
@@ -139,6 +139,20 @@ export const Viewed = Event.durable({
|
||||
})
|
||||
export type Viewed = typeof Viewed.Type
|
||||
|
||||
export const Archived = Event.durable({
|
||||
type: "session.archived",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Archived = typeof Archived.Type
|
||||
|
||||
export const Unarchived = Event.durable({
|
||||
type: "session.unarchived",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type Unarchived = typeof Unarchived.Type
|
||||
|
||||
// Replay-only: older releases allowed replacing completed assistant content.
|
||||
export const MessageContentUpdated = Event.durable({
|
||||
type: "session.message.content.updated",
|
||||
@@ -658,6 +672,8 @@ export const Definitions = Event.inventory(
|
||||
MetadataUpdated,
|
||||
Permissions,
|
||||
Viewed,
|
||||
Archived,
|
||||
Unarchived,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
Forked,
|
||||
|
||||
@@ -111,6 +111,8 @@ describe("public event manifest", () => {
|
||||
"session.metadata.updated.1",
|
||||
"session.permissions.1",
|
||||
"session.viewed.1",
|
||||
"session.archived.1",
|
||||
"session.unarchived.1",
|
||||
"session.message.content.updated.1",
|
||||
"session.usage.recorded.1",
|
||||
"session.forked.2",
|
||||
|
||||
@@ -277,6 +277,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
yield* session
|
||||
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
if (ctx.payload.archived !== undefined)
|
||||
yield* (ctx.payload.archived ? session.archive : session.unarchive)(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,28 +5,32 @@ import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) })
|
||||
const SessionsResponse = Schema.Struct({ data: Schema.Array(Schema.toEncoded(Session.Info)) })
|
||||
|
||||
const makeRequest = Effect.fn(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
return (path: string, method: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
})
|
||||
})
|
||||
|
||||
it.live("updates session metadata through PATCH", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({
|
||||
app: { version: "test" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
const request = (path: string, method: string, body?: unknown) =>
|
||||
Effect.promise(async () => {
|
||||
const response = await handler(
|
||||
new Request(`http://opencode.local${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(method === "PATCH" ? 204 : 200)
|
||||
return response.status === 204 ? undefined : response.json()
|
||||
})
|
||||
|
||||
const request = yield* makeRequest()
|
||||
const created = Schema.decodeUnknownSync(SessionResponse)(
|
||||
yield* request("/api/session", "POST", { metadata: { source: "create", stale: true } }),
|
||||
)
|
||||
@@ -36,3 +40,27 @@ it.live("updates session metadata through PATCH", () =>
|
||||
expect(updated.data.metadata).toEqual({ source: "patch" })
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("archives and unarchives a session through PATCH", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* makeRequest()
|
||||
const created = Schema.decodeUnknownSync(SessionResponse)(yield* request("/api/session", "POST", {}))
|
||||
const get = () =>
|
||||
request(`/api/session/${created.data.id}`, "GET").pipe(
|
||||
Effect.map((body) => Schema.decodeUnknownSync(SessionResponse)(body).data),
|
||||
)
|
||||
const listed = (archived: boolean) =>
|
||||
request(`/api/session?archived=${archived}`, "GET").pipe(
|
||||
Effect.map((body) => Schema.decodeUnknownSync(SessionsResponse)(body).data.map((item) => item.id)),
|
||||
)
|
||||
|
||||
yield* request(`/api/session/${created.data.id}`, "PATCH", { archived: true })
|
||||
expect((yield* get()).time.archived).toBeNumber()
|
||||
expect(yield* listed(true)).toContain(created.data.id)
|
||||
expect(yield* listed(false)).not.toContain(created.data.id)
|
||||
|
||||
yield* request(`/api/session/${created.data.id}`, "PATCH", { archived: false })
|
||||
expect((yield* get()).time.archived).toBeUndefined()
|
||||
expect(yield* listed(false)).toContain(created.data.id)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
Reference in new issue
Block a user