perf(app): keep each tab's timeline when switching directories (#59168)

This commit is contained in:
Kit Langton
2026-10-29 10:05:53 -07:00
committed by GitHub
Unverified
parent a8e17aee80
commit 6b9f21471f
22 changed files with 1044 additions and 271 deletions
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { expected, messages } from "../utils/markdown-sessions"
import { expectPath, holdRoute, seed, sessionHref, type SeedInput } from "../utils/app"
@@ -169,7 +169,7 @@ test("reloads a transcript only after inline file links have their final styling
}
})
test("restores verified inline paths before painting after a workspace tab switch", async ({ page }) => {
test("keeps verified inline paths across a workspace tab switch", async ({ page }) => {
const directory = "C:/OpenCode/InlinePaths"
const other = "C:/OpenCode/OtherInlinePaths"
const sourceID = "ses_inline_source"
@@ -177,7 +177,8 @@ test("restores verified inline paths before painting after a workspace tab switc
const queries: { query: string; directory: string }[] = []
const existing = new Set([directory])
await page.clock.setFixedTime(new Date("2026-10-21T00:00:00Z"))
await mockWorkspace(page, {
const workspace = await mockWorkspace(page, {
name: "InlinePaths",
directory,
project: { sandboxes: [other] },
@@ -204,60 +205,43 @@ test("restores verified inline paths before painting after a workspace tab switc
return existing.has(input.directory) ? ["src/file.ts"] : []
},
})
await page.goto(sessionHref(sourceID))
const source = page.locator(`[data-timeline-part-id="answer_${sourceID}:text:0"] code`)
const source = part(page, sourceID).locator("code")
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
const original = await source.elementHandle()
await tab(page, targetID).click()
const target = page.locator(`[data-timeline-part-id="answer_${targetID}:text:0"] code`)
const target = part(page, targetID).locator("code")
await expect(target).toHaveText("src/file.ts")
await expect.poll(() => queries.some((query) => query.directory === other)).toBe(true)
await expect(target).not.toHaveAttribute("data-inline-code-kind")
expect(await original!.evaluate((node) => node.isConnected)).toBe(false)
const insertion = await page.evaluateHandle((id) => {
type Insertion = { kind?: string | null }
const state: Insertion = {}
const observer = new MutationObserver(() => {
const code = document.querySelector(`[data-timeline-part-id="answer_${id}:text:0"] code`)
if (!code) return
state.kind = code.getAttribute("data-inline-code-kind")
observer.disconnect()
})
observer.observe(document, { childList: true, subtree: true })
return state
}, sourceID)
const count = queries.length
const held = await holdRoute(page, /\/api\/fs\/find\?/)
try {
await tab(page, sourceID).click()
await expect(source).toBeVisible()
// The other workspace disposes the old timeline, so this must exercise a real Markdown remount.
expect(await source.evaluate((node, original) => node === original, original)).toBe(false)
expect(await insertion.evaluate((state) => state.kind)).toBe("path")
// The source workspace's timeline is kept while the other workspace is selected, so its links need no new lookup.
expect(await source.evaluate((node, original) => node === original, original)).toBe(true)
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
expect(held.requests).toHaveLength(0)
expect(queries).toHaveLength(count)
} finally {
held.release()
await insertion.dispose()
}
// Expired positives keep their styling while revalidation is pending; negatives must discover new files.
await tab(page, targetID).click()
await expect(target).toBeVisible()
// Markdown that renders again repeats its lookups: expired positives keep their styling while revalidation is
// pending, and negatives must discover new files. Link results last as long as the session screen, which going
// Home unmounts, so the text changes in place.
await page.clock.setFixedTime(new Date("2026-10-21T00:01:01Z"))
const refresh = await holdRoute(page, /\/api\/fs\/find\?/)
try {
await tab(page, sourceID).click()
await workspace.push([textEnded(sourceID, directory)])
await refresh.arrived
await expect(part(page, sourceID)).toContainText("again")
await expect(source).toHaveAttribute("data-inline-code-kind", "path")
} finally {
refresh.release()
@@ -265,6 +249,9 @@ test("restores verified inline paths before painting after a workspace tab switc
existing.add(other)
await tab(page, targetID).click()
await expect(target).toBeVisible()
await workspace.push([textEnded(targetID, other)])
await expect(part(page, targetID)).toContainText("again")
await expect(target).toHaveAttribute("data-inline-code-kind", "path")
})
@@ -355,7 +342,7 @@ test("focuses Find in the selected cached timeline", async ({ page }) => {
await expect(search).toHaveCount(0)
})
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
test("keeps the old workspace's shell on its own Location while destination history is loading", async ({ page }) => {
const destination = "C:/OpenCode/OtherProject"
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
@@ -430,7 +417,7 @@ test("disposes the old workspace's shell while destination history is loading",
output.text += "Output after returning\n"
await tab(page, fixture.sourceID).click()
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
expect(reads.length).toBeGreaterThan(1)
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
} finally {
@@ -615,6 +602,21 @@ function placeholderHistory(id: string) {
}
}
function part(page: Page, sessionID: string) {
return page.locator(`[data-timeline-part-id="answer_${sessionID}:text:0"]`)
}
function textEnded(sessionID: string, directory: string): OpenCodeEvent {
return {
id: `evt_text_${sessionID}`,
created: 4,
type: "session.text.ended",
location: { directory },
durable: { aggregateID: sessionID, seq: 0, version: 1 },
data: { sessionID, assistantMessageID: `answer_${sessionID}`, ordinal: 0, text: "Inspect `src/file.ts` again." },
}
}
function tab(page: Page, sessionID: string) {
return page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`)
}
@@ -0,0 +1,35 @@
import { createComputed, untrack, type Accessor } from "solid-js"
/**
* Asks whether a link's file exists only while its timeline is selected. Plugins check links for the selected
* session alone, so a hidden timeline's answer would be a false negative: a check asked while hidden, or one that
* comes back negative after the timeline was hidden, even briefly, waits and asks again once it is selected.
*/
export function createLinkCheck(active: Accessor<boolean>, check: (href: string) => boolean | Promise<boolean>) {
let waiting: (() => void)[] = []
let hidden = 0
createComputed(() => {
if (!active()) {
hidden++
return
}
const ready = waiting
waiting = []
ready.forEach((resolve) => resolve())
})
const exists = (href: string): boolean | Promise<boolean> => {
if (!untrack(active)) return new Promise<void>((resolve) => waiting.push(resolve)).then(() => exists(href))
const started = hidden
const result = untrack(() => check(href))
if (result === true || result === false) return result
return result.then((found) => (found || (untrack(active) && hidden === started) ? found : exists(href)))
}
return exists
}
+9 -3
View File
@@ -7,6 +7,7 @@ import { Style, type MountedSession } from "@opencode/plugin/gui"
import { BuiltinSlot, type BuiltinSlotMap } from "@opencode/plugin/gui/builtin"
import { PluginContext } from "@opencode/plugin/gui/host"
import { usePluginHost } from "./host"
import { createLinkCheck } from "./link-check"
/**
* Renders one plugin contribution with its context and error isolation: a contribution that throws records the
@@ -76,16 +77,21 @@ export function PluginStyles() {
* Routes local markdown links in the session to plugin LinkHandler contributions, keeping image loading. A turn's
* sources chip opens its turn through the Sources plugin's handler.
*/
export function PluginLinks(props: ParentProps<{ session: MountedSession }>) {
export function PluginLinks(props: ParentProps<{ session: MountedSession; active?: boolean }>) {
const host = usePluginHost()
const markdown = useMarkdown()
// Checks untracked: markdown calls this inside its render effect, which must not follow the routed session.
const localFileExists = createLinkCheck(
() => props.active !== false,
(href) => host.links.exists({ href, session: props.session }),
)
return (
<MarkdownProvider
readImage={markdown?.readImage}
openLocalFile={(href) => void host.links.open({ href, session: props.session })}
// Untracked: markdown calls this inside its render effect, which must not follow the routed session.
localFileExists={(href) => untrack(() => host.links.exists({ href, session: props.session }))}
localFileExists={localFileExists}
>
<SourcesProvider
enabled={sourcesExperiment.enabled}
+12 -29
View File
@@ -51,6 +51,7 @@ import { createSessionRequestModel } from "../requests/model"
import type { Region } from "@/runtime/plugin/panels"
import { SessionLocationMissing } from "./location-missing"
import { SessionPluginPills } from "./plugin-pills"
import { createTimelineActions, type TimelineActionsView, type TimelinePendingActions } from "./timeline-actions"
import type { MountedSession, SessionScreen } from "@opencode/plugin/gui"
export function createActiveSessionRegion(input: {
@@ -272,12 +273,8 @@ export function createActiveSessionRegion(input: {
showToast({ title: language.t("common.requestFailed") })
}
const pendingSteer: NonNullable<SessionUserActions["pending"]> = {
status: (messageID) => {
const sessionID = input.session.identity.params.id
return sessionID ? data.session.pending.status(sessionID, messageID) : undefined
},
const pendingSteer: TimelinePendingActions = {
status: (sessionID, messageID) => data.session.pending.status(sessionID, messageID),
queue: ({ sessionID, messageID }) =>
server.api.session.inbox
.update({ sessionID, inboxID: messageID, delivery: "queue" })
@@ -375,29 +372,15 @@ export function createActiveSessionRegion(input: {
focus: focusPrompt,
},
actions: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
get fork() {
if (input.session.data.isChild()) return
return forkMessage
},
get pending() {
if (input.session.data.isChild()) return
return pendingSteer
},
get chooseModel() {
if (input.session.data.isChild()) return
return () => command.trigger("model.choose")
},
openAttachment,
} satisfies SessionUserActions,
// Each timeline passes its own session and child flag, which a kept timeline holds while another is routed.
timeline: (view: TimelineActionsView) =>
createTimelineActions(view, {
revert: revertMessage,
fork: forkMessage,
pending: pendingSteer,
chooseModel: () => command.trigger("model.choose"),
openAttachment,
}),
},
requests: state,
}
@@ -0,0 +1,49 @@
import type { Accessor } from "solid-js"
import type { SessionUserActions } from "@opencode/session-ui/actions"
type PendingActions = NonNullable<SessionUserActions["pending"]>
/** The timeline's own session, which a kept timeline holds while another session is routed. */
export type TimelineActionsView = {
sessionID: Accessor<string | undefined>
isChild: Accessor<boolean>
}
/** Pending input actions, for any session. */
export type TimelinePendingActions = Omit<PendingActions, "status"> & {
status: (sessionID: string, messageID: string) => ReturnType<PendingActions["status"]>
}
/** A timeline's message actions. A child session's timeline only opens attachments. */
export function createTimelineActions(
view: TimelineActionsView,
actions: Required<Pick<SessionUserActions, "revert" | "fork" | "chooseModel" | "openAttachment">> & {
pending: TimelinePendingActions
},
): SessionUserActions {
const pending: PendingActions = {
status: (messageID) => {
const id = view.sessionID()
return id ? actions.pending.status(id, messageID) : undefined
},
queue: actions.pending.queue,
remove: actions.pending.remove,
}
return {
get revert() {
return view.isChild() ? undefined : actions.revert
},
get fork() {
return view.isChild() ? undefined : actions.fork
},
get pending() {
return view.isChild() ? undefined : pending
},
get chooseModel() {
return view.isChild() ? undefined : actions.chooseModel
},
openAttachment: actions.openAttachment,
}
}
+44 -36
View File
@@ -51,7 +51,7 @@ import { createTimelineCache } from "./timeline/cache"
import { createPanelMotion, holdLandingWidth, panelOpacity } from "@/shell/panel-motion"
export function SessionScreenView(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
// Link handling for the rest of the screen; each cached timeline provides its own for its session.
const mounted = createMountedSession(props.session)
return (
@@ -280,47 +280,55 @@ function SessionScreenContent(props: {
(source, active) => {
// A cached timeline keeps its own session's latest object while another session is routed.
const own = createMemo<MountedSession>((previous) => {
if (!active()) return previous
const view = props.view()
return view.id === source.identity.sessionID() ? view : previous
}, untrack(props.view))
const actions = composer.actions.timeline({
sessionID: source.identity.sessionID,
isChild: () => !!source.data.parentID(),
})
return (
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
registerHeaderGap={registerHeaderGap}
session={source}
view={own()}
screen={props.screen}
background={composer.requests.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
bottomInset={timeline.inset()}
setVisibleRef={timeline.view.setVisibleRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setRevealPart={timeline.view.setRevealPart}
reveal={timeline.view.reveal}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
<PluginLinks session={own()} active={active()}>
<MessageTimeline
active={active()}
hideHeader={!isDesktop()}
registerHeaderGap={registerHeaderGap}
session={source}
view={own()}
screen={props.screen}
background={composer.requests.background}
actions={actions}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
reserveReviewToggle={!sideVisible()}
setContentRef={timeline.view.setContentRef}
bottomInset={timeline.inset()}
setVisibleRef={timeline.view.setVisibleRef}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setRevealPart={timeline.view.setRevealPart}
reveal={timeline.view.reveal}
setScrollToEnd={timeline.view.setScrollToEnd}
search={
<Show when={active()}>
<TimelineSearchBar controller={timelineSearch} />
</Show>
}
/>
</PluginLinks>
)
},
() => conversationVisible() && messagesReady(),
+134 -26
View File
@@ -1,4 +1,5 @@
import {
createComponent,
createComputed,
createMemo,
createRoot,
@@ -7,19 +8,31 @@ import {
onCleanup,
untrack,
type Accessor,
type Context,
type JSX,
} from "solid-js"
import { DataContext, useData } from "@opencode/session-ui/context"
import { MarkdownContext, useMarkdown } from "@opencode/session-ui/context/markdown"
import { createScopedCache } from "@/runtime/server/scoped-cache"
import { useWorkspaceLocation, WorkspaceLocationContext } from "@/workspaces/location"
import type { TimelineSessionSource } from "./controller"
export function createTimelineCache(
session: TimelineSessionSource & { identity: { workspaceKey: Accessor<string> } },
session: TimelineSessionSource,
render: (source: TimelineSessionSource, active: Accessor<boolean>) => JSX.Element,
visible: Accessor<boolean>,
transcript: (sessionID: string) => readonly unknown[],
) {
const owner = getOwner()
let workspace = untrack(session.identity.workspaceKey)
// These providers follow the selected Location. Each view reads its own copies, which hold while it is detached,
// so a view kept while another Location is selected never reads that Location's providers.
const location = useWorkspaceLocation()
const data = useData()
const markdown = useMarkdown()
// The key and directory of each session's view. A session moved to another directory gets a new key; its old view
// goes first, so the old view saves its scroll and disclosures before the new one reads them.
const keys = new Map<string, { key: string; directory?: string }>()
const cache = createScopedCache(
(key) =>
@@ -28,26 +41,55 @@ export function createTimelineCache(
const sessionKey = untrack(session.identity.sessionKey)
const active = createMemo(() => visible() && session.identity.sessionKey() === key)
// A detached view retains its own inputs until it is selected again.
const select = <T>(read: Accessor<T>) =>
createMemo<T>((previous) => (active() ? read() : previous), untrack(read))
// A detached view retains its own inputs until it is selected again, and reads each once on creation.
const select = <T>(read: Accessor<T>) => {
const held = createMemo<{ value: T } | undefined>(
(previous) => (active() || !previous ? { value: read() } : previous),
undefined,
{ equals: (previous, next) => previous?.value === next?.value },
)
return () => held()!.value
}
// Session retention evicts the transcripts of closed sessions. A detached view would still hold the old
// transcript and its DOM, so it goes too; reopening the session renders a new view.
// transcript and its DOM, so it goes too; reopening the session renders a new view. It goes in the same flush,
// before its effects run, so none of them reloads what was just evicted, such as a subagent's parent.
createComputed(
on(
() => !!id && transcript(id).length === 0,
(evicted, previous) => {
if (!evicted || previous !== false || untrack(active)) return
queueMicrotask(() => {
if (!untrack(active) && cache.peek(key)?.dispose === dispose) cache.delete(key)
})
if (cache.peek(key)?.dispose === dispose) cache.delete(key)
},
),
)
return {
value: render(
const store = select(() => data.store)
const directory = select(() => data.directory)
const sessionID = select(() => data.sessionID)
const agents = select(() => data.agents)
// Local file links are not held: each view provides its own link handling for its session.
const readImage = select(() => markdown?.readImage)
const openSession = select(() => markdown?.openSession)
const sessionTitle = select(() => markdown?.sessionTitle)
const turnDiff = select(() => data.turnDiff)
// The Location's config is a query: read it in the reader and only while selected, so a pending config
// suspends that reader as the live provider would rather than this view's owner, the whole screen.
let compaction = false
const backgroundCompaction = () => {
if (!active()) return compaction
compaction = data.backgroundCompaction
return compaction
}
const view = () =>
render(
{
identity: {
params: session.identity.params,
@@ -63,30 +105,96 @@ export function createTimelineCache(
history: { messages: select(session.history.messages) },
},
active,
)
return {
value: provide(WorkspaceLocationContext, select(location), () =>
provide(
DataContext,
{
get store() {
return store()
},
get directory() {
return directory()
},
get sessionID() {
return sessionID()
},
get agents() {
return agents()
},
get backgroundCompaction() {
return backgroundCompaction()
},
navigateToSession: data.navigateToSession,
sessionHref: data.sessionHref,
readMedia: data.readMedia,
peekMedia: data.peekMedia,
shellRunning: data.shellRunning,
// A hidden running shell stops polling its output and resumes from its cursor once selected.
get shellOutput() {
return active() ? data.shellOutput : undefined
},
get turnDiff() {
return turnDiff()
},
},
() =>
provide(
MarkdownContext,
{
get readImage() {
return readImage()
},
get openSession() {
return openSession()
},
get sessionTitle() {
return sessionTitle()
},
},
view,
),
),
),
dispose,
}
}, owner),
{ maxEntries: 16, dispose: (entry) => entry.dispose() },
{
maxEntries: 16,
dispose: (entry, key) => {
entry.dispose()
for (const [id, value] of keys) if (value.key === key) keys.delete(id)
},
},
)
onCleanup(cache.clear)
const syncWorkspace = (key: string) => {
if (workspace === key) return
workspace = key
cache.clear()
}
// Providers follow the selected Location even while its history is loading.
// Dispose detached views before their effects can read the new Location.
createComputed(on(session.identity.workspaceKey, syncWorkspace, { defer: true }))
return () => {
// A tab's render can run before the workspace watcher in the same batch.
// Clear the old workspace here, and let that later watcher keep this view.
syncWorkspace(session.identity.workspaceKey())
const key = session.identity.sessionKey()
const id = untrack(session.identity.sessionID)
return cache.get(session.identity.sessionKey()).value
if (id) {
const previous = keys.get(id)
const directory = untrack(session.data.info)?.location.directory
if (previous && previous.key !== key && previous.directory !== directory) cache.delete(previous.key)
keys.set(id, { key, directory })
}
return cache.get(key).value
}
}
// Untracked like a component: the provider keeps one view rather than rendering another when a read changes.
function provide<T>(context: Context<T>, value: T, children: () => JSX.Element) {
return createComponent(context.Provider, {
value,
get children() {
return untrack(children)
},
})
}
@@ -2,7 +2,7 @@ import type { SessionMessageInfo } from "@opencode/client/promise"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { Button } from "@opencode/ui/button"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on } from "solid-js"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Predicate, Schema } from "effect"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
@@ -56,7 +56,7 @@ export type TimelineSessionSource = {
history: Pick<SessionModel["history"], "messages">
}
export function createTimelineController(input: { session: TimelineSessionSource }) {
export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor<boolean> }) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -68,15 +68,28 @@ export function createTimelineController(input: { session: TimelineSessionSource
const language = useLanguage()
const platform = usePlatform()
const visibleMessages = createMemo(() => {
const id = input.session.identity.sessionID()
// Pending inputs hold with the history while the view is detached, so a hidden view never shows one against
// history it has not caught up with.
const pendingItems = createMemo<{ value: ReturnType<typeof data.session.pending.list> } | undefined>(
(previous) => {
if (previous && !input.active()) return previous
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
return { value: id ? data.session.pending.list(id) : [] }
},
undefined,
{ equals: (previous, next) => previous?.value === next?.value },
)
const pendingList = () => pendingItems()!.value
const visibleMessages = createMemo(() =>
visibleTimelineMessages(
input.session.history.messages(),
id ? data.session.pending.list(id) : [],
pendingList(),
input.session.data.info()?.revert?.messageID,
)
})
),
)
// A handoff projects after visibility: its prompt commits any staged revert, so the boundary does not hide it.
const projectedMessages = createMemo(() =>
@@ -95,24 +108,20 @@ export function createTimelineController(input: { session: TimelineSessionSource
if (!id) return new Set<string>()
return new Set(
data.session.pending
.list(id)
.flatMap((item) =>
(item.type === "user" &&
item.delivery === "steer" &&
data.session.pending.status(id, item.id) !== "starting") ||
item.type === "synthetic"
? [item.id]
: [],
),
pendingList().flatMap((item) =>
(item.type === "user" &&
item.delivery === "steer" &&
data.session.pending.status(id, item.id) !== "starting") ||
item.type === "synthetic"
? [item.id]
: [],
),
)
})
const queuedCompactionIDs = createMemo(() => {
const id = input.session.identity.sessionID()
return (id ? data.session.pending.list(id) : []).flatMap((item) => (item.type === "compaction" ? [item.id] : []))
})
const queuedCompactionIDs = createMemo(() =>
pendingList().flatMap((item) => (item.type === "compaction" ? [item.id] : [])),
)
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
@@ -22,6 +22,7 @@ import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/
import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown-cache"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
import { createSelectedEffect } from "./selected-effect"
import { containsDirectory } from "@opencode/util/path"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
@@ -102,7 +103,7 @@ type MessageTimelineProps = {
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session })
const controller = createTimelineController({ session: props.session, active: () => props.active !== false })
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
@@ -166,12 +167,16 @@ function MessageTimelineView(
return server.ctx.projects.forSession(session)
})
createEffect(() => {
const directory = project()?.worktree
createSelectedEffect(
project,
() => props.active !== false,
(project) => {
const directory = project?.worktree
if (!directory) return
void data.location.vcs.sync({ directory }).catch(() => undefined)
})
if (!directory) return
void data.location.vcs.sync({ directory }).catch(() => undefined)
},
)
const turnPadding = () => "px-4 md:px-6"
const showHeader = createMemo(() => !props.hideHeader && (props.data.showHeader() || workspaceSession()))
const pinned = createMemo(() => props.pinned)
@@ -0,0 +1,19 @@
import { createEffect, createMemo, untrack, type Accessor } from "solid-js"
/**
* Runs `run` with `source` when the view is created and whenever `source` changes, as an effect would. A detached
* view runs nothing; once selected again it runs once if `source` changed meanwhile.
*/
export function createSelectedEffect<T>(source: Accessor<T>, active: Accessor<boolean>, run: (value: T) => void) {
const changes = createMemo((count: number) => (source(), count + 1), 0)
let ran: number | undefined
createEffect(() => {
if (!active()) return
const change = changes()
if (change === ran) return
ran = change
untrack(() => run(source()))
})
}
@@ -1,15 +1,15 @@
import { DataProvider } from "@opencode/session-ui/context"
import { DataProvider, type TurnDiffInput } from "@opencode/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode/session-ui/context/markdown"
import { useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { createMemo, onCleanup, type ParentProps } from "solid-js"
import { createMemo, onCleanup, untrack, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs, type Tab } from "@/shell/tabs/tabs"
import { findSessionTab, useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
@@ -94,22 +94,26 @@ export function SessionUIProvider(
?.flatMap((entry) => (entry.type === "document" && entry.info.experimental ? [entry.info.experimental] : []))
.at(-1)?.background_compaction === true
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
const turnDiffs = () => {
const id = params.id
// Turn diffs live in the routed tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
// A cached timeline keeps the source it had while routed, so a child keeps its tab's diffs after the tab returns to
// the parent. A route without an open tab, such as one whose tab is closing, loads into a loose cache rather than
// tab state nothing would remove.
const looseTurnDiffs = createTurnDiffCache()
if (!id) return
const turnDiffCache = createMemo(() => {
const tab = params.id ? findSessionTab(tabs.store, props.server, params.id) : undefined
const tab =
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === props.server &&
(item.sessionId === id || item.routeSessionId === id),
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
return tab ? untrack(() => tabs.state(tab, "turnDiffs", createTurnDiffCache)) : looseTurnDiffs
})
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
}
const turnDiff = createMemo(() => {
const cache = turnDiffCache()
return {
get: cache.get,
load: (input: TurnDiffInput) => cache.load(input, () => serverSDK.api.session.diff(input)),
}
})
const sessionUIData = createMemo(() => ({
provider: providers.ready()
@@ -138,10 +142,7 @@ export function SessionUIProvider(
shellOutput={(input) => serverSDK.api.shell.output(input)}
readMedia={(uri, mime, signal) => serverSDK.media.load(uri, mime, signal)}
peekMedia={(uri, mime) => serverSDK.media.peek(uri, mime)}
turnDiff={{
get: (input) => turnDiffs()?.get(input),
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
}}
turnDiff={turnDiff()}
onNavigateToSession={navigateToSession}
onSessionHref={href}
backgroundCompaction={backgroundCompaction()}
+3
View File
@@ -74,3 +74,6 @@ const context = createSimpleContext({
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
export const LocationProvider = context.provider
/** Provides a resolved Location without syncing it, e.g. the one a detached view keeps while another is selected. */
export const WorkspaceLocationContext = context.context
@@ -0,0 +1,67 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import type { MountedSession } from "@opencode/plugin/gui"
import { syncCatalog } from "../../gui-plugins/src/context/catalog"
function setup() {
const [connected, setConnected] = createSignal(true)
const [active, setActive] = createSignal(true)
const loads: string[] = []
const sync = async (ref: { directory: string }) => {
loads.push(ref.directory)
}
const data = {
location: {
syncInfo: async () => {},
default: () => ({ directory: "/default" }),
provider: { sync },
model: { sync: async () => {} },
},
}
const value = {
directory: "/repo",
server: {
get connected() {
return connected()
},
data,
},
}
// SAFETY: syncCatalog reads only `directory` and the `server` fields above.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const session = value as unknown as MountedSession
const dispose = createRoot((dispose) => {
syncCatalog(() => session, active)
return dispose
})
return { loads, setConnected, setActive, dispose }
}
test("a detached view loads its catalogs on reconnect only once selected again", () => {
const input = setup()
try {
expect(input.loads).toEqual(["/repo"])
input.setActive(false)
input.setActive(true)
expect(input.loads).toEqual(["/repo"])
input.setActive(false)
input.setConnected(false)
input.setConnected(true)
expect(input.loads).toEqual(["/repo"])
input.setActive(true)
expect(input.loads).toEqual(["/repo", "/repo"])
} finally {
input.dispose()
}
})
@@ -0,0 +1,85 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createLinkCheck } from "../src/runtime/plugin/link-check"
function setup(answer: (href: string) => boolean | Promise<boolean>) {
return createRoot((dispose) => {
const [active, setActive] = createSignal(true)
const asked: string[] = []
const exists = createLinkCheck(active, (href) => {
asked.push(href)
return answer(href)
})
return { exists, asked, setActive, dispose }
})
}
test("passes through a check asked while the timeline is selected", async () => {
const input = setup((href) => (href === "sync.ts" ? true : Promise.resolve(href === "async.ts")))
try {
expect(input.exists("sync.ts")).toBe(true)
expect(await input.exists("async.ts")).toBe(true)
expect(await input.exists("missing.ts")).toBe(false)
expect(input.asked).toEqual(["sync.ts", "async.ts", "missing.ts"])
} finally {
input.dispose()
}
})
test("defers a check asked while the timeline is hidden until it is selected", async () => {
const input = setup(() => true)
try {
input.setActive(false)
const pending = input.exists("src/app.ts")
await Promise.resolve()
expect(input.asked).toEqual([])
input.setActive(true)
expect(await pending).toBe(true)
expect(input.asked).toEqual(["src/app.ts"])
} finally {
input.dispose()
}
})
test("asks again when a check comes back negative after the timeline was hidden", async () => {
const answers = [Promise.withResolvers<boolean>(), Promise.withResolvers<boolean>()]
const input = setup(() => answers[input.asked.length - 1]!.promise)
try {
const pending = input.exists("src/app.ts")
input.setActive(false)
answers[0]!.resolve(false)
await Bun.sleep(0)
expect(input.asked).toHaveLength(1)
input.setActive(true)
await Bun.sleep(0)
expect(input.asked).toHaveLength(2)
answers[1]!.resolve(true)
expect(await pending).toBe(true)
} finally {
input.dispose()
}
})
test("asks again when a check comes back negative after the timeline was hidden and shown again", async () => {
const answers = [Promise.withResolvers<boolean>(), Promise.withResolvers<boolean>()]
const input = setup(() => answers[input.asked.length - 1]!.promise)
try {
const pending = input.exists("src/app.ts")
input.setActive(false)
input.setActive(true)
answers[0]!.resolve(false)
await Bun.sleep(0)
expect(input.asked).toHaveLength(2)
answers[1]!.resolve(true)
expect(await pending).toBe(true)
} finally {
input.dispose()
}
})
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSelectedEffect } from "../src/session/timeline/selected-effect"
test("a detached view syncs nothing, then once on selection if its source changed meanwhile", () => {
const [source, setSource] = createSignal("/repo")
const [active, setActive] = createSignal(true)
const runs: string[] = []
const dispose = createRoot((dispose) => {
createSelectedEffect(source, active, (value) => runs.push(value))
return dispose
})
try {
expect(runs).toEqual(["/repo"])
setSource("/repo/a")
expect(runs).toEqual(["/repo", "/repo/a"])
setActive(false)
setActive(true)
expect(runs).toEqual(["/repo", "/repo/a"])
setActive(false)
setSource("/repo/b")
setSource("/repo/c")
expect(runs).toEqual(["/repo", "/repo/a"])
setActive(true)
expect(runs).toEqual(["/repo", "/repo/a", "/repo/c"])
} finally {
dispose()
}
})
+409 -85
View File
@@ -1,20 +1,46 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { DataContext, useData, type TurnDiffSource } from "@opencode/session-ui/context"
import { MarkdownContext, useMarkdown, type ReadMarkdownImage } from "@opencode/session-ui/context/markdown"
import {
batch,
createComponent,
createComputed,
createEffect,
createMemo,
createRoot,
on,
onCleanup,
type Context,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { ServerScope, SessionRouteKey, SessionStateKey } from "../src/runtime/server/scope"
import { createTimelineActions } from "../src/session/composer/timeline-actions"
import { createTimelineCache } from "../src/session/timeline/cache"
import { useWorkspaceLocation, WorkspaceLocationContext } from "../src/workspaces/location"
function setup() {
return createRoot((dispose) => {
const [state, setState] = createStore({
const [state, setState] = createStore<{
id: string
directory: string
visible: boolean
deferred: boolean
turnDiff: string
messages: Partial<Record<string, SessionMessageInfo[]>>
parents: Partial<Record<string, string>>
}>({
id: "ses_a",
directory: "/repo",
visible: true,
deferred: false,
turnDiff: "a1",
messages: {
ses_a: [{ id: "msg_a", type: "user", text: "First session", time: { created: 1 } }],
ses_b: [{ id: "msg_b", type: "user", text: "Second session", time: { created: 2 } }],
} as Record<string, SessionMessageInfo[]>,
},
parents: {},
})
const views = new Map<
@@ -24,57 +50,220 @@ function setup() {
const disposed: string[] = []
const cache = createTimelineCache(
{
identity: {
params: {
get id() {
return state.id
},
serverKey: "local",
},
sessionID: () => state.id,
workspaceKey: () => SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory)),
sessionKey: () =>
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
},
data: {
info: createMemo(() => ({
id: state.id,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: state.directory },
})),
parent: () => undefined,
parentID: () => undefined,
status: () => ({ type: "idle" }),
},
history: { messages: () => state.messages[state.id] ?? [] },
},
(source, active) => {
const id = source.identity.sessionID()!
const element = document.createElement("div")
createMemo(() =>
element.setAttribute(
"data-messages",
source.history
.messages()
.map((message) => message.id)
.join(","),
),
)
views.set(id, { active, messages: source.history.messages, element })
onCleanup(() => disposed.push(id))
// The Location of every provider value each view reads, by the view's session and Location.
const reads: Record<string, string[]> = {}
const imageReaders = new Map<ReadMarkdownImage, string>()
// What each view reads once a deferred part of it, like a Markdown block, first renders.
const deferredReads: Record<string, string[]> = {}
const shellOutput = async () => ({ data: { cursor: 0, size: 0, output: "" } })
const shells: Record<string, boolean[]> = {}
// Turn diff sources by label, as the host binds one to the routed tab, and the labels each view saw.
const turnDiffSources = new Map<string, TurnDiffSource>()
return element
},
() => state.visible,
(id) => state.messages[id] ?? [],
const turnDiffSource = (label: string) => {
const source = turnDiffSources.get(label) ?? { get: () => undefined, load: () => {} }
turnDiffSources.set(label, source)
return source
}
const turnDiffs: Record<string, string[]> = {}
// Views whose effects saw their transcript empty, as a subagent's view reloads its evicted parent.
const reloads: string[] = []
// The sessions each view's message actions read, and whether they were a child's.
const actions: Record<string, string[]> = {}
let directoryReads = 0
const imageReader = createMemo(() => {
const read: ReadMarkdownImage = async () => undefined
imageReaders.set(read, state.directory)
return read
})
const location = createMemo(() => ({
directory: state.directory,
event: { on: () => () => {}, listen: () => () => {} },
ref: { directory: state.directory },
current: undefined,
missing: undefined,
}))
const provide = <T>(context: Context<T>, value: T, children: () => JSX.Element) =>
createComponent(context.Provider, {
value,
get children() {
return children()
},
})
let cache!: () => JSX.Element
provide(WorkspaceLocationContext, location, () =>
provide(
DataContext,
{
store: { session: [], session_status: {}, session_diff: {} },
get directory() {
directoryReads++
return state.directory
},
sessionID: undefined,
agents: true,
get backgroundCompaction() {
return state.directory === "/other"
},
navigateToSession: undefined,
sessionHref: undefined,
shellRunning: undefined,
shellOutput,
get turnDiff() {
return turnDiffSource(state.turnDiff)
},
},
() =>
provide(
MarkdownContext,
{
get readImage() {
return imageReader()
},
},
() => {
cache = createSessionTimelineCache()
return null
},
),
),
)
return { state, setState, cache, views, disposed, dispose }
return {
state,
setState,
cache,
views,
disposed,
reads,
deferredReads,
shells,
turnDiffs,
reloads,
actions,
directoryReads: () => directoryReads,
dispose,
}
function createSessionTimelineCache() {
return createTimelineCache(
{
identity: {
params: {
get id() {
return state.id
},
serverKey: "local",
},
sessionID: () => state.id,
sessionKey: () =>
SessionStateKey.from(ServerScope.local, SessionRouteKey.fromRoute(state.directory, state.id)),
},
data: {
info: createMemo(() => ({
id: state.id,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: state.directory },
})),
parent: () => undefined,
parentID: () => state.parents[state.id],
status: () => ({ type: "idle" }),
},
history: { messages: () => state.messages[state.id] ?? [] },
},
(source, active) => {
const id = source.identity.sessionID()!
const element = document.createElement("div")
const workspace = useWorkspaceLocation()
const data = useData()
const markdown = useMarkdown()
createMemo(() =>
element.setAttribute(
"data-messages",
source.history
.messages()
.map((message) => message.id)
.join(","),
),
)
const view = `${id} in ${state.directory}`
createComputed(() => {
const readImage = markdown?.readImage
reads[view] = [
...(reads[view] ?? []),
workspace().directory,
data.directory,
(readImage && imageReaders.get(readImage)) ?? "",
]
})
createComputed(() => {
if (!state.deferred) return
const readImage = markdown?.readImage
deferredReads[view] = [
workspace().directory,
data.directory,
(readImage && imageReaders.get(readImage)) ?? "",
data.backgroundCompaction ? "/other" : "/repo",
]
})
createComputed(() => {
const source = data.turnDiff
const label = [...turnDiffSources].find((entry) => entry[1] === source)?.[0] ?? ""
turnDiffs[view] = [...(turnDiffs[view] ?? []), label]
})
createComputed(() => {
shells[view] = [...(shells[view] ?? []), data.shellOutput === shellOutput]
})
createEffect(
on(
() => (state.messages[id] ?? []).length,
(count) => {
if (count === 0) reloads.push(id)
},
{ defer: true },
),
)
const timelineActions = createTimelineActions(
{ sessionID: source.identity.sessionID, isChild: () => !!source.data.parentID() },
{
revert: () => {},
fork: () => {},
chooseModel: () => {},
openAttachment: () => {},
pending: {
status: (sessionID) => (sessionID === id ? "queued" : undefined),
queue: async () => {},
remove: async () => {},
},
},
)
createComputed(() => {
const status = timelineActions.pending?.status("msg")
actions[view] = [...(actions[view] ?? []), timelineActions.revert ? `root ${status}` : "child"]
})
views.set(id, { active, messages: source.history.messages, element })
onCleanup(() => disposed.push(id))
return element
},
() => state.visible,
(id) => state.messages[id] ?? [],
)
}
})
}
@@ -129,6 +318,53 @@ test("disposes a detached view once its transcript is evicted, but never the sel
}
})
test("a detached view goes before its effects can reload its evicted transcript", () => {
const input = setup()
try {
input.cache()
input.setState("id", "ses_b")
input.cache()
input.setState("messages", "ses_a", [])
expect(input.disposed).toEqual(["ses_a"])
expect(input.reloads).toEqual([])
} finally {
input.dispose()
}
})
test("a session moved to another Location replaces its view under the old one", () => {
const input = setup()
try {
const first = input.cache()
input.setState("directory", "/moved")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a"])
} finally {
input.dispose()
}
})
test("a detached view's message actions keep its own session while a child session is routed", () => {
const input = setup()
try {
input.cache()
input.setState({ id: "ses_child", parents: { ses_child: "ses_a" } })
input.cache()
input.setState("id", "ses_b")
input.cache()
expect(input.actions).toEqual({
"ses_a in /repo": ["root queued"],
"ses_child in /repo": ["child"],
"ses_b in /repo": ["root queued"],
})
} finally {
input.dispose()
}
})
test("suspends a detached mobile view and reuses it when the conversation returns", () => {
const input = setup()
@@ -144,47 +380,49 @@ test("suspends a detached mobile view and reuses it when the conversation return
}
})
test("disposes views whose Location-scoped providers no longer match", () => {
const input = setup()
for (const order of ["session-first", "workspace-first"] as const) {
test(`keeps views across Locations, each reading only its own Location, when updates are ${order}`, () => {
const input = setup()
try {
const first = input.cache()
input.setState("directory", "/other")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
expect(input.cache()).not.toBe(first)
expect(input.disposed).toEqual(["ses_a", "ses_a"])
} finally {
input.dispose()
}
const select = (id: string, directory: string) => {
if (order === "workspace-first") input.setState("directory", directory)
input.setState("id", id)
expect(input.disposed).toHaveLength(3)
})
if (order === "session-first") input.setState("directory", directory)
test("disposes views on workspace changes while the destination is not rendered", () => {
const input = setup()
return input.cache()
}
try {
const first = input.cache()
input.setState("visible", false)
input.setState("directory", "/other")
expect(input.disposed).toEqual(["ses_a"])
input.setState("directory", "/repo")
input.setState("visible", true)
expect(input.cache()).not.toBe(first)
} finally {
input.dispose()
}
try {
const first = input.cache()
const second = select("ses_b", "/other")
expect(second).not.toBe(first)
expect(select("ses_a", "/repo")).toBe(first)
expect(select("ses_b", "/other")).toBe(second)
expect(input.disposed).toEqual(["ses_a", "ses_a"])
})
// A Location change while the conversation is hidden keeps both views too.
input.setState("visible", false)
select("ses_a", "/repo")
input.setState("visible", true)
expect(input.cache()).toBe(first)
expect(input.disposed).toEqual([])
expect(Object.keys(input.reads).sort()).toEqual(["ses_a in /repo", "ses_b in /other"])
expect(new Set(input.reads["ses_a in /repo"])).toEqual(new Set(["/repo"]))
expect(new Set(input.reads["ses_b in /other"])).toEqual(new Set(["/other"]))
} finally {
input.dispose()
}
expect(input.disposed.sort()).toEqual(["ses_a", "ses_b"])
})
}
for (const order of ["session-first", "workspace-first"] as const) {
test(`keeps views live across five workspaces when updates are ${order}`, () => {
const input = setup()
input.setState("directory", "/repo/ses_a")
const render = createRoot((dispose) => ({ selected: createMemo(input.cache), dispose }))
const visited = ["ses_a"]
try {
;["ses_b", "ses_c", "ses_d", "ses_e", "ses_a", "ses_c", "ses_b", "ses_e", "ses_d", "ses_a"].forEach(
@@ -195,15 +433,18 @@ for (const order of ["session-first", "workspace-first"] as const) {
if (order === "session-first") input.setState("directory", `/repo/${id}`)
})
expect(input.disposed).toEqual(visited)
expect(input.disposed).toEqual([])
input.setState("messages", id, [
{ id: `msg_live_${index}`, type: "user", text: "Live update", time: { created: index + 3 } },
])
expect((render.selected() as HTMLDivElement).dataset.messages).toBe(`msg_live_${index}`)
render.selected()
expect(input.views.get(id)!.element.dataset.messages).toBe(`msg_live_${index}`)
expect(input.views.get(id)!.active()).toBe(true)
visited.push(id)
},
)
Object.entries(input.reads).forEach(([view, directories]) =>
expect(new Set(directories)).toEqual(new Set([view.split(" in ")[1]])),
)
} finally {
render.dispose()
input.dispose()
@@ -211,6 +452,89 @@ for (const order of ["session-first", "workspace-first"] as const) {
})
}
test("a view first reading its providers while another Location is selected reads its own", () => {
const input = setup()
try {
input.cache()
input.setState("id", "ses_b")
input.setState("directory", "/other")
input.cache()
input.setState("deferred", true)
expect(input.deferredReads).toEqual({
"ses_a in /repo": ["/repo", "/repo", "/repo", "/repo"],
"ses_b in /other": ["/other", "/other", "/other", "/other"],
})
} finally {
input.dispose()
}
})
test("a detached view's readers stay still when another Location is selected", () => {
const input = setup()
try {
input.cache()
const runs = input.reads["ses_a in /repo"]!.length
input.setState("id", "ses_b")
input.setState("directory", "/other")
input.cache()
input.setState("id", "ses_c")
input.setState("directory", "/third")
input.cache()
expect(input.reads["ses_a in /repo"]!.length).toBe(runs)
} finally {
input.dispose()
}
})
test("a detached view's shells stop reading output until it is selected again", () => {
const input = setup()
try {
input.cache()
input.setState("id", "ses_b")
input.cache()
expect(input.shells["ses_a in /repo"]!.at(-1)).toBe(false)
expect(input.shells["ses_b in /repo"]!.at(-1)).toBe(true)
input.setState("id", "ses_a")
input.cache()
expect(input.shells["ses_a in /repo"]!.at(-1)).toBe(true)
expect(input.shells["ses_b in /repo"]!.at(-1)).toBe(false)
} finally {
input.dispose()
}
})
test("a view follows its routed tab's turn diffs while selected and keeps them once detached", () => {
const input = setup()
try {
input.cache()
// The tab of a newly opened session arrives after its route.
input.setState("turnDiff", "a2")
expect(input.turnDiffs["ses_a in /repo"]!.at(-1)).toBe("a2")
// A child session's tab returns to its parent, whose route resolves its own tab.
input.setState({ id: "ses_b", turnDiff: "b1" })
input.cache()
expect(input.turnDiffs["ses_a in /repo"]!.at(-1)).toBe("a2")
expect(input.turnDiffs["ses_b in /repo"]!.at(-1)).toBe("b1")
} finally {
input.dispose()
}
})
test("reads each provider value once when a view is created", () => {
const input = setup()
try {
input.cache()
expect(input.directoryReads()).toBe(1)
} finally {
input.dispose()
}
})
test("evicts the least recently selected view and disposes all retained owners", () => {
const input = setup()
+21 -3
View File
@@ -3,10 +3,13 @@ import { createKeyed, type MountedSession } from "@opencode/plugin/gui"
const location = (session: MountedSession) => (session.directory ? { directory: session.directory } : undefined)
/** Loads the provider and model catalogs of the routed session's location. */
export function syncCatalog(session: Accessor<MountedSession>) {
/**
* Loads the provider and model catalogs of the routed session's location. A detached view loads nothing; once
* selected again it loads if its target changed or the server reconnected meanwhile.
*/
export function syncCatalog(session: Accessor<MountedSession>, active: Accessor<boolean> = () => true) {
// The same target while another session of the same directory is routed, so a session switch loads nothing.
const target = createMemo(
const live = createMemo(
() => {
const current = session()
@@ -16,6 +19,21 @@ export function syncCatalog(session: Accessor<MountedSession>) {
{ equals: (a, b) => a?.data === b?.data && a?.ref?.directory === b?.ref?.directory },
)
const changes = createMemo((count: number) => (live(), count + 1), 0)
const target = createMemo<
{ data: MountedSession["server"]["data"]; ref?: { directory: string }; change: number } | undefined
>(
(previous) => {
if (!active()) return previous
const current = live()
return current && { ...current, change: changes() }
},
undefined,
{ equals: (a, b) => a?.change === b?.change },
)
// Loads again when the server reconnects or is replaced, and when another session's directory is routed.
createKeyed(
target,
@@ -18,13 +18,18 @@ function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
export function SessionContextUsage(props: {
session: MountedSession
/** False while the session's view is detached; it loads nothing until selected again. */
active?: boolean
variant?: "button" | "indicator"
placement?: ComponentProps<typeof Tooltip>["placement"]
}) {
const ctx = usePlugin()
const layout = ctx.layout
const i18n = useI18n()
syncCatalog(() => props.session)
syncCatalog(
() => props.session,
() => props.active !== false,
)
const variant = createMemo(() => props.variant ?? "button")
@@ -31,7 +31,7 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
ctx.add(Slot, {
at: "session.header",
order: 10,
render: (input) => <SessionContextUsage session={input.session} placement="bottom" />,
render: (input) => <SessionContextUsage session={input.session} active={input.active} placement="bottom" />,
})
ctx.add(
+8 -2
View File
@@ -55,7 +55,11 @@ export type ReadMediaUrlFn = (uri: string, mime: string, signal?: AbortSignal) =
export type PeekMediaUrlFn = (uri: string, mime: string) => string | undefined
export const { use: useData, provider: DataProvider } = createSimpleContext({
export const {
use: useData,
provider: DataProvider,
context: DataContext,
} = createSimpleContext({
name: "Data",
init: (props: {
data: Data
@@ -93,7 +97,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sessionHref: props.onSessionHref,
shellRunning: props.shellRunning,
shellOutput: props.shellOutput,
turnDiff: props.turnDiff,
get turnDiff() {
return props.turnDiff
},
readMedia: props.readMedia,
peekMedia: props.peekMedia,
}
@@ -56,3 +56,6 @@ export function MarkdownProvider(
}
export const useMarkdown = () => useContext(context)
/** Provides markdown handlers as they are, e.g. the ones a detached view keeps while another Location is selected. */
export const MarkdownContext = context
+4 -3
View File
@@ -9,6 +9,7 @@ export function createSimpleContext<T, Props extends Record<string, any>>(
const ctx = createContext<T>()
return {
context: ctx,
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
const gate = input.gate ?? true
@@ -19,10 +20,10 @@ export function createSimpleContext<T, Props extends Record<string, any>>(
// Access init.ready inside the memo to make it reactive for getter properties
const isReady = createMemo(() => {
// @ts-expect-error
const ready = init.ready as Accessor<boolean> | boolean | undefined
// SAFETY: contexts whose value has `ready` must pass `gate`, and their `ready` is a boolean or its accessor.
const ready = (init as { ready?: Accessor<boolean> | boolean }).ready
return ready === undefined || (typeof ready === "function" ? ready() : ready)
return ready === undefined || (ready instanceof Function ? ready() : ready)
})
return (