Compare commits

...
23 changed files with 926 additions and 82 deletions

No files matched your search

+22
View File
@@ -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",
+6
View File
@@ -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
+493
View File
@@ -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)
}
+58 -18
View File
@@ -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>
+30
View File
@@ -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
+8
View File
@@ -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) {
+4
View File
@@ -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,
+1 -1
View File
@@ -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(
+16
View File
@@ -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))
+16
View File
@@ -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,
+3
View File
@@ -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 }))
}),
)
})
+14 -7
View File
@@ -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,
+16
View File
@@ -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",
+4
View File
@@ -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()
}),
)
+47 -19
View File
@@ -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),
)