From 6b9f21471f0501a7eee89b97a3746abdb4e28d5b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 29 Oct 2026 10:05:53 -0700 Subject: [PATCH] perf(app): keep each tab's timeline when switching directories (#59168) --- .../app/e2e/regression/timeline-cache.spec.ts | 68 +-- packages/app/src/runtime/plugin/link-check.ts | 35 ++ packages/app/src/runtime/plugin/render.tsx | 12 +- packages/app/src/session/composer/region.tsx | 41 +- .../src/session/composer/timeline-actions.ts | 49 ++ packages/app/src/session/screen.tsx | 80 +-- packages/app/src/session/timeline/cache.ts | 160 +++++- .../app/src/session/timeline/controller.tsx | 55 +- .../src/session/timeline/message-timeline.tsx | 17 +- .../src/session/timeline/selected-effect.ts | 19 + .../src/shell/routes/session-ui-provider.tsx | 41 +- packages/app/src/workspaces/location.tsx | 3 + .../app/test-browser/plugin-catalog.test.ts | 67 +++ .../test-browser/plugin-link-check.test.ts | 85 +++ .../app/test-browser/selected-effect.test.ts | 36 ++ .../app/test-browser/timeline-cache.test.ts | 494 +++++++++++++++--- packages/gui-plugins/src/context/catalog.ts | 24 +- .../gui-plugins/src/context/indicator.tsx | 7 +- packages/gui-plugins/src/context/renderer.tsx | 2 +- packages/session-ui/src/context/data.tsx | 10 +- packages/session-ui/src/context/markdown.tsx | 3 + packages/ui/src/context/helper.tsx | 7 +- 22 files changed, 1044 insertions(+), 271 deletions(-) create mode 100644 packages/app/src/runtime/plugin/link-check.ts create mode 100644 packages/app/src/session/composer/timeline-actions.ts create mode 100644 packages/app/src/session/timeline/selected-effect.ts create mode 100644 packages/app/test-browser/plugin-catalog.test.ts create mode 100644 packages/app/test-browser/plugin-link-check.test.ts create mode 100644 packages/app/test-browser/selected-effect.test.ts diff --git a/packages/app/e2e/regression/timeline-cache.spec.ts b/packages/app/e2e/regression/timeline-cache.spec.ts index 1c1d348a7ce..819432eeaef 100644 --- a/packages/app/e2e/regression/timeline-cache.spec.ts +++ b/packages/app/e2e/regression/timeline-cache.spec.ts @@ -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() const release = Promise.withResolvers() @@ -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)}"]`) } diff --git a/packages/app/src/runtime/plugin/link-check.ts b/packages/app/src/runtime/plugin/link-check.ts new file mode 100644 index 00000000000..4998ec67568 --- /dev/null +++ b/packages/app/src/runtime/plugin/link-check.ts @@ -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, check: (href: string) => boolean | Promise) { + 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 => { + if (!untrack(active)) return new Promise((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 +} diff --git a/packages/app/src/runtime/plugin/render.tsx b/packages/app/src/runtime/plugin/render.tsx index 719d3a6e23f..bebf38f991e 100644 --- a/packages/app/src/runtime/plugin/render.tsx +++ b/packages/app/src/runtime/plugin/render.tsx @@ -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 ( 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} > = { - 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, } diff --git a/packages/app/src/session/composer/timeline-actions.ts b/packages/app/src/session/composer/timeline-actions.ts new file mode 100644 index 00000000000..5c2dae6187a --- /dev/null +++ b/packages/app/src/session/composer/timeline-actions.ts @@ -0,0 +1,49 @@ +import type { Accessor } from "solid-js" +import type { SessionUserActions } from "@opencode/session-ui/actions" + +type PendingActions = NonNullable + +/** The timeline's own session, which a kept timeline holds while another session is routed. */ +export type TimelineActionsView = { + sessionID: Accessor + isChild: Accessor +} + +/** Pending input actions, for any session. */ +export type TimelinePendingActions = Omit & { + status: (sessionID: string, messageID: string) => ReturnType +} + +/** A timeline's message actions. A child session's timeline only opens attachments. */ +export function createTimelineActions( + view: TimelineActionsView, + actions: Required> & { + 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, + } +} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index dec2d00a19d..d21d60f4f87 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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((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 ( - - - - } - /> + + + + + } + /> + ) }, () => conversationVisible() && messagesReady(), diff --git a/packages/app/src/session/timeline/cache.ts b/packages/app/src/session/timeline/cache.ts index f0514fd6ed6..27749727366 100644 --- a/packages/app/src/session/timeline/cache.ts +++ b/packages/app/src/session/timeline/cache.ts @@ -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 } }, + session: TimelineSessionSource, render: (source: TimelineSessionSource, active: Accessor) => JSX.Element, visible: Accessor, 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() 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 = (read: Accessor) => - createMemo((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 = (read: Accessor) => { + 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(context: Context, value: T, children: () => JSX.Element) { + return createComponent(context.Provider, { + value, + get children() { + return untrack(children) + }, + }) +} diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index 6062f8fe094..eb072c92233 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -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 } -export function createTimelineController(input: { session: TimelineSessionSource }) { +export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor }) { 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 } | 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() 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")) diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index d80b2c1f37f..71e0dc311a7 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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) diff --git a/packages/app/src/session/timeline/selected-effect.ts b/packages/app/src/session/timeline/selected-effect.ts new file mode 100644 index 00000000000..6e6cb6d52ec --- /dev/null +++ b/packages/app/src/session/timeline/selected-effect.ts @@ -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(source: Accessor, active: Accessor, 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())) + }) +} diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index aa7f4e6b1df..63c43e2bb67 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -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()} diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index e20bcdd3ef2..eb1cf0872a9 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -74,3 +74,6 @@ const context = createSimpleContext({ export const useWorkspaceLocation: () => Accessor = 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 diff --git a/packages/app/test-browser/plugin-catalog.test.ts b/packages/app/test-browser/plugin-catalog.test.ts new file mode 100644 index 00000000000..b60c2766c5b --- /dev/null +++ b/packages/app/test-browser/plugin-catalog.test.ts @@ -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() + } +}) diff --git a/packages/app/test-browser/plugin-link-check.test.ts b/packages/app/test-browser/plugin-link-check.test.ts new file mode 100644 index 00000000000..ae58331f1b2 --- /dev/null +++ b/packages/app/test-browser/plugin-link-check.test.ts @@ -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) { + 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(), Promise.withResolvers()] + 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(), Promise.withResolvers()] + 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() + } +}) diff --git a/packages/app/test-browser/selected-effect.test.ts b/packages/app/test-browser/selected-effect.test.ts new file mode 100644 index 00000000000..4b683bfbd5e --- /dev/null +++ b/packages/app/test-browser/selected-effect.test.ts @@ -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() + } +}) diff --git a/packages/app/test-browser/timeline-cache.test.ts b/packages/app/test-browser/timeline-cache.test.ts index fc38a245383..7ac55c4c9b8 100644 --- a/packages/app/test-browser/timeline-cache.test.ts +++ b/packages/app/test-browser/timeline-cache.test.ts @@ -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> + parents: Partial> + }>({ 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, + }, + 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 = {} + const imageReaders = new Map() + // What each view reads once a deferred part of it, like a Markdown block, first renders. + const deferredReads: Record = {} + const shellOutput = async () => ({ data: { cursor: 0, size: 0, output: "" } }) + const shells: Record = {} + // Turn diff sources by label, as the host binds one to the routed tab, and the labels each view saw. + const turnDiffSources = new Map() - 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 = {} + // 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 = {} + 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 = (context: Context, 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() diff --git a/packages/gui-plugins/src/context/catalog.ts b/packages/gui-plugins/src/context/catalog.ts index 4083736769f..834c5b19415 100644 --- a/packages/gui-plugins/src/context/catalog.ts +++ b/packages/gui-plugins/src/context/catalog.ts @@ -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) { +/** + * 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, active: Accessor = () => 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) { { 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, diff --git a/packages/gui-plugins/src/context/indicator.tsx b/packages/gui-plugins/src/context/indicator.tsx index 1066dc34647..fe5b438e632 100644 --- a/packages/gui-plugins/src/context/indicator.tsx +++ b/packages/gui-plugins/src/context/indicator.tsx @@ -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["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") diff --git a/packages/gui-plugins/src/context/renderer.tsx b/packages/gui-plugins/src/context/renderer.tsx index 1597e2f88de..a75aae30d48 100644 --- a/packages/gui-plugins/src/context/renderer.tsx +++ b/packages/gui-plugins/src/context/renderer.tsx @@ -31,7 +31,7 @@ const setup: Plugin.Setup = (ctx) => { ctx.add(Slot, { at: "session.header", order: 10, - render: (input) => , + render: (input) => , }) ctx.add( diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index d617e87c2d4..2402a6acb63 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -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, } diff --git a/packages/session-ui/src/context/markdown.tsx b/packages/session-ui/src/context/markdown.tsx index f3f37a274e2..8f2b691c610 100644 --- a/packages/session-ui/src/context/markdown.tsx +++ b/packages/session-ui/src/context/markdown.tsx @@ -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 diff --git a/packages/ui/src/context/helper.tsx b/packages/ui/src/context/helper.tsx index d6d1b4b7df2..a7ae3f2ee0e 100644 --- a/packages/ui/src/context/helper.tsx +++ b/packages/ui/src/context/helper.tsx @@ -9,6 +9,7 @@ export function createSimpleContext>( const ctx = createContext() return { + context: ctx, provider: (props: ParentProps) => { const init = input.init(props) const gate = input.gate ?? true @@ -19,10 +20,10 @@ export function createSimpleContext>( // 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 | 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 }).ready - return ready === undefined || (typeof ready === "function" ? ready() : ready) + return ready === undefined || (ready instanceof Function ? ready() : ready) }) return (