From e55a91eadd3cbc464b77875edf100065efea6800 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:18:33 +0800 Subject: [PATCH] refactor(app): use shared server data (#43017) --- .github/workflows/test.yml | 4 + .../performance/timeline-stability/fixture.ts | 14 +- .../regression/cross-server-tab-close.spec.ts | 6 - .../session-timeline-collapse-state.spec.ts | 13 +- .../session-timeline-context-resize.spec.ts | 38 +- .../session-timeline-lifecycle-state.spec.ts | 4 +- .../session-timeline-notices.spec.ts | 7 +- .../subagent-child-navigation.spec.ts | 2 +- packages/app/e2e/utils/mock-server.ts | 8 + packages/app/e2e/utils/sse-transport.ts | 2 +- packages/app/src/app.tsx | 41 +- .../components/dialog-connect-provider.tsx | 7 +- .../src/components/dialog-custom-provider.tsx | 9 +- .../src/components/dialog-edit-project-v2.tsx | 6 +- packages/app/src/components/dialog-fork.tsx | 46 +- .../app/src/components/dialog-select-mcp.tsx | 13 +- .../project-settings-extensions.tsx | 117 +- .../app/src/components/prompt-input-v2.tsx | 27 +- .../src/components/prompt-input.stories.tsx | 98 - .../components/prompt-input/submit.test.ts | 139 +- .../app/src/components/prompt-input/submit.ts | 106 +- .../provider-connection-controller.ts | 20 +- .../src/components/session-context-usage.tsx | 29 +- .../src/components/session-workspace-menu.tsx | 6 +- .../session/session-context-tab.tsx | 158 +- .../components/session/session-new-view.tsx | 26 +- .../settings-v2/dialog-settings-v2.tsx | 2 +- .../src/components/settings-v2/extensions.tsx | 45 +- .../settings-v2/general-controllers.ts | 3 +- .../src/components/settings-v2/providers.tsx | 33 +- .../src/components/settings-v2/workspaces.tsx | 58 +- .../src/components/status-popover-body.tsx | 153 +- .../app/src/components/status-popover.tsx | 30 +- packages/app/src/components/terminal.tsx | 16 +- .../app/src/components/titlebar-tab-strip.tsx | 41 +- packages/app/src/components/titlebar.tsx | 15 +- packages/app/src/context/comments.tsx | 4 +- packages/app/src/context/directory-sync.ts | 127 -- packages/app/src/context/file.tsx | 14 +- .../src/context/global-sync/bootstrap.test.ts | 90 +- .../app/src/context/global-sync/bootstrap.ts | 221 +- .../context/global-sync/child-store.test.ts | 79 +- .../src/context/global-sync/child-store.ts | 109 +- .../context/global-sync/event-reducer.test.ts | 571 +----- .../src/context/global-sync/event-reducer.ts | 399 +--- .../global-sync/home-session-index.test.ts | 155 +- .../context/global-sync/home-session-index.ts | 142 +- .../context/global-sync/session-cache.test.ts | 105 - .../src/context/global-sync/session-cache.ts | 63 - .../src/context/global-sync/session-load.ts | 21 - .../context/global-sync/session-trim.test.ts | 59 - .../src/context/global-sync/session-trim.ts | 58 - packages/app/src/context/global-sync/types.ts | 40 +- .../app/src/context/global-sync/utils.test.ts | 2 - packages/app/src/context/global-sync/utils.ts | 7 +- packages/app/src/context/global.tsx | 14 +- packages/app/src/context/layout.tsx | 15 - packages/app/src/context/local.tsx | 32 +- packages/app/src/context/location.tsx | 60 + packages/app/src/context/mcp.ts | 39 +- packages/app/src/context/notification.tsx | 19 +- packages/app/src/context/permission.tsx | 26 +- packages/app/src/context/prompt.tsx | 4 +- packages/app/src/context/sdk.tsx | 19 - packages/app/src/context/server-sdk.tsx | 48 +- .../context/server-session-v2-reducer.test.ts | 375 ---- .../src/context/server-session-v2-reducer.ts | 592 ------ .../app/src/context/server-session.test.ts | 1808 ----------------- packages/app/src/context/server-session.ts | 1468 ------------- packages/app/src/context/server-sync.test.ts | 214 -- packages/app/src/context/server-sync.tsx | 542 +---- packages/app/src/context/server.tsx | 5 + packages/app/src/context/sync.tsx | 112 +- packages/app/src/context/terminal.tsx | 24 +- packages/app/src/hooks/use-integrations.ts | 23 +- packages/app/src/hooks/use-providers.ts | 38 +- packages/app/src/index.ts | 1 - packages/app/src/pages/directory-layout.tsx | 61 +- .../pages/home/home-sessions-controller.tsx | 52 +- .../src/pages/layout/project-avatar-state.ts | 18 +- .../new-session-draft-controller.ts | 3 - .../pages/new-session/new-session-view.tsx | 8 +- .../new-session-workspace-controller.ts | 50 +- packages/app/src/pages/session.tsx | 380 +--- .../composer/prompt-model-selection.ts | 16 +- .../composer/session-composer-controls.ts | 22 +- .../composer/session-composer-pullout.tsx | 2 +- .../session-composer-region-controller.ts | 75 +- .../composer/session-composer-region.tsx | 30 +- .../composer/session-composer-state.test.ts | 33 - .../composer/session-composer-state.ts | 198 +- .../composer/session-question-dock.tsx | 6 +- .../session/composer/session-request-tree.ts | 11 +- .../session/composer/session-todo-dock.tsx | 154 -- .../composer/todo-panel-motion.stories.tsx | 624 ------ packages/app/src/pages/session/review-tab.tsx | 10 +- .../src/pages/session/session-controller.ts | 20 +- .../app/src/pages/session/session-layout.ts | 4 +- ...ssion-lineage.ts => session-resolution.ts} | 31 +- .../src/pages/session/session-side-panel.tsx | 4 +- .../src/pages/session/terminal-panel-v2.tsx | 4 +- .../src/pages/session/timeline/controller.tsx | 60 +- .../session/timeline/message-timeline.tsx | 35 +- .../app/src/pages/session/timeline/model.ts | 60 +- .../pages/session/usage-exceeded-dialogs.tsx | 4 +- .../pages/session/use-session-commands.tsx | 102 +- .../src/pages/session/v2/review-panel-v2.tsx | 10 +- .../session/v2/session-file-browser-tab.tsx | 4 +- packages/app/src/utils/server-errors.ts | 5 +- ...age.test.ts => session-resolution.test.ts} | 72 +- packages/client/src/solid/data.ts | 65 +- packages/core/test/session-generate.test.ts | 2 + packages/core/test/vcs-hg.test.ts | 1 + packages/desktop/src/main/background-cli.ts | 2 +- packages/storybook/.storybook/main.ts | 2 +- .../mocks/app/context/global-sync.ts | 19 - .../.storybook/mocks/app/context/location.ts | 18 + .../.storybook/mocks/app/context/sdk.ts | 27 - .../mocks/app/context/server-sync.ts | 10 - .../.storybook/mocks/app/context/sync.ts | 38 +- .../tui/test/context/session-tabs.test.tsx | 1 + 121 files changed, 1457 insertions(+), 9982 deletions(-) delete mode 100644 packages/app/src/context/directory-sync.ts delete mode 100644 packages/app/src/context/global-sync/session-cache.test.ts delete mode 100644 packages/app/src/context/global-sync/session-cache.ts delete mode 100644 packages/app/src/context/global-sync/session-load.ts delete mode 100644 packages/app/src/context/global-sync/session-trim.test.ts delete mode 100644 packages/app/src/context/global-sync/session-trim.ts create mode 100644 packages/app/src/context/location.tsx delete mode 100644 packages/app/src/context/sdk.tsx delete mode 100644 packages/app/src/context/server-session-v2-reducer.test.ts delete mode 100644 packages/app/src/context/server-session-v2-reducer.ts delete mode 100644 packages/app/src/context/server-session.test.ts delete mode 100644 packages/app/src/context/server-session.ts delete mode 100644 packages/app/src/pages/session/composer/session-todo-dock.tsx delete mode 100644 packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx rename packages/app/src/pages/session/{session-lineage.ts => session-resolution.ts} (70%) rename packages/app/test-browser/{session-lineage.test.ts => session-resolution.test.ts} (71%) create mode 100644 packages/storybook/.storybook/mocks/app/context/location.ts delete mode 100644 packages/storybook/.storybook/mocks/app/context/sdk.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf495355ac..e278e7af35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,6 +80,8 @@ jobs: if: always() timeout-minutes: 10 working-directory: packages/cli + env: + NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }} run: | bun run script/build.ts --single --skip-install bun run script/service-smoke.ts @@ -94,6 +96,8 @@ jobs: if: always() timeout-minutes: 15 working-directory: packages/cli + env: + NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }} run: | bun run script/build-node.ts --single --skip-install --outdir=dist/node bun run script/service-smoke.ts --node diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 8163772f1e..caf29afecb 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -114,6 +114,7 @@ const nextOrdinals = new Map() const startedParts = new Set() const toolStates = new Map() let eventSequence = 0 +let durableSequence = -1 export async function setupTimeline( page: Page, @@ -132,6 +133,8 @@ export async function setupTimeline( seedHistory?: boolean } = {}, ) { + eventSequence = 0 + durableSequence = -1 const sessions = input.sessions ?? [session()] const messages = input.sessionMessages ?? @@ -427,9 +430,14 @@ export function messageUpdated(info: SessionMessageAssistant) { } export function status(type: SessionStatus["type"], attempt = 1) { - return event("session.status", { + if (type === "busy") return makeEvent("session.execution.started", { sessionID }) + if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID }) + return makeEvent("session.retry.scheduled", { sessionID, - status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type }, + assistantMessageID: assistantID, + attempt, + at: 1700000010000, + error: { type: "provider.error", message: "Rate limited" }, }) } @@ -803,7 +811,7 @@ function makeEvent( definition.durability === "durable" ? { ...base, - durable: { aggregateID: sessionID, seq: eventSequence, version: definition.durable.version }, + durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version }, } : base return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 49faa082d9..9505991fa8 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -38,12 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) - expect( - requests.some((request) => { - const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory - }), - ).toBe(true) }) function session(id: string, directory: string, title: string) { diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 640cdeba05..0651ed9911 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -93,7 +93,9 @@ test.describe("regression: session timeline local row state", () => { events.push(...textEvents()) - await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ + timeout: 10_000, + }) expect(await readToolState(page)).toEqual({ expanded: false, @@ -119,7 +121,9 @@ test.describe("regression: session timeline local row state", () => { events.push(...textEvents()) - await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({ + timeout: 10_000, + }) const siblingProbe = await readDiffProbe(page) expect(siblingProbe).toEqual({ fileMarker: "before", @@ -232,7 +236,7 @@ async function readToolState(page: Page) { row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"), streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`), }), - textPartID, + `${assistantMessageID}:text:0`, ) } @@ -309,7 +313,7 @@ function toolContent(part: typeof editPart): SessionMessageAssistant["content"][ } } -let eventSequence = 0 +let eventSequence = -1 function textEvents(): OpenCodeEvent[] { return [ @@ -410,6 +414,7 @@ async function mockServer( events: EventPayload[], messages: SessionMessageInfo[] = [userMessage, assistantMessage], ) { + eventSequence = -1 await mockOpenCodeServer(page, { directory, project: project(), diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index 6bd1f95b9f..d9e915fae3 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -310,45 +310,10 @@ function toolContent(part: ContextTool): SessionMessageAssistant["content"][numb } } -let eventSequence = 0 +let eventSequence = -1 function toolEvents(part: ContextTool): OpenCodeEvent[] { - const events = [ - eventValue( - "session.tool.input.started", - { - sessionID, - assistantMessageID: part.messageID, - id: part.callID, - name: part.tool, - }, - 1, - ), - eventValue( - "session.tool.input.ended", - { - sessionID, - assistantMessageID: part.messageID, - id: part.callID, - text: JSON.stringify(part.state.input), - }, - 1, - ), - eventValue( - "session.tool.called", - { - sessionID, - assistantMessageID: part.messageID, - id: part.callID, - input: part.state.input, - executed: true, - }, - 1, - ), - ] satisfies OpenCodeEvent[] - if (part.state.status === "running") return events return [ - ...events, eventValue( "session.tool.success", { @@ -381,6 +346,7 @@ function eventValue( } async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) { + eventSequence = -1 await mockOpenCodeServer(page, { directory, project: project(), diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 28cec5b2e3..e428f61cd3 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -96,14 +96,12 @@ test("moves busy through retry and recovery to final idle content", async ({ pag await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) await timeline.send(status("retry"), 180) - await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await timeline.send(status("busy", 2), 180) await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100) await timeline.send(status("idle"), 350) - await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText( "Recovered response", diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index 5f19a23ccd..366513d046 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({ page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), ).toHaveAttribute("data-active", "true") - await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } })) + await timeline.transport.send({ + id: "evt_background_succeeded", + created: Date.now(), + type: "session.execution.succeeded", + data: { sessionID: backgroundID }, + } as never) await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) await expect(backgroundCard).toContainText("Background task (background)") }) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index a9588c37a8..12dc41f41d 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -26,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) }) -test("keeps the parent visible while child lineage resolves", async ({ page }) => { +test("keeps the parent visible while the child session resolves", async ({ page }) => { await setup(page) const requested = Promise.withResolvers() const release = Promise.withResolvers() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index d7c876a64e..bf617f6602 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -182,6 +182,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) }) if (path === "/api/integration") return json(route, { location: location(config), data: [] }) if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/skill") return json(route, { location: location(config), data: [] }) if (path === "/api/plugin") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) if (path === "/api/mcp/resource") @@ -366,6 +367,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET") return json(route, { data: [] }) + const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1] + if (sessionPermission && route.request().method() === "GET") { + const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []) + return json(route, { + data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission), + }) + } if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index cd72cab1aa..2e6a7b67fa 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -148,7 +148,7 @@ export async function installSseTransport marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) + const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join("")) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index ebdfd89a25..2efb7909f4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -34,9 +34,9 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServersProvider } from "@/context/servers" import { SettingsProvider } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" -import { SDKProvider } from "@/context/sdk" +import { LocationProvider } from "@/context/location" import { WslServersProvider } from "@/wsl/context" -import { DirectoryDataProvider } from "@/pages/directory-layout" +import { SessionUIProvider } from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { requireServerKey } from "./utils/session-route" @@ -48,19 +48,16 @@ import { ServerProvider } from "./context/server" const NewSession = lazy(() => import("@/pages/new-session")) function TargetServerRoute(props: ParentProps) { - const params = useParams<{ serverKey: string; id: string }>() + const params = useParams<{ serverKey: string }>() const global = useGlobal() - const conn = createMemo(() => { - const key = requireServerKey(params.serverKey) - return global.servers.list().find((item) => ServerConnection.key(item) === key) - }) + const conn = createMemo(() => + global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)), + ) return ( // Owns the server-identity remount. Session changes must not remount this subtree. - - - {(conn) => {props.children}} - + + {(conn) => {props.children}} ) } @@ -69,14 +66,12 @@ function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() return ( - - tab.type === "draft" && tab.draftID === search.draftId)} - keyed - fallback={} - > - {(draft) => } - + tab.type === "draft" && tab.draftID === search.draftId)} + keyed + fallback={tabs.ready() && } + > + {(draft) => } ) } @@ -91,13 +86,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { {(conn) => ( - - + + - - + + )} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 7309bcf71c..2d3ecff7e7 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -13,7 +13,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, import { createStore } from "solid-js/store" import { useParams } from "@solidjs/router" import { ExternalLink } from "@/components/external-link" -import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useIntegrations } from "@/hooks/use-integrations" @@ -258,7 +257,6 @@ function ProviderConnection(props: { setBack: (handler: () => void) => void }) { const dialog = useDialog() - const serverSync = useServerSync() const params = useParams() const language = useLanguage() const providers = useProviders(() => props.directory) @@ -280,10 +278,7 @@ function ProviderConnection(props: { const provider = createMemo(() => ({ id: props.provider, name: - providers.all().get(props.provider)?.name ?? - serverSync.data.provider.all.get(props.provider)?.name ?? - controller.integration()?.name ?? - props.provider, + providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider, })) const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index c777321aec..4adc8d37f7 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast" import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { ExternalLink } from "@/components/external-link" -import { useServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" @@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) { export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() - const serverSync = useServerSync() + const data = useData() const language = useLanguage() const [form, setForm] = createStore({ @@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const output = validateCustomProvider({ form, t: language.t, - disabledProviders: serverSync.data.config.disabled_providers ?? [], - existingProviderIDs: new Set(serverSync.data.provider.all.keys()), + // TODO: Restore disabled-provider validation when V2 exposes config reads. + disabledProviders: [], + existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)), }) batch(() => { setForm("err", output.err) diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx index 6deb3c4c28..fc5d958c5f 100644 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js" import { useLanguage } from "@/context/language" import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" import { ServerConnection } from "@/context/servers" -import { SDKProvider } from "@/context/sdk" +import { LocationProvider } from "@/context/location" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { createEditProjectModel } from "./edit-project" import { ProjectSettingsExtensions } from "./project-settings-extensions" @@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css" export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { return ( - + - + ) } diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 2771a8193a..c9d7626361 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -1,16 +1,17 @@ import { Component, createMemo } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { useData } from "@/context/server" import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { showToast } from "@/utils/toast" -import { extractPromptFromParts } from "@/utils/prompt" -import type { TextPart as SDKTextPart } from "@/types" -import { base64Encode } from "@opencode-ai/core/util/encode" import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { extractPromptFromParts } from "@/utils/prompt" +import { normalizeSessionMessages } from "@/utils/session-message" +import { useWorkspaceLocation } from "@/context/location" import { useServer } from "@/context/server" import { sessionHref } from "@/utils/session-route" @@ -27,8 +28,9 @@ function formatTime(date: Date): string { export const DialogFork: Component = () => { const params = useParams() const navigate = useNavigate() - const sync = useSync() - const sdk = useSDK() + const data = useData() + const serverSDK = useServerSDK() + const location = useWorkspaceLocation() const prompt = usePrompt() const dialog = useDialog() const language = useLanguage() @@ -38,19 +40,15 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return [] - const msgs = sync().data.message[sessionID] ?? [] + const msgs = data.session.message.list(sessionID) const result: ForkableMessage[] = [] for (const message of msgs) { - if (message.role !== "user") continue - - const parts = sync().data.part[message.id] ?? [] - const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) - if (!textPart) continue + if (message.type !== "user" || !message.text) continue result.push({ id: message.id, - text: textPart.text.replace(/\n/g, " ").slice(0, 200), + text: message.text.replace(/\n/g, " ").slice(0, 200), time: formatTime(new Date(message.time.created)), }) } @@ -63,17 +61,19 @@ export const DialogFork: Component = () => { const sessionID = params.id if (!sessionID) return + const restored = extractPromptFromParts( + normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [], + { + directory: location().directory, + attachmentName: language.t("common.attachment"), + }, + ) + const dir = base64Encode(location().directory) - const parts = sync().data.part[item.id] ?? [] - const restored = extractPromptFromParts(parts, { - directory: sdk().directory, - attachmentName: language.t("common.attachment"), - }) - const dir = base64Encode(sdk().directory) - - sdk() - .api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } }) + serverSDK.api.session + .fork({ sessionID, boundary: { type: "before", messageID: item.id } }) .then((forked) => { + data.session.remember(forked) dialog.close() prompt.set(restored, undefined, { dir, id: forked.id }) navigate(sessionHref(server.key, forked.id)) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 16a73af1c1..f73256e21e 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,5 +1,6 @@ import { Component, createMemo, Show } from "solid-js" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" +import { useWorkspaceLocation } from "@/context/location" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" @@ -14,12 +15,13 @@ const statusLabels = { } as const export const DialogSelectMcp: Component = () => { - const sync = useSync() + const data = useData() + const sdk = useWorkspaceLocation() const language = useLanguage() const items = createMemo(() => - Object.entries(sync().data.mcp ?? {}) - .map(([name, status]) => ({ name, status: status.status })) + (data.location.mcp.server.list({ directory: sdk().directory }) ?? []) + .map((server) => ({ name: server.name, status: server.status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) @@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => { }} > {(i) => { - const mcpStatus = () => sync().data.mcp[i.name] + const mcpStatus = () => + data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status const status = () => mcpStatus()?.status const statusLabel = () => { const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined diff --git a/packages/app/src/components/project-settings-extensions.tsx b/packages/app/src/components/project-settings-extensions.tsx index 85abfe4d94..fb77c7077e 100644 --- a/packages/app/src/components/project-settings-extensions.tsx +++ b/packages/app/src/components/project-settings-extensions.tsx @@ -1,13 +1,12 @@ import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" -import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js" +import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js" import { useLanguage } from "@/context/language" import { useMcpToggle } from "@/context/mcp" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" import { ExternalLink } from "./external-link" type SkillItem = { @@ -15,7 +14,6 @@ type SkillItem = { location: string } -const pluginName = (item: string | [string, Record]) => (typeof item === "string" ? item : item[0]) const skillKey = (item: SkillItem) => `${item.name}\n${item.location}` const ExtensionCard: Component<{ children: unknown }> = (props) => ( @@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => ( ) const ExtensionRow: Component<{ - icon: "mcp" | "cube" | "post-skill" | "code" + icon: "mcp" | "cube" | "post-skill" name: string - status?: string children?: unknown }> = (props) => (
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{ {props.name}
- - {(status) => ( - - - {status()} - - )} - {props.children as any} ) @@ -75,56 +64,55 @@ const SharedSection: Component<{ export const ProjectSettingsExtensions: Component = () => { const language = useLanguage() const serverSDK = useServerSDK() - const directorySDK = useSDK() - const serverSync = useServerSync() - const sync = useSync() - const toggleMcp = useMcpToggle() + const directorySDK = useWorkspaceLocation() + const data = useData() + const toggleMcp = useMcpToggle(() => directorySDK().directory) + + createEffect(() => { + if (serverSDK.connection.status() !== "connected") return + const ref = { directory: directorySDK().directory } + void Promise.all([ + data.location.mcp.server.sync(), + data.location.skill.sync(), + data.location.mcp.server.sync(ref), + data.location.skill.sync(ref), + ]).catch(() => undefined) + }) - const [serverMcp] = createResource( - serverSDK, - (sdk) => - sdk.api.mcp - .list() - .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) - .catch(() => ({})), - { initialValue: {} }, - ) const globalMcpNames = createMemo(() => - [...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(), + [...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(), ) const projectMcpNames = createMemo(() => { const shared = new Set(globalMcpNames()) - const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name)) - if (configured.length > 0) return configured.sort() - return Object.keys(sync().data.mcp ?? {}) + return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? []) + .map((server) => server.name) .filter((name) => !shared.has(name)) .sort() }) - const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected" + const mcpEnabled = (name: string) => + data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status + .status === "connected" - const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName)) + const [globalPluginList] = createResource( + () => serverSDK.connection.status() === "connected", + () => serverSDK.api.plugin.list().then((result) => result.data), + ) + const [projectPluginList] = createResource( + () => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined), + (directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data), + ) + const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id)) const projectPlugins = createMemo(() => { const shared = new Set(globalPlugins()) - return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name)) + return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name)) }) - const [serverSkills] = createResource( - serverSDK, - (sdk): Promise => - sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))), - { initialValue: [] }, - ) - const [directorySkills] = createResource( - directorySDK, - (sdk): Promise => - sdk.api.skill - .list({ location: { directory: sdk.directory } }) - .then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))), - { initialValue: [] }, - ) + const serverSkills = createMemo(() => data.location.skill.list() ?? []) const projectSkills = createMemo(() => { - const shared = new Set(serverSkills.latest.map(skillKey)) - return directorySkills.latest.filter((item) => !shared.has(skillKey(item))) + const shared = new Set(serverSkills().map(skillKey)) + return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter( + (item) => !shared.has(skillKey(item)), + ) }) const mcpRows = (items: string[]) => ( @@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => { {language.t("settings.extensions.tab.mcps")} {language.t("status.popover.tab.plugins")} {language.t("settings.extensions.tab.skills")} - {language.t("project.settings.extensions.tab.lsps")} + {/* TODO: Restore LSP status when V2 exposes it. */} @@ -205,33 +193,10 @@ export const ProjectSettingsExtensions: Component = () => { 0}> {skillRows(projectSkills())} - {skillRows(serverSkills.latest)} + {skillRows(serverSkills())} - -
-
- {language.t("project.settings.extensions.lsp.detected")} - {language.t("project.settings.extensions.lsp.description")} -
- 0}> - - - {(item) => ( - - )} - - - -
-
) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 6adea5d88d..994070f6d1 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout" import { usePermission } from "@/context/permission" import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" +import { useData } from "@/context/server" import { createSessionTabs } from "@/pages/session/helpers" import { showToast } from "@/utils/toast" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" @@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { } export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() + const data = useData() const files = useFile() const layout = useLayout() const comments = useComments() @@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): return [...result, path] }, []) }) - const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running") const attachments = createMemo(() => prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) @@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): const referenceDescription = (reference: ReferenceInfo) => reference.source.type === "git" ? reference.source.repository : reference.source.path const references = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) + (data.location.reference.list({ directory: sdk().directory }) ?? []) + .filter((reference) => !reference.hidden) .map((reference) => ({ id: `reference:${reference.name}`, kind: "reference" as const, @@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): })), ) const resources = createMemo(() => - Object.values(sync().data.mcp_resource).map((resource) => ({ + (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({ id: `resource:${resource.server}:${resource.uri}`, kind: "resource" as const, label: `@${resource.name}`, @@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): })), ]) const slashCommands = createMemo(() => [ - ...sync().data.command.map((item) => ({ + ...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({ id: `custom.${item.name}`, trigger: item.name, title: item.name, @@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): dialog.show(() => ), openContext(key) { const item = controller.contextItem(key) - if (item) openComment(item, props, sync, layout, files, comments) + if (item) openComment(item, props, layout, files, comments) }, onEditor(element) { editor = element as HTMLDivElement @@ -557,7 +557,6 @@ function PromptInputV2ModelControl(props: { function openComment( item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, props: PromptInputV2ControllerProps, - sync: ReturnType, layout: ReturnType, files: ReturnType, comments: ReturnType, @@ -575,9 +574,7 @@ function openComment( }) }) } - const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined - const review = - item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + const review = item.commentOrigin === "review" if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() if (review) { layout.fileTree.setTab("changes") diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 4f7c62dc13..7345afe1c7 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -1,8 +1,6 @@ // @ts-nocheck import { createStore } from "solid-js/store" -import type { Todo } from "@/types" import { createPromptState } from "@/context/prompt" -import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" import { createPromptInputHistory, PromptInput } from "./prompt-input" function createPromptInputStoryRuntime() { @@ -111,93 +109,6 @@ function PromptInputExample() { ) } -const todos: Todo[] = [ - { id: "todo-1", content: "Inspect the session composer animation", status: "completed" }, - { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" }, - { id: "todo-3", content: "Verify session navigation behavior", status: "pending" }, -] - -function PromptInputWithOpenDock() { - const input = createPromptInputStoryRuntime() - const [controls, setControls] = createStore({ - agent: "build", - activeTab: undefined as string | undefined, - todoCollapsed: false, - }) - const inputControls = { - agents: { - available: [], - options: ["build"], - get current() { - return controls.agent - }, - loading: false, - visible: true, - select: (agent?: string) => setControls("agent", agent ?? "build"), - }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { - active: () => controls.activeTab, - all: () => [], - open: () => {}, - setActive: (tab: string) => setControls("activeTab", tab), - }, - reviewPanel: { opened: () => false, open: () => {} }, - }, - } - const state = { - blocked: () => false, - questionRequest: () => undefined, - permissionRequest: () => undefined, - permissionResponding: () => false, - decide: () => {}, - todos: () => todos, - dock: () => true, - closing: () => false, - opening: () => false, - } - return ( - "story-session", - sessionID: () => "story-session", - prompt: input.state, - ready: () => true, - centered: () => false, - todo: { - collapsed: () => controls.todoCollapsed, - onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), - }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: () => {}, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - })} - promptInput={ - {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> - } - /> - ) -} - export default { title: "App/PromptInput", id: "app-prompt-input", @@ -212,12 +123,3 @@ export const Basic = { ), } - -export const DockAlreadyOpen = { - render: () => ( -
-

Prompt Input with open Todo dock

- -
- ), -} diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b65f2c3788..8eba8e80d6 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -11,18 +11,6 @@ type SessionCreateInput = { model?: { id: string; providerID: string; variant?: string } location?: { directory: string } } -const admitted: Array<{ - directory?: string - sessionID: string - messageID: string - text: string - displayText: string - agent: string - model: { providerID: string; modelID: string; variant?: string } - comments: unknown[] -}> = [] -const confirmed: unknown[] = [] -const storedSessions: Record> = {} const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShellDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] @@ -36,13 +24,10 @@ const switchedModels: Array<{ }> = [] const sessionRequestOrder: string[] = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] -const syncedServers: string[] = [] -const admittedServers: string[] = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] const navigations: string[] = [] let serverSessionSyncs = 0 let restoredPrompts = 0 -let clearEchoCalls = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -53,12 +38,9 @@ let createWorktreeGate: Promise | undefined let worktreeFailure: Error | undefined let locationFailure: Error | undefined let promptFailure: Error | undefined -let clearEchoResult = true let worktreeCreates = 0 let activeSDK = "server-a" let activeServer = "server-a" -let activeServerSync = "server-a" -let activeDirectorySync = "server-a" let commands: Array<{ name: string }> = [] let worktreeDirectory = "/repo/new-0" let worktreeID = 0 @@ -217,10 +199,6 @@ beforeAll(async () => { return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } }) - mock.module("@/context/server", () => ({ - useServer: () => ({ key: activeServer }), - })) - mock.module("@/context/tabs", () => ({ useTabs: () => ({ updateDraft: (draftID: string, draft: { worktree?: string }) => { @@ -236,83 +214,35 @@ beforeAll(async () => { usePrompt: () => prompt, })) - mock.module("@/context/sdk", () => ({ - useSDK: () => { + mock.module("@/context/location", () => ({ + useWorkspaceLocation: () => { return () => ({ - scope: activeSDK === "server-a" ? ServerScope.local : "server-b", directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", - api: rootClient.api, - url: "http://localhost:4096", }) }, })) - mock.module("@/context/sync", () => ({ - useSync: () => () => { - const server = activeDirectorySync - return { - data: { command: commands, project: "project" }, - session: { - inbox: { - echo: (value: { - directory?: string - sessionID: string - messageID: string - text: string - displayText: string - agent: string - model: { providerID: string; modelID: string; variant?: string } - comments: unknown[] - }) => { - admittedServers.push(server) - admitted.push(value) - }, - confirm: (value: unknown) => { - confirmed.push(value) - }, - clearEcho: () => { - clearEchoCalls++ - return clearEchoResult - }, - }, - }, - set: () => undefined, - project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" }, - } - }, + mock.module("@/context/server-sdk", () => ({ + useServerSDK: () => ({ + scope: activeSDK === "server-a" ? ServerScope.local : "server-b", + api: rootClient.api, + }), })) - mock.module("@/context/server-sync", () => ({ - useServerSync: () => { - const server = activeServerSync - return { - session: { - remember: () => undefined, - set: () => undefined, - sync: async () => { - serverSessionSyncs++ - }, + mock.module("@/context/server", () => ({ + useServer: () => ({ key: activeServer }), + useData: () => ({ + session: { + remember: () => undefined, + setStatus: () => undefined, + }, + location: { + info: () => ({ project: { id: "project", directory: "/repo/main" } }), + command: { + list: () => commands, }, - child: (directory: string) => { - syncedServers.push(server) - storedSessions[directory] ??= [] - return [ - { session: storedSessions[directory] }, - (...args: unknown[]) => { - if (args[0] !== "session") return - const next = args[1] - if (typeof next === "function") { - storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }> - return - } - if (Array.isArray(next)) { - storedSessions[directory] = next as Array<{ id: string; title?: string }> - } - }, - ] - }, - } - }, + }, + }), })) mock.module("@/context/platform", () => ({ @@ -333,8 +263,6 @@ beforeAll(async () => { beforeEach(() => { createdSessions.length = 0 - admitted.length = 0 - confirmed.length = 0 promotedDrafts.length = 0 updatedDrafts.length = 0 sentCommands.length = 0 @@ -343,12 +271,9 @@ beforeEach(() => { switchedAgents.length = 0 switchedModels.length = 0 sessionRequestOrder.length = 0 - syncedServers.length = 0 - admittedServers.length = 0 promptCaptures.length = 0 navigations.length = 0 restoredPrompts = 0 - clearEchoCalls = 0 params = {} search = {} sentShell.length = 0 @@ -357,8 +282,6 @@ beforeEach(() => { variant = undefined activeSDK = "server-a" activeServer = "server-a" - activeServerSync = "server-a" - activeDirectorySync = "server-a" commands = [] promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] worktreeDirectory = `/repo/new-${++worktreeID}` @@ -368,10 +291,8 @@ beforeEach(() => { worktreeFailure = undefined locationFailure = undefined promptFailure = undefined - clearEchoResult = true worktreeCreates = 0 for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] - for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) const event = { preventDefault: () => undefined } as unknown as Event @@ -446,8 +367,6 @@ describe("prompt submit worktree selection", () => { const result = submit.handleSubmit(event) activeSDK = "server-b" activeServer = "server-b" - activeServerSync = "server-b" - activeDirectorySync = "server-b" search.draftId = "draft-2" release() await result @@ -455,8 +374,6 @@ describe("prompt submit worktree selection", () => { expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }]) - expect(syncedServers.every((server) => server === "server-a")).toBe(true) - expect(admittedServers).toEqual(["server-a"]) expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local }) expect(submitted).toBe(0) }) @@ -476,15 +393,6 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) await Bun.sleep(0) - expect(admitted).toHaveLength(1) - expect(admitted[0]).toMatchObject({ - sessionID: "session-1", - text: "ls", - agent: "agent", - model: { providerID: "provider", modelID: "model", variant: "high" }, - }) - expect(admitted[0]?.messageID).toStartWith("msg_") - expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }]) expect(sentPrompts).toEqual(["/repo/main"]) expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) expect(switchedModels).toEqual([ @@ -503,10 +411,9 @@ describe("prompt submit worktree selection", () => { expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") }) - test("keeps a confirmed echo when the prompt response is lost", async () => { + test("restores the prompt when sending fails", async () => { params = { id: "session-1" } promptFailure = new Error("connection lost") - clearEchoResult = false const submit = makeSubmit({ info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }), }) @@ -514,9 +421,7 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) await settle() - expect(admitted).toHaveLength(1) - expect(clearEchoCalls).toBe(1) - expect(restoredPrompts).toBe(0) + expect(restoredPrompts).toBe(1) }) test("submits slash commands through the current session API", async () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 1e3ca51c87..5a0d7981a0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,17 +1,16 @@ -import type { SessionInfo } from "@opencode-ai/client/promise" +import type { Data } from "@opencode-ai/client/solid" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" -import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" -import { useServerSync, type ServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" import { useLanguage } from "@/context/language" import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" -import { useSDK, type DirectorySDK } from "@/context/sdk" -import { useSync, type DirectorySync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" +import { useServerSDK, type ServerSDK } from "@/context/server-sdk" import { Identifier } from "@/utils/id" import { getDirectory } from "@opencode-ai/core/util/path" import { buildPromptRequest } from "./build-prompt-request" @@ -37,9 +36,8 @@ export type FollowupDraft = { } type FollowupSendInput = { - api: DirectorySDK["api"]["session"] - serverSync: ServerSync - sync: DirectorySync + api: ServerSDK["api"]["session"] + data: Data session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> draft: FollowupDraft messageID?: string @@ -55,17 +53,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) { const images = draftImages(input.draft.prompt) const setBusy = () => { if (!input.optimisticBusy) return - input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" }) + input.data.session.setStatus(input.draft.sessionID, "running") } const setIdle = () => { if (!input.optimisticBusy) return - input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" }) + input.data.session.setStatus(input.draft.sessionID, "idle") } const [head, ...tail] = text.split(" ") const cmd = head?.startsWith("/") ? head.slice(1) : undefined - if (cmd && input.sync.data.command.find((item) => item.name === cmd)) { + if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) { setBusy() try { const messageID = Identifier.ascending("message") @@ -110,14 +108,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) setBusy() - input.sync.session.inbox.echo({ - directory: input.draft.sessionDirectory, - sessionID: input.draft.sessionID, - messageID, - agent: input.draft.agent, - model: { ...input.draft.model, variant: input.draft.variant }, - ...request, - }) try { const session = input.session() @@ -139,22 +129,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) { }) } - const admitted = await input.api.prompt({ + await input.api.prompt({ sessionID: input.draft.sessionID, id: messageID, text: request.text, files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), agents: request.agents, }) - input.sync.session.inbox.confirm(admitted) return true } catch (err) { - const failed = input.sync.session.inbox.clearEcho({ - directory: input.draft.sessionDirectory, - sessionID: input.draft.sessionID, - messageID, - }) - if (!failed) return true setIdle() throw err } @@ -188,9 +171,9 @@ type PromptSubmitInput = { export function createPromptSubmit(input: PromptSubmitInput) { const navigate = useNavigate() - const sdk = useSDK() - const sync = useSync() - const serverSync = useServerSync() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() + const data = useData() const server = useServer() const local = useLocal() const permission = usePermission() @@ -212,12 +195,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { const abort = async () => { const sessionID = params.id if (!sessionID) return Promise.resolve() - serverSync.session.set("todo", sessionID, []) - input.onAbort?.() - return sdk() - .api.session.interrupt({ sessionID }) + return serverSDK.api.session.interrupt({ sessionID }) .catch(() => {}) } @@ -244,21 +224,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const seed = (target: ServerSync, dir: string, info: SessionInfo) => { - target.session.remember(info) - const [, setStore] = target.child(dir) - setStore("session", (list: SessionInfo[]) => { - const result = Binary.search(list, info.id, (item) => item.id) - const next = [...list] - if (result.found) { - next[result.index] = info - return next - } - next.splice(result.index, 0, info) - return next - }) - } - const handleSubmit = async (event: Event) => { event.preventDefault() @@ -291,9 +256,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const submissionSDK = sdk() - const submissionSync = sync() - const submissionServerSync = serverSync - const submissionScope = submissionSDK.scope + const submissionServerSDK = serverSDK + const submissionData = data + const submissionScope = submissionServerSDK.scope const submissionServer = server.key const projectDirectory = submissionSDK.directory const sessionID = params.id @@ -321,14 +286,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { let sessionDirectory = projectDirectory if (isNewSession) { if (worktreeSelection === "create") { - const createdWorktree = await submissionSDK.api.worktree + const createdWorktree = await submissionServerSDK.api.worktree .create({ - projectID: submissionSync.data.project, + projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "", strategy: "git", - directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory), + directory: getDirectory( + submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory, + ), }) .then(async (created) => { - await submissionSDK.api.location.get({ location: { directory: created.directory } }) + await submissionServerSDK.api.location.get({ location: { directory: created.directory } }) return created }) .catch((err) => { @@ -345,15 +312,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (worktreeSelection !== "main" && worktreeSelection !== "create") { sessionDirectory = worktreeSelection } - - if (sessionDirectory !== projectDirectory) { - submissionServerSync.child(sessionDirectory) - } } let session = currentSession if (!session && isNewSession) { - const created = await submissionSDK.api.session + const created = await submissionServerSDK.api.session .create({ agent: currentAgent.name, model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, @@ -367,7 +330,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return undefined }) if (created) { - seed(submissionServerSync, sessionDirectory, created) + submissionData.session.remember(created) session = created await startTransition(() => { if (!session) return @@ -448,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() const eventID = Event.ID.create() - void submissionSDK.api.session + void submissionServerSDK.api.session .shell({ sessionID: session.id, id: eventID, @@ -467,12 +430,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (text.startsWith("/")) { const [cmdName, ...args] = text.split(" ") const commandName = cmdName.slice(1) - const customCommand = submissionSync.data.command.find((c) => c.name === commandName) + const customCommand = submissionData.location.command + .list({ directory: sessionDirectory }) + ?.find((command) => command.name === commandName) if (customCommand) { clearInput() const messageID = Identifier.ascending("message") - submissionServerSync.session.set("session_status", session.id, { type: "busy" }) - void submissionSDK.api.session + submissionData.session.setStatus(session.id, "running") + void submissionServerSDK.api.session .command({ sessionID: session.id, id: messageID, @@ -488,7 +453,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { ), }) .catch((err) => { - submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + submissionData.session.setStatus(session.id, "idle") showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), @@ -506,16 +471,15 @@ export function createPromptSubmit(input: PromptSubmitInput) { clearInput() void sendFollowupDraft({ - api: submissionSDK.api.session, - sync: submissionSync, - serverSync: submissionServerSync, + api: submissionServerSDK.api.session, + data: submissionData, session: () => session, draft, messageID, optimisticBusy: sessionDirectory === projectDirectory, }).catch((err) => { if (sessionDirectory === projectDirectory) { - submissionSync.set("session_status", session.id, { type: "idle" }) + submissionData.session.setStatus(session.id, "idle") } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), diff --git a/packages/app/src/components/provider-connection-controller.ts b/packages/app/src/components/provider-connection-controller.ts index ebb64b7dae..6082f5b5d3 100644 --- a/packages/app/src/components/provider-connection-controller.ts +++ b/packages/app/src/components/provider-connection-controller.ts @@ -1,9 +1,7 @@ import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" -import { useQueryClient } from "@tanstack/solid-query" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { pathKey } from "@/utils/path-key" +import { useData } from "@/context/server" import { createEffect, createMemo, createResource, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" @@ -18,8 +16,7 @@ export function createProviderConnectionController(options: { }) { const language = useLanguage() const serverSDK = useServerSDK() - const serverSync = useServerSync() - const queryClient = useQueryClient() + const data = useData() const location = () => { const directory = options.directory() return directory ? { directory } : undefined @@ -118,12 +115,15 @@ export function createProviderConnectionController(options: { } const finish = async () => { cancelPolling() - const directory = options.directory() - const key = directory ? pathKey(directory) : null + const ref = location() + data.location.integration.invalidate(ref) + data.location.provider.invalidate(ref) + data.location.model.invalidate(ref) await Promise.all([ - queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined), - queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined), - ]) + data.location.integration.sync(ref), + data.location.provider.sync(ref), + data.location.model.sync(ref), + ]).catch(() => undefined) if (polling.disposed) return options.onComplete() } diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 71a908ebef..11d12d031e 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -7,11 +7,10 @@ import { createMediaQuery } from "@solid-primitives/media" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -import { useSDK } from "@/context/sdk" -import { getSessionContext } from "@/components/session/session-context-metrics" +import { useWorkspaceLocation } from "@/context/location" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -40,11 +39,11 @@ function openSessionContext(args: { } export function SessionContextUsage(props: SessionContextUsageProps) { - const sync = useSync() + const data = useData() const file = useFile() const layout = useLayout() const language = useLanguage() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -56,8 +55,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), fileBrowser: () => isDesktop() && !!params.id, }) - const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) - const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : [])) + const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) const usd = createMemo( () => @@ -67,7 +66,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) - const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const context = createMemo(() => { + const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens) + if (message?.type !== "assistant" || !message.tokens) return + const model = providers.all().get(message.model.providerID)?.models[message.model.id] + const total = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + return { + total, + usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, + } + }) const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx index f133107e3b..5aa792b4ce 100644 --- a/packages/app/src/components/session-workspace-menu.tsx +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js import type { Project } from "@/types" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" import { useSettingsDialog } from "@/components/settings-dialog" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" @@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: { }) { const language = useLanguage() const serverSDK = useServerSDK() - const serverSync = useServerSync() + const data = useData() const openWorkspaces = useSettingsDialog("workspaces") const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [directories, setDirectories] = createSignal(workspaceDirectories(props.project)) - const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID) + const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running" const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory)) const workspaces = () => directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory)) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 7ffe6ebb86..aa6673160b 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,8 +1,7 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" import { checksum } from "@opencode-ai/core/util/encode" -import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" @@ -11,25 +10,16 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Message, Part, UserMessage } from "@/types" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" +import { useServerSDK } from "@/context/server-sdk" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContext } from "./session-context-metrics" -import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" -const BREAKDOWN_COLOR: Record = { - system: "var(--syntax-info)", - user: "var(--syntax-success)", - assistant: "var(--syntax-property)", - tool: "var(--syntax-warning)", - other: "var(--syntax-comment)", -} - function Stat(props: { label: string; value: JSX.Element }) { return (
@@ -39,12 +29,11 @@ function Stat(props: { label: string; value: JSX.Element }) { ) } -function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) { +function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) { const file = createMemo(() => { - const parts = props.getParts(props.message.id) - const contents = JSON.stringify({ message: props.message, parts }, null, 2) + const contents = JSON.stringify(props.message, null, 2) return { - name: `${props.message.role}-${props.message.id}.json`, + name: `${props.message.type}-${props.message.id}.json`, contents, cacheKey: checksum(contents), } @@ -62,8 +51,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) => } function RawMessage(props: { - message: Message - getParts: (id: string) => Part[] + message: SessionMessageInfo onRendered: () => void time: (value: number | undefined) => string }) { @@ -73,7 +61,7 @@ function RawMessage(props: {
- {props.message.role} • {props.message.id} + {props.message.type} • {props.message.id}
{props.time(props.message.time.created)}
@@ -84,52 +72,35 @@ function RawMessage(props: {
- +
) } -const emptyMessages: Message[] = [] -const emptyUserMessages: UserMessage[] = [] +const emptyMessages: SessionMessageInfo[] = [] export function SessionContextTab() { - const sync = useSync() + const data = useData() const language = useLanguage() - const sdk = useSDK() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const { params, view } = useSessionLayout() - const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined)) const messages = createMemo( () => { const id = params.id if (!id) return emptyMessages - return (sync().data.message[id] ?? []) as Message[] + return data.session.message.list(id) }, emptyMessages, { equals: same }, ) - const userMessages = createMemo( - () => messages().filter((m) => m.role === "user") as UserMessage[], - emptyUserMessages, - { equals: same }, - ) - - const visibleUserMessages = createMemo( - () => { - const revert = info()?.revert?.messageID - if (!revert) return userMessages() - const boundary = userMessages().findIndex((message) => message.id === revert) - return boundary < 0 ? userMessages() : userMessages().slice(0, boundary) - }, - emptyUserMessages, - { equals: same }, - ) - const usd = createMemo( () => new Intl.NumberFormat(language.intl(), { @@ -138,7 +109,28 @@ export function SessionContextTab() { }), ) - const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const ctx = createMemo(() => { + const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens) + if (message?.type !== "assistant" || !message.tokens) return + const provider = providers.all().get(message.model.providerID) + const model = provider?.models[message.model.id] + const total = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + return { + message, + tokens: message.tokens, + providerLabel: provider?.name ?? message.model.providerID, + modelLabel: model?.name ?? message.model.id, + limit: model?.limit.context, + input: message.tokens.input, + total, + usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, + } + }) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -147,8 +139,8 @@ export function SessionContextTab() { const counts = createMemo(() => { const all = messages() - const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) - const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) + const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0) + const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0) return { all: all.length, user, @@ -157,8 +149,7 @@ export function SessionContextTab() { }) const systemPrompt = createMemo(() => { - const msg = findLast(visibleUserMessages(), (m) => !!m.system) - const system = msg?.system + const system = messages().findLast((message) => message.type === "system")?.text if (!system) return const trimmed = system.trim() if (!trimmed) return @@ -177,30 +168,6 @@ export function SessionContextTab() { return c.modelLabel }) - const breakdown = createMemo( - on( - () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], - () => { - const c = ctx() - if (!c?.input) return [] - return estimateSessionContextBreakdown({ - messages: messages(), - parts: sync().data.part as Record, - input: c.input, - systemPrompt: systemPrompt(), - }) - }, - ), - ) - - const breakdownLabel = (key: SessionContextBreakdownKey) => { - if (key === "system") return language.t("context.breakdown.system") - if (key === "user") return language.t("context.breakdown.user") - if (key === "assistant") return language.t("context.breakdown.assistant") - if (key === "tool") return language.t("context.breakdown.tool") - return language.t("context.breakdown.other") - } - const stats = [ { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) }, @@ -210,12 +177,12 @@ export function SessionContextTab() { { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) }, { label: "context.stats.cacheTokens", value: () => - `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, + `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, @@ -230,7 +197,7 @@ export function SessionContextTab() { try { const data = await fetchSessionExport({ sessionID, - api: sdk().api, + api: serverSDK.api, }) const filename = sessionExportFilename(data.info) downloadSessionExport(filename, data) @@ -252,8 +219,6 @@ export function SessionContextTab() { let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined - const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] - const restoreScroll = () => { const el = scroll if (!el) return @@ -314,37 +279,6 @@ export function SessionContextTab() {
- 0}> -
-
{language.t("context.breakdown.title")}
-
- - {(segment) => ( -
- )} - -
-
- - {(segment) => ( -
-
-
{breakdownLabel(segment.key)}
-
{segment.percent.toLocaleString(language.intl())}%
-
- )} - -
- -
- - {(prompt) => (
@@ -372,7 +306,7 @@ export function SessionContextTab() { {(message) => ( - + )} diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index dba0560425..acaca950c9 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -1,8 +1,8 @@ import { Show, createMemo } from "solid-js" import { DateTime } from "luxon" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { useLanguage } from "@/context/language" +import { useData } from "@/context/server" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" @@ -16,28 +16,32 @@ interface NewSessionViewProps { } export function NewSessionView(props: NewSessionViewProps) { - const sync = useSync() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const language = useLanguage() + const data = useData() - const sandboxes = createMemo(() => sync().project?.sandboxes ?? []) + const project = createMemo(() => { + const projectID = data.location.info({ directory: sdk().directory })?.project.id + return projectID ? data.project.get(projectID) : undefined + }) + const sandboxes = createMemo(() => project()?.sandboxes ?? []) const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) const current = createMemo(() => { const selection = props.worktree if (options().includes(selection)) return selection return MAIN_WORKTREE }) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory) const isWorktree = createMemo(() => { - const project = sync().project - if (!project) return false - return sdk().directory !== project.worktree + const current = project() + if (!current) return false + return sdk().directory !== current.canonical }) const label = (value: string) => { if (value === MAIN_WORKTREE) { if (isWorktree()) return language.t("session.new.worktree.main") - const branch = sync().data.vcs?.branch + const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) return language.t("session.new.worktree.main") } @@ -69,7 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) { {label(current())}
- + {(project) => (
diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 05c12bcf18..c1fc459e84 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -63,7 +63,7 @@ export const DialogSettings: Component<{ const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) return draft?.type === "draft" ? draft.directory : undefined } - if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory + if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory return undefined }) diff --git a/packages/app/src/components/settings-v2/extensions.tsx b/packages/app/src/components/settings-v2/extensions.tsx index f5ce1ea4b6..49aab487da 100644 --- a/packages/app/src/components/settings-v2/extensions.tsx +++ b/packages/app/src/components/settings-v2/extensions.tsx @@ -1,10 +1,11 @@ -import { Component, For, createMemo, createResource } from "solid-js" +import { Component, For, createEffect, createMemo, createResource } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { useLanguage } from "@/context/language" +import { useData } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useMcpToggle } from "@/context/mcp" import { ExternalLink } from "../external-link" import { InlineServerSelect } from "./parts/server-select" import "./settings-v2.css" @@ -21,35 +22,35 @@ interface PluginRowItem { export const SettingsExtensionsV2: Component = () => { const language = useLanguage() const serverSdk = useServerSDK() - const serverSync = useServerSync() + const data = useData() + const [mcpList, { refetch: refetchMcp }] = createResource( + () => serverSdk.connection.status() === "connected", + () => serverSdk.api.mcp.list().then((result) => result.data), + ) + const toggleMcp = useMcpToggle(() => undefined, refetchMcp) const mcps = createMemo(() => { - const configMcp = serverSync.data.config.mcp ?? {} - return Object.entries(configMcp).map(([name, config]) => ({ - name, - enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false, + return (mcpList.latest ?? []).map((server) => ({ + name: server.name, + enabled: server.status.status === "connected", })) }) const handleMcpToggle = (item: McpRowItem, checked: boolean) => { - const before = serverSync.data.config.mcp ?? {} - const config = before[item.name] - if (typeof config !== "object" || config === null) return - const next = { ...before, [item.name]: { ...config, enabled: checked } } - serverSync.set("config", "mcp", next) - void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before)) + if (item.enabled === checked || toggleMcp.isPending) return + toggleMcp.mutate(item.name) } - const plugins = createMemo(() => { - const raw = serverSync.data.config.plugin ?? [] - return raw.map((item) => { - const name = typeof item === "string" ? item : item[0] - return { name } - }) - }) + const [pluginList] = createResource( + () => serverSdk.connection.status() === "connected", + () => serverSdk.api.plugin.list().then((result) => result.data), + ) + const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => ({ name: item.id }))) - const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), { - initialValue: [], + createEffect(() => { + if (serverSdk.connection.status() !== "connected") return + void data.location.skill.sync().catch(() => undefined) }) + const skills = () => data.location.skill.list() ?? [] return ( <> diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts index 66b43552bd..6b5b63efd5 100644 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js" import type { ColorScheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context" import { usePermission } from "@/context/permission" -import { useServerSync } from "@/context/server-sync" import { monoDefault, monoFontFamily, @@ -34,7 +33,7 @@ export function createPermissionScopeController( const s = server() const id = sessionID() if (!s || !id) return undefined - return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory + return serverCtx()?.data.session.get(id)?.location.directory }) return { diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 3e3390b9a3..0b59b21308 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -8,7 +8,6 @@ import { useIntegrations } from "@/hooks/use-integrations" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsServerScope } from "../settings-server-picker" @@ -39,7 +38,6 @@ export const SettingsProvidersV2: Component<{ const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() - const serverSync = useServerSync() const providers = useProviders(() => props.directory) const integrations = useIntegrations(() => props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) @@ -55,15 +53,12 @@ export const SettingsProvidersV2: Component<{ } const connected = createMemo(() => { - return providers.connected().filter( - (provider) => - provider.id !== "opencode" || - Object.values(provider.models).some((model) => { - if (typeof model !== "object" || model === null || !("cost" in model)) return false - const cost = model.cost - return typeof cost === "object" && cost !== null && "input" in cost - }), - ) + return providers + .connected() + .filter( + (provider) => + provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0), + ) }) const popular = createMemo(() => { @@ -94,10 +89,7 @@ export const SettingsProvidersV2: Component<{ const current = source(item) if (current === "env") return language.t("settings.providers.tag.environment") if (current === "api") return language.t("provider.connect.method.apiKey") - if (current === "config") { - if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") - return language.t("settings.providers.tag.config") - } + if (current === "config") return language.t("settings.providers.tag.config") if (current === "custom") return language.t("settings.providers.tag.custom") return language.t("settings.providers.tag.other") } @@ -105,19 +97,12 @@ export const SettingsProvidersV2: Component<{ const canDisconnect = (item: ProviderItem) => { const current = integration(item.id) if (current) return current.connections.some((connection) => connection.type === "credential") - return source(item) !== "env" && !isConfigCustom(item.id) + const currentSource = source(item) + return currentSource !== "env" && currentSource !== "config" } const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key - const isConfigCustom = (providerID: string) => { - const provider = serverSync.data.config.provider?.[providerID] - if (!provider) return false - if (provider.npm !== "@ai-sdk/openai-compatible") return false - if (!provider.models || Object.keys(provider.models).length === 0) return false - return true - } - const disconnect = async (providerID: string, name: string) => { const location = props.directory ? { directory: props.directory } : undefined await serverSdk.api.integration diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx index 48454fdf83..605d4885f7 100644 --- a/packages/app/src/components/settings-v2/workspaces.tsx +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -1,6 +1,6 @@ import type { Component } from "solid-js" import { For, Show, createMemo } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import type { SessionInfo } from "@opencode-ai/client/promise" import { useQuery } from "@tanstack/solid-query" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -13,9 +13,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { getFilename } from "@opencode-ai/core/util/path" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" import { showToast } from "@/utils/toast" import { getRelativeTime } from "@/utils/time" +import { sessionLabel } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { SettingsListV2 } from "./parts/list" import { InlineServerSelect } from "./parts/server-select" @@ -37,6 +38,7 @@ import { } from "@/utils/workspace" import { listAllSessions } from "@/utils/session" import type { ServerScope } from "@/utils/server-scope" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import "./settings-v2.css" type Workspace = { @@ -48,7 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() - const serverSync = useServerSync() + const data = useData() const tabs = useTabs() const platform = usePlatform() const [store, setStore] = createStore({ @@ -56,9 +58,22 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr transaction: undefined as "confirm" | "running" | undefined, }) - const workspaces = createMemo(() => workspaceInventory(serverSync.data.project)) + const projectQuery = useQuery(() => ({ + queryKey: [serverSDK.scope, "settings-workspace-projects"] as const, + queryFn: async () => + Promise.all( + (await serverSDK.api.project.list()).map(async (project) => { + const worktrees = await serverSDK.api.worktree + .list({ projectID: project.id }) + .catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))]) + return normalizeProjectInfo({ ...project, worktrees }) + }), + ), + refetchOnMount: "always", + })) + const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? [])) const projects = createMemo(() => - serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0), + (projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0), ) const projectName = (project: Project) => project.name || getFilename(project.worktree) const projectOptions = createMemo(() => [ @@ -71,18 +86,21 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) const captureDeleteContext = () => { const sdk = serverSDK - return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } + return { + sdk, + data, + server: ServerConnection.key(sdk.server), + activeDirectory: props.activeDirectory, + } } const loadSessions = async (context = captureDeleteContext()) => { const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" }) - return mergeWorkspaceSessionInventory( - fetched, - Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session), - ) + fetched.forEach(context.data.session.remember) + return mergeWorkspaceSessionInventory(fetched, context.data.session.list()) } const sessionQuery = useQuery(() => ({ queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const, - queryFn: () => loadSessions(), + queryFn: () => loadSessions().then(() => Date.now()), refetchOnMount: "always", })) const sessionsByWorkspace = createMemo( @@ -90,7 +108,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr new Map( workspaces().map((workspace) => [ pathKey(workspace.directory), - sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [], + sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [], ]), ), ) @@ -177,19 +195,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr }) }) clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope) - context.sync.set( - "project", - produce((draft) => { - const project = draft.find((item) => item.id === workspace.project.id) - if (!project) return - project.sandboxes = (project.sandboxes ?? []).filter( - (directory) => pathKey(directory) !== pathKey(workspace.directory), - ) - project.worktrees = project.worktrees.filter( - (worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory), - ) - }), - ) + await projectQuery.refetch() } let inspectionID = 0 @@ -377,7 +383,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr {(session) => (
- {session.title} + {sessionLabel(session)} {(time) => {time()}} diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 427c86458d..6777ed0938 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -1,17 +1,11 @@ import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" -import { showToast } from "@/utils/toast" -import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, createResource, For, type JSXElement, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { ServerConnection, useServers } from "@/context/servers" -import { useSync } from "@/context/sync" -import { type ServerHealth } from "@/utils/server-health" -import { useGlobal } from "@/context/global" import { useMcpToggle } from "@/context/mcp" -import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" +import { useWorkspaceLocation } from "@/context/location" +import { useData } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -25,110 +19,22 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { ) } -const listServersByHealth = ( - list: ServerConnection.Any[], - active: ServerConnection.Key | undefined, - status: Record, -) => { - if (!list.length) return list - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerHealth) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 - } - - return list.slice().sort((a, b) => { - if (ServerConnection.key(a) === active) return -1 - if (ServerConnection.key(b) === active) return 1 - const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) - }) -} - -const useDefaultServerKey = ( - get: (() => string | Promise | null | undefined) | undefined, -) => { - const [state, setState] = createStore({ - key: undefined as ServerConnection.Key | undefined, - tick: 0, - }) - - createEffect(() => { - state.tick - let dead = false - const result = get?.() - if (!result) { - setState("key", undefined) - onCleanup(() => { - dead = true - }) - return - } - - if (result instanceof Promise) { - void result.then((next) => { - if (dead) return - setState("key", next ?? undefined) - }) - onCleanup(() => { - dead = true - }) - return - } - - setState("key", ServerConnection.Key.make(result)) - onCleanup(() => { - dead = true - }) - }) - - return { - key: () => { - return state.key - }, - refresh: () => setState("tick", (value) => value + 1), - } -} - -type ServerStatusItem = { - key: ServerConnection.Key - conn: ServerConnection.Any - health?: ServerHealth - blocked: boolean - active: boolean - onSelect: () => void -} - export function StatusPopoverBody(props: { shown: boolean }) { - const sync = useSync() - const sdk = useSDK() + const data = useData() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const language = useLanguage() - const fail = (err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - - let dialogRun = 0 - onCleanup(() => { - dialogRun += 1 - }) - const toggleMcp = useMcpToggle() - const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) - const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status + const toggleMcp = useMcpToggle(() => sdk().directory) + const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? [] + const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b))) + const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) - const lspItems = createMemo(() => sync().data.lsp ?? []) - const lspCount = createMemo(() => lspItems().length) const [pluginList] = createResource( () => (props.shown ? sdk().directory : undefined), (directory) => - sdk() - .api.plugin.list({ location: { directory } }) + serverSDK.api.plugin + .list({ location: { directory } }) .then((result) => result.data), ) const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id)) @@ -150,10 +56,7 @@ export function StatusPopoverBody(props: { shown: boolean }) { {mcpConnected() > 0 ? `${mcpConnected()} ` : ""} {language.t("status.popover.tab.mcp")} - - {lspCount() > 0 ? `${lspCount()} ` : ""} - {language.t("status.popover.tab.lsp")} - + {/* TODO: Restore LSP status when V2 exposes it. */} {pluginCount() > 0 ? `${pluginCount()} ` : ""} @@ -223,34 +126,6 @@ export function StatusPopoverBody(props: { shown: boolean }) {
- -
-
- 0} - fallback={ -
{language.t("dialog.lsp.empty")}
- } - > - - {(item) => ( -
-
- {item.name || item.id} -
- )} - - -
-
- -
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 9d4689515b..8c3b2e134c 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -5,16 +5,14 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Popover } from "@opencode-ai/ui/popover" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" -import { ServerConnection, useServers } from "@/context/servers" -import { useServerSDK } from "@/context/server-sdk" -import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" import { hasNonBlockingServiceIssue, hasServiceNeedingAttention, serverStatusDotClass, } from "./status-popover-indicator" -import { useServer } from "@/context/server" +import { useData, useServer } from "@/context/server" +import { useWorkspaceLocation } from "@/context/location" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) @@ -22,19 +20,21 @@ export function StatusPopover() { const language = useLanguage() const server = useServer() const global = useGlobal() - const sync = useSync() + const data = useData() + const sdk = useWorkspaceLocation() const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[server.key]?.healthy - const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) + const ready = createMemo(() => serverHealth() === false || mcp() !== undefined) const attention = createMemo(() => hasServiceNeedingAttention({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + mcp: (mcp() ?? []).map((item) => item.status.status), }), ) const issue = createMemo(() => hasNonBlockingServiceIssue({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - lsp: (sync().data.lsp ?? []).map((item) => item.status), + mcp: (mcp() ?? []).map((item) => item.status.status), + lsp: [], }), ) @@ -86,19 +86,21 @@ export function StatusPopoverV2() { const language = useLanguage() const server = useServer() const global = useGlobal() - const sync = useSync() + const data = useData() + const sdk = useWorkspaceLocation() const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[server.key]?.healthy - const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) + const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) + const ready = createMemo(() => serverHealth() === false || mcp() !== undefined) const attention = createMemo(() => hasServiceNeedingAttention({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), + mcp: (mcp() ?? []).map((item) => item.status.status), }), ) const issue = createMemo(() => hasNonBlockingServiceIssue({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - lsp: (sync().data.lsp ?? []).map((item) => item.status), + mcp: (mcp() ?? []).map((item) => item.status.status), + lsp: [], }), ) const state = createMemo(() => ({ diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index b4a3e369b9..e2ccca634c 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -10,7 +10,8 @@ import { SerializeAddon } from "@/addons/serialize" import { matchKeybind, parseKeybind } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" +import { useServerSDK } from "@/context/server-sdk" import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" @@ -171,13 +172,14 @@ const persistTerminal = (input: { export const Terminal = (props: TerminalProps) => { const platform = usePlatform() - const sdk = useSDK() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const settings = useSettings() const theme = useTheme() const language = useLanguage() // Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes. const directory = sdk().directory - const url = sdk().url + const url = serverSDK.url let container!: HTMLDivElement const [local, others] = splitProps(props, [ "pty", @@ -235,8 +237,8 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = async (cols: number, rows: number) => { - return sdk() - .api.pty.update({ + return serverSDK.api.pty + .update({ ptyID: id, location: { directory }, size: { cols, rows }, @@ -519,8 +521,8 @@ export const Terminal = (props: TerminalProps) => { } const gone = async () => { - return sdk() - .api.pty.get({ ptyID: id, location: { directory } }) + return serverSDK.api.pty + .get({ ptyID: id, location: { directory } }) .then((result) => result.data.status === "exited") .catch((err) => { if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 845290a44f..fd48ee2cfb 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" @@ -84,32 +84,35 @@ function SessionTabEntry(props: { const tabs = useTabs() const language = useLanguage() const sdk = createMemo(() => props.serverCtx?.sdk ?? null) - const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId)) + const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) const [loadedSession] = createResource( () => { const ctx = props.serverCtx return ctx ? { id: props.tab.sessionId, ctx } : null }, - ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), + ({ id, ctx }) => + ctx.data.session + .sync(id) + .then(() => ctx.data.session.get(id)) + .catch(() => undefined), ) const session = createMemo(() => cachedSession() ?? loadedSession()) const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) - let prefetched = false const rename = async (title: string) => { const value = session() const ctx = props.serverCtx if (!value || !ctx) return - ctx.sync.session.remember({ ...value, title }) + ctx.data.session.remember({ ...value, title }) try { await ctx.sdk.api.session.rename({ sessionID: value.id, title }) } catch (err) { const current = session() const currentCtx = props.serverCtx - if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) + if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title }) showToast({ title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : undefined, @@ -122,19 +125,19 @@ function SessionTabEntry(props: { createEffect(() => { const ctx = props.serverCtx const value = session() - if (!ctx || !value || prefetched) return - prefetched = true - createRoot((dispose) => { - try { - void ctx.sync - .ensureDirSyncContext(value.location.directory) - .session.sync(value.id) - .catch(() => {}) - .finally(dispose) - } catch { - dispose() - } - }) + if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return + const timer = window.setTimeout( + () => + void Promise.allSettled([ + ctx.data.session.sync(value.id, { children: true }), + ctx.data.session.pending.sync(value.id), + ctx.data.session.message.sync(value.id), + ctx.data.session.permission.sync(value.id), + ctx.data.session.form.sync(value.id), + ]), + 300 + props.index * 50, + ) + onCleanup(() => window.clearTimeout(timer)) }) createEffect(() => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 6434a642b8..796ccd88ae 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -254,13 +254,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl } case "home": { const selection = layout.home.selection() - const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) - const project = conn - ? global - .ensureServerCtx(conn) - .projects.list() - .find((item) => item.worktree === selection.directory) - : undefined + const conn = + global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ?? + global.servers.list()[0] + const projects = conn ? global.ensureServerCtx(conn).projects : undefined + const project = + projects?.list().find((item) => item.worktree === selection.directory) ?? + projects?.list().find((item) => item.worktree === projects.last()) ?? + projects?.list()[0] if (conn && project) { tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") return diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index cac4d8cc6f..f2f23342f5 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -9,7 +9,7 @@ import type { ServerScope } from "@/utils/server-scope" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" -import { useSDK } from "./sdk" +import { useWorkspaceLocation } from "./location" export type LineComment = { id: string @@ -210,7 +210,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() const cache = createScopedCache( (key) => { diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts deleted file mode 100644 index 09e5ec7bba..0000000000 --- a/packages/app/src/context/directory-sync.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Binary } from "@opencode-ai/core/util/binary" -import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise" -import { createMemo } from "solid-js" -import { produce, reconcile, type SetStoreFunction } from "solid-js/store" -import type { createServerSdkContext } from "./server-sdk" -import type { createServerSyncContextInner } from "./server-sync" -import type { PromptEcho } from "./server-session" -import type { State } from "./global-sync/types" - -const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -const sessionFields = new Set([ - "session_status", - "session_working", - "session_diff", - "todo", - "permission", - "question", - "message", - "session_message", - "part", - "part_text_accum_delta", -]) - -export const createDirSyncContext = ( - directory: string, - serverSync: ReturnType, - serverSDK: ReturnType, -) => { - const current = createMemo(() => serverSync.child(directory, { mcp: true })) - const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const data = new Proxy({} as State, { - get(_, property: keyof State) { - if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data) - if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data] - return current()[0][property] - }, - }) - const set = ((...input: unknown[]) => { - if (typeof input[0] === "string" && sessionFields.has(input[0])) { - return (serverSync.session.set as (...args: unknown[]) => unknown)(...input) - } - const result = (current()[1] as (...args: unknown[]) => unknown)(...input) - if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember) - return result - }) as SetStoreFunction - - const index = (sessionID: string) => { - const session = serverSync.session.get(sessionID) - if (!session || session.location.directory !== directory) return - const [store, setStore] = current() - const result = Binary.search(store.session, session.id, (item) => item.id) - if (result.found) { - setStore("session", result.index, reconcile(session)) - return - } - setStore( - "session", - produce((draft) => void draft.splice(result.index, 0, session)), - ) - } - - return { - data, - set, - get status() { - return current()[0].status - }, - get ready() { - return current()[0].status !== "loading" - }, - get project() { - const store = current()[0] - const match = Binary.search(serverSync.data.project, store.project, (project) => project.id) - if (match.found) return serverSync.data.project[match.index] - }, - session: { - remember(session: SessionInfo) { - serverSync.session.remember(session) - index(session.id) - }, - get(sessionID: string) { - const session = serverSync.session.get(sessionID) - if (session?.location.directory === directory) return session - }, - inbox: { - echo(input: PromptEcho & { directory?: string }) { - serverSync.session.inbox.echo(input) - }, - confirm(input: SessionInboxInfo) { - return serverSync.session.inbox.confirm(input) - }, - clearEcho(input: { directory?: string; sessionID: string; messageID: string }) { - return serverSync.session.inbox.clearEcho(input) - }, - }, - async sync(sessionID: string, options?: { force?: boolean }) { - await serverSync.session.sync(sessionID, options) - index(sessionID) - }, - todo: serverSync.session.todo, - history: serverSync.session.history, - evict(sessionID: string) { - serverSync.session.evict(sessionID) - }, - fetch: async (count = 10) => { - const [store, setStore] = current() - setStore("limit", (value) => value + count) - const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) - const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit) - sessions.forEach(serverSync.session.remember) - setStore("session", reconcile(sessions, { key: "id" })) - }, - more: createMemo(() => current()[0].session.length >= current()[0].limit), - archive: async (sessionID: string) => { - // TODO: Restore archiving when the V2 client exposes a session archive API. - void sessionID - }, - }, - mcp: { - toggle: (name: string) => serverSync.mcp.toggle(directory, name), - }, - absolute, - get directory() { - return current()[0].path.directory - }, - } -} diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 20e3868d61..d104410294 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -5,8 +5,7 @@ import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" -import { useSDK } from "./sdk" -import { useSync } from "./sync" +import { useWorkspaceLocation } from "./location" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { createPathHelpers } from "./file/path" @@ -56,8 +55,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ name: "File", gate: false, init: () => { - const sdk = useSDK() - useSync() + const sdk = useWorkspaceLocation() const params = useParams() const serverSDK = useServerSDK() const language = useLanguage() @@ -80,8 +78,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ scope, normalizeDir: path.normalizeDir, list: (dir) => - sdk() - .api.file.list({ path: dir, location: { directory: scope() } }) + serverSDK.api.file + .list({ path: dir, location: { directory: scope() } }) .then((x) => x.data.map((entry) => ({ ...entry, @@ -187,8 +185,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setLoading(file) - const promise = sdk() - .api.file.read({ path: file, location: { directory } }) + const promise = serverSDK.api.file + .read({ path: file, location: { directory } }) .then((data) => { if (scope() !== directory) return const content = { type: "text" as const, content: new TextDecoder().decode(data) } diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 81cc3001be..f715e71a1f 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,15 +1,7 @@ import { describe, expect, test } from "bun:test" import { QueryClient } from "@tanstack/solid-query" -import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { - loadAgentsQuery, - loadCommands, - loadPathQuery, - loadProjectsQuery, - loadProvidersQuery, - loadReferencesQuery, -} from "./bootstrap" +import { loadPathQuery, loadProjectsQuery } from "./bootstrap" import { ServerScope } from "@/utils/server-scope" import type { ServerApi } from "@/utils/server" @@ -18,44 +10,11 @@ type WorktreeApi = ServerApi["worktree"] describe("query keys", () => { test("partitions identical directories by server scope", () => { - const api = {} as CatalogApi const location = {} as ServerApi["location"] const remote = "https://debian.example" as typeof ServerScope.local expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) - }) - - test("loads the current provider and model catalog", async () => { - const calls: unknown[] = [] - const api = { - provider: { - list: async (input: unknown) => { - calls.push(["provider", input]) - return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } - }, - }, - model: { - list: async (input: unknown) => { - calls.push(["model", input]) - return { location: {}, data: [] } - }, - default: async (input: unknown) => { - calls.push(["default", input]) - return { location: {}, data: null } - }, - }, - } as unknown as CatalogApi - - const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([ - ["provider", { location: { directory: "/repo" } }], - ["model", { location: { directory: "/repo" } }], - ["default", { location: { directory: "/repo" } }], - ]) - expect(result.connected).toEqual(["openai"]) }) test("loads current location metadata", async () => { @@ -73,39 +32,6 @@ describe("query keys", () => { expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" }) }) - test("loads agents from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { location: {}, data: [] } - }, - } as unknown as AgentApi - - const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toEqual([]) - }) - - test("loads commands from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { - location: {}, - data: [{ name: "review", template: "Review files" /* source: "command" as const */ }], - } - }, - } as unknown as CommandApi - - const result = await loadCommands("/repo", api) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }]) - }) - test("loads projects from the current endpoint", async () => { const calls: string[] = [] const projects = { @@ -161,18 +87,4 @@ describe("query keys", () => { ]) }) - test("loads references from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } - }, - } as unknown as ReferenceApi - - const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toHaveLength(1) - }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ac7a560bff..b7cf1b0435 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,46 +1,26 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type { - AgentListInput, - AgentListOutput, - CatalogApi, - CommandInfo, - CommandListInput, - CommandListOutput, - IntegrationListInput, - IntegrationListOutput, LocationGetInput, LocationGetOutput, - PermissionRequest, ProjectCurrentInput, ProjectCurrentOutput, ProjectListOutput, - ReferenceListInput, - ReferenceListOutput, - ReferenceInfo, - SessionApi, - SessionInfo, } from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" -import { batch } from "solid-js" -import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" +import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State } from "./types" -import type { ServerSession } from "../server-session" -import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" +import { cmp, normalizeProjectInfo } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" -import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import type { ServerScope } from "@/utils/server-scope" import type { ServerApi } from "@/utils/server" import { sameDirectory } from "@/utils/workspace" type GlobalStore = { - ready: boolean path: Path project: Project[] - provider: NormalizedProviderListResponse provider_auth: ProviderAuthResponse config: Config reload: undefined | "pending" | "complete" @@ -69,12 +49,6 @@ function errors(list: PromiseSettledResult[]) { return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) } -const providerRev = new Map() - -export function clearProviderRev(scope: ServerScope, directory: string) { - providerRev.delete(ScopedKey.from(scope, directory)) -} - function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } @@ -109,10 +83,6 @@ type ProjectApi = { type WorktreeApi = Pick type LocationApi = { readonly get: (input?: LocationGetInput) => Promise } -type McpApi = ServerApi["mcp"] -type PermissionApi = ServerApi["permission"] -type VcsApi = ServerApi["vcs"] - export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) => queryOptions({ queryKey: [scope, "project"], @@ -150,7 +120,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work }) export async function bootstrapGlobal(input: { - serverAPI: CatalogApi & { + serverAPI: { readonly location: LocationApi readonly project: ProjectApi readonly worktree: WorktreeApi @@ -164,7 +134,6 @@ export async function bootstrapGlobal(input: { }) { const slow = [ () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), - () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient @@ -180,97 +149,10 @@ export async function bootstrapGlobal(input: { // }) } -function groupBySession(input: T[]) { - return input.reduce>((acc, item) => { - if (!item?.id || !item.sessionID) return acc - const list = acc[item.sessionID] - if (list) list.push(item) - if (!list) acc[item.sessionID] = [item] - return acc - }, {}) -} - function projectID(directory: string, projects: Project[]) { return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id } -function mergeSession(setStore: SetStoreFunction, session: SessionInfo) { - setStore("session", (list) => { - const next = list.slice() - const idx = next.findIndex((item) => item.id >= session.id) - if (idx === -1) return [...next, session] - if (next[idx]?.id === session.id) { - next[idx] = session - return next - } - next.splice(idx, 0, session) - return next - }) -} - -function warmSessions(input: { - ids: string[] - store: Store - setStore: SetStoreFunction - api: SessionApi -}) { - const known = new Set(input.store.session.map((item) => item.id)) - const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) - if (ids.length === 0) return Promise.resolve() - return Promise.all( - ids.map((sessionID) => - retry(() => input.api.get({ sessionID })).then((session) => mergeSession(input.setStore, session)), - ), - ).then(() => undefined) -} - -export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) => - queryOptions({ - queryKey: [scope, directory, "providers"], - queryFn: () => - retry(async () => { - const location = directory ? { location: { directory } } : undefined - const [providers, models, defaultModel] = await Promise.all([ - sdk.provider.list(location), - sdk.model.list(location), - sdk.model.default(location), - ]) - return normalizeProviderList(providers.data, models.data, defaultModel.data) - }), - }) - -type AgentListApi = { - readonly list: (input?: AgentListInput) => Promise -} - -type CommandListApi = { - readonly list: (input?: CommandListInput) => Promise -} - -type IntegrationListApi = { - readonly list: (input?: IntegrationListInput) => Promise -} - -type ReferenceListApi = { - readonly list: (input?: ReferenceListInput) => Promise -} - -export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) => - queryOptions({ - queryKey: [scope, directory, "agents"], - queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), - }) - -export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) => - queryOptions({ - queryKey: [scope, directory, "integrations"] as const, - queryFn: () => - retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)), - }) - -export const loadCommands = (directory: string, api: CommandListApi): Promise => - retry(() => api.list({ location: { directory } }).then((result) => result.data)) - export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) => queryOptions({ queryKey: [scope, directory, "path"], @@ -284,130 +166,37 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api: })), }) -export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) => - queryOptions({ - queryKey: [scope, directory, "references"] as const, - queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []), - placeholderData: [], - }) - export async function bootstrapDirectory(input: { directory: string scope: ServerScope mcp: boolean - api: CatalogApi & { - readonly agent: AgentListApi - readonly command: CommandListApi - readonly mcp: McpApi - readonly permission: PermissionApi + api: { readonly project: ProjectApi - readonly reference: ReferenceListApi - readonly session: SessionApi - readonly vcs: VcsApi - readonly location: LocationApi } store: Store setStore: SetStoreFunction - loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string global: { config: Config path: Path project: Project[] - provider: NormalizedProviderListResponse } queryClient: QueryClient - session?: ServerSession }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) - const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined if (seededProject) input.setStore("project", seededProject) - if (seededPath) input.setStore("path", seededPath) if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } if (loading) input.setStore("status", "partial") - const revKey = ScopedKey.from(input.scope, input.directory) - const rev = (providerRev.get(revKey) ?? 0) + 1 - providerRev.set(revKey, rev) const slow = [ - () => Promise.resolve(input.loadSessions(input.directory)), - () => - input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent)) - .then((data) => input.setStore("agent", data)), !seededProject && (() => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => input.setStore("project", project.id), )), - !seededPath && - (() => - input.queryClient - .ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location)) - .then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), - input.mcp && - (() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), - () => - input.queryClient.fetchQuery( - loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference), - ), - () => - retry(() => - input.api.permission.request - .list({ location: { directory: input.directory } }) - .then((result) => result.data) - .then((permissions) => { - const ids = permissions.map((permission) => permission.sessionID) - const grouped = groupBySession( - permissions.filter((permission) => !!permission.id && !!permission.sessionID), - ) - const warm = input.session - ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) - return warm.then(() => - batch(() => { - const current = input.session?.data.permission ?? input.store.permission - for (const sessionID of Object.keys(current)) { - if (grouped[sessionID]) continue - if (input.session?.get(sessionID)?.location.directory !== input.directory) continue - if (input.session) input.session.set("permission", sessionID, []) - if (!input.session) input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - const value = reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ) - if (input.session) input.session.set("permission", sessionID, value) - if (!input.session) input.setStore("permission", sessionID, value) - } - }), - ) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && - (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))), - input.mcp && - (() => - input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))), - () => - input.queryClient - .fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api)) - .catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 30a822cdbb..623ce20dac 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -1,14 +1,13 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" import { createRoot, getOwner, type Owner } from "solid-js" import { createStore } from "solid-js/store" -import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { State } from "./types" import type { QueryOptionsApi } from "../server-sync" import { ServerScope } from "@/utils/server-scope" +import type { Data } from "@opencode-ai/client/solid" let createChildStoreManager: typeof import("./child-store").createChildStoreManager const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = [] -let providerQuerySuccess = true const persist: typeof import("@/utils/persist").persisted = (_target, store) => [ store[0], store[1], @@ -17,12 +16,26 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) => ] const child = () => createStore({} as State) -const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const path = { state: "", config: "", worktree: "", directory: "", home: "" } +const data = { + location: { + info: () => undefined, + agent: { list: () => undefined }, + command: { list: () => undefined }, + reference: { list: () => undefined }, + provider: { list: () => undefined }, + model: { list: () => undefined, default: () => undefined }, + mcp: { + server: { list: () => undefined }, + resource: { list: () => undefined }, + }, + vcs: { info: () => undefined }, + }, +} as unknown as Data const queryOptionsApi = { globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }), projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }), - providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }), path: (directory: string | null) => ({ queryKey: [directory, "path"], queryFn: async () => ({ @@ -60,7 +73,7 @@ beforeAll(async () => { return options().queryKey?.[1] === "path" }, get isSuccess() { - return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess + return false }, get isRefetchError() { return false @@ -69,7 +82,6 @@ beforeAll(async () => { if (options().queryKey?.[1] === "path") throw new Error("pending path data read") if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined if (options().queryKey?.[1] === "lsp") return [] - if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined return undefined }, } @@ -100,7 +112,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { @@ -134,7 +147,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -144,7 +158,6 @@ describe("createChildStoreManager", () => { const [store] = manager.child("/project") expect(store.status).toBe("loading") - expect(store.limit).toBe(5) expect(bootstraps).toEqual(["/project"]) } finally { dispose() @@ -167,7 +180,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -198,7 +212,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -214,7 +229,7 @@ describe("createChildStoreManager", () => { } }) - test("enables MCP only when requested for the directory", () => { + test("syncs MCP only when requested for the directory", () => { let manager: ReturnType | undefined const offset = querySingles.length const mcpLoads: string[] = [] @@ -234,30 +249,21 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) try { if (!manager) throw new Error("manager required") - const [store, setStore] = manager.child("/project", { bootstrap: false }) - expect(querySingles.length - offset).toBe(6) - const query = querySingles[offset + 1] - const resourceQuery = querySingles[offset + 2] - if (!query) throw new Error("query required") - if (!resourceQuery) throw new Error("resource query required") - expect(query().enabled).toBe(false) - expect(resourceQuery().enabled).toBe(false) + const [, setStore] = manager.child("/project", { bootstrap: false }) + expect(querySingles.length - offset).toBe(1) setStore("status", "complete") manager.child("/project", { bootstrap: false, mcp: true }) - expect(query().enabled).toBe(true) - expect(resourceQuery().enabled).toBe(true) - expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(mcpLoads).toEqual(["/project"]) manager.disableMcp("/project") - expect(query().enabled).toBe(false) expect(manager.mcp("/project")).toBe(false) } finally { dispose() @@ -284,7 +290,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -293,11 +300,8 @@ describe("createChildStoreManager", () => { const [store] = manager.child("/project", { bootstrap: false }) const queries = querySingles.slice(offset) - expect(queries).toHaveLength(6) + expect(queries).toHaveLength(1) expect(queries[0]?.().enabled).toBe(false) - expect(queries[3]?.().enabled).toBe(false) - expect(queries[4]?.().enabled).toBe(false) - expect(queries[5]?.().enabled).toBe(false) expect(store.path.directory).toBe("/project") expect(store.provider_ready).toBe(false) expect(store.lsp_ready).toBe(false) @@ -305,9 +309,6 @@ describe("createChildStoreManager", () => { manager.child("/project") expect(queries[0]?.().enabled).toBe(true) - expect(queries[3]?.().enabled).toBe(true) - expect(queries[4]?.().enabled).toBe(true) - expect(queries[5]?.().enabled).toBe(true) expect(bootstraps).toEqual(["/project"]) manager.child("/project", { bootstrap: false }) @@ -317,9 +318,8 @@ describe("createChildStoreManager", () => { } }) - test("does not mark a cancelled provider query as ready", () => { + test("does not mark unsynced provider data as ready", () => { let manager: ReturnType | undefined - providerQuerySuccess = false const dispose = createOwner((owner) => { manager = createChildStoreManager({ @@ -334,7 +334,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -343,7 +344,6 @@ describe("createChildStoreManager", () => { const [store] = manager.child("/cancelled") expect(store.provider_ready).toBe(false) } finally { - providerQuerySuccess = true dispose() } }) @@ -365,7 +365,8 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, queryOptions: queryOptionsApi, - global: { provider }, + data, + global: { path }, }) }) @@ -374,11 +375,9 @@ describe("createChildStoreManager", () => { manager.child("/handshake") const queries = querySingles.slice(offset) expect(queries[0]?.().enabled).toBe(false) - expect(queries[4]?.().enabled).toBe(false) connected = true expect(queries[0]?.().enabled).toBe(true) - expect(queries[4]?.().enabled).toBe(true) } finally { dispose() } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index cdfb0509d6..78f56bde34 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { VcsInfo } from "@/types" +import type { Path, VcsInfo } from "@/types" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQuery } from "@tanstack/solid-query" import { QueryOptionsApi } from "../server-sync" import { directoryKey, type DirectoryKey } from "./utils" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { ServerScope } from "@/utils/server-scope" +import type { Data } from "@opencode-ai/client/solid" +import { normalizeAgentList, normalizeProviderList } from "./utils" export function createChildStoreManager(input: { owner: Owner @@ -32,8 +33,9 @@ export function createChildStoreManager(input: { onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string queryOptions: QueryOptionsApi + data: Data global: { - provider: NormalizedProviderListResponse + path: Path } }) { const children: Record, SetStoreFunction]> = {} @@ -45,7 +47,6 @@ export function createChildStoreManager(input: { const ownerPins = new WeakMap>() const disposers = new Map void>() const mcpDirectories = new Set() - const mcpToggles = new Map void>() const activeDirectories = new Set() const activationToggles = new Map void>() @@ -120,7 +121,6 @@ export function createChildStoreManager(input: { iconCache.delete(key) lifecycle.delete(key) mcpDirectories.delete(key) - mcpToggles.delete(key) activeDirectories.delete(key) activationToggles.delete(key) const dispose = disposers.get(key) @@ -186,78 +186,63 @@ export function createChildStoreManager(input: { createRoot((dispose) => { const initialMeta = meta[0].value const initialIcon = icon[0].value - const [mcpEnabled, setMcpEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) - - const pathQuery = useQuery(() => ({ - ...input.queryOptions.path(key), - enabled: input.connected() && instanceQueriesEnabled(), - })) - const mcpQuery = useQuery(() => ({ - ...input.queryOptions.mcp(key), - enabled: input.connected() && mcpEnabled(), - })) - const mcpResourceQuery = useQuery(() => ({ - ...input.queryOptions.mcpResources(key), - enabled: input.connected() && mcpEnabled(), - })) const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: input.connected() && instanceQueriesEnabled(), })) - const providerQuery = useQuery(() => ({ - ...input.queryOptions.providers(key), - enabled: input.connected() && instanceQueriesEnabled(), - })) - const referenceQuery = useQuery(() => ({ - ...input.queryOptions.references(key), - enabled: input.connected() && instanceQueriesEnabled(), - })) - const child = createStore({ project: "", projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError) + return ( + input.data.location.provider.list({ directory }) !== undefined && + input.data.location.model.list({ directory }) !== undefined + ) }, get provider() { - const EMPTY = { all: new Map(), connected: [], default: {} } - if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY - const provider = providerQuery.data - if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider - return provider + const provider = input.data.location.provider.list({ directory }) + const model = input.data.location.model.list({ directory }) + if (!provider || !model) return { all: new Map(), connected: [], default: {} } + return normalizeProviderList(provider, model) }, config: {}, get path() { - const EMPTY = { state: "", config: "", worktree: "", directory, home: "" } - if (pathQuery.isLoading) return EMPTY - return pathQuery.data ?? EMPTY + const location = input.data.location.info({ directory }) + return { + state: "", + config: "", + worktree: location?.project.directory ?? "", + directory: location?.directory ?? directory, + home: input.global.path.home, + } }, status: "loading" as const, - agent: [], - command: [], + get agent() { + return normalizeAgentList(input.data.location.agent.list({ directory }) ?? []) + }, + get command() { + return input.data.location.command.list({ directory }) ?? [] + }, get reference() { - return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) + return input.data.location.reference.list({ directory }) ?? [] }, - session: [], - sessionTotal: 0, - session_status: {}, - session_working(id: string) { - const type = this.session_status[id]?.type - return (type ?? "idle") !== "idle" - }, - session_diff: {}, - todo: {}, - permission: {}, get mcp_ready() { - return !mcpQuery.isLoading + return input.data.location.mcp.server.list({ directory }) !== undefined }, get mcp() { - return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) + return Object.fromEntries( + (input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]), + ) }, get mcp_resource() { - return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) + return Object.fromEntries( + (input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [ + `${resource.server}:${resource.uri}`, + resource, + ]), + ) }, get lsp_ready() { return instanceQueriesEnabled() && !lspQuery.isLoading @@ -265,16 +250,14 @@ export function createChildStoreManager(input: { get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) }, - vcs: vcsStore.value, - limit: 5, - message: {}, - session_message: {}, - part: {}, - part_text_accum_delta: {}, + get vcs() { + const vcs = input.data.location.vcs.info({ directory }) + if (!vcs) return vcsStore.value + return { branch: vcs.branch.current, default_branch: vcs.branch.default } + }, }) children[key] = child disposers.set(key, dispose) - mcpToggles.set(key, setMcpEnabled) activationToggles.set(key, setInstanceQueriesEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { @@ -338,14 +321,11 @@ export function createChildStoreManager(input: { function enableMcp(directory: string, key: DirectoryKey, childStore: [Store, SetStoreFunction]) { if (mcpDirectories.has(key)) return mcpDirectories.add(key) - mcpToggles.get(key)?.(true) if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) } - // Passive Home/project metadata reads must not initialize the directory. + // Passive project metadata reads must not initialize the directory. // A real directory access enables these queries once for the store lifetime. - // TODO(v2): After Home switches to v2.project.list and root-filtered, - // updated-time v2.session.list, remove any Home-only passive child creation. function activate(key: DirectoryKey) { if (activeDirectories.has(key)) return activeDirectories.add(key) @@ -355,7 +335,6 @@ export function createChildStoreManager(input: { function disableMcp(directory: string) { const key = directoryKey(directory) if (!mcpDirectories.delete(key)) return - mcpToggles.get(key)?.(false) } function projectMeta(directory: string, patch: ProjectMeta) { diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index a0865e57e8..5ea4c4ea8a 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,566 +1,61 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, Project } from "@/types" -import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" import { createStore } from "solid-js/store" +import type { Project } from "@/types" import type { State } from "./types" -import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" - -const rootSession = (input: { id: string; parentID?: string; archived?: number }) => - ({ - id: input.id, - parentID: input.parentID, - time: { - created: 1, - updated: 1, - archived: input.archived, - }, - }) as SessionInfo - -const userMessage = (id: string, sessionID: string, created = 1) => - ({ - id, - sessionID, - role: "user", - time: { created }, - agent: "assistant", - model: { providerID: "openai", modelID: "gpt" }, - }) as Message - -const textPart = (id: string, sessionID: string, messageID: string) => - ({ - id, - sessionID, - messageID, - type: "text", - text: id, - }) as Part - -const permissionRequest = (id: string, sessionID: string, title = id) => - ({ - id, - sessionID, - action: title, - resources: ["*"], - metadata: {}, - save: [], - }) as PermissionRequest - -const baseState = (input: Partial = {}) => - ({ - status: "complete", - agent: [], - command: [], - project: "", - projectMeta: undefined, - icon: undefined, - provider: {} as State["provider"], - config: {} as State["config"], - path: { directory: "/tmp" } as State["path"], - session: [], - sessionTotal: 0, - session_status: {}, - session_diff: {}, - todo: {}, - permission: {}, - mcp: {}, - lsp: [], - vcs: undefined, - limit: 10, - message: {}, - session_message: {}, - part: {}, - part_text_accum_delta: {}, - ...input, - }) as State +import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" describe("applyGlobalEvent", () => { test("upserts project.updated in sorted position", () => { - const project = [{ id: "a" }, { id: "c" }] as Project[] - let refreshCount = 0 + const projects = [{ id: "b", worktree: "/b" }] as Project[] + let next = projects applyGlobalEvent({ - event: { type: "project.updated", properties: { id: "b" } }, - project, - refresh: () => { - refreshCount += 1 - }, - setGlobalProject(next) { - if (typeof next === "function") next(project) + event: { type: "project.updated", properties: { id: "a", worktree: "/a" } }, + project: projects, + setGlobalProject: (value) => { + next = typeof value === "function" ? value(next) : value }, + refresh() {}, }) - - expect(project.map((x) => x.id)).toEqual(["a", "b", "c"]) - expect(refreshCount).toBe(0) + expect(next.map((project) => project.id)).toEqual(["a", "b"]) }) - test("handles global.disposed by triggering refresh", () => { - let refreshCount = 0 + test("refreshes on global disposal", () => { + let refreshed = false applyGlobalEvent({ event: { type: "global.disposed" }, project: [], - refresh: () => { - refreshCount += 1 - }, setGlobalProject() {}, + refresh: () => (refreshed = true), }) - - expect(refreshCount).toBe(1) - }) - - test("leaves server.connected refresh to the connection sync", () => { - let refreshCount = 0 - applyGlobalEvent({ - event: { type: "server.connected" }, - project: [], - refresh: () => { - refreshCount += 1 - }, - setGlobalProject() {}, - }) - - expect(refreshCount).toBe(0) + expect(refreshed).toBe(true) }) }) describe("applyDirectoryEvent", () => { - test("initializes text delta accumulation from the current part text", () => { - const part = { ...textPart("part", "session", "message"), text: "existing" } - const [store, setStore] = createStore(baseState({ part: { message: [part] } })) - - applyDirectoryEvent({ - event: { - type: "message.part.delta", - properties: { messageID: "message", partID: "part", field: "text", delta: " appended" }, - }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.part_text_accum_delta.part).toBe("existing appended") - expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended") - }) - - test("preserves a Home-specific retained session limit", () => { - const [store, setStore] = createStore( - baseState({ - limit: 1, - session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })], - }), - ) - - applyDirectoryEvent({ - event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - retainedLimit: 3, - }) - - expect(store.session).toHaveLength(3) - }) - - test("inserts root sessions in sorted order and updates sessionTotal", () => { - const [store, setStore] = createStore( - baseState({ - session: [rootSession({ id: "b" })], - sessionTotal: 1, - }), - ) - - applyDirectoryEvent({ - event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.session.map((x) => x.id)).toEqual(["a", "b"]) - expect(store.sessionTotal).toBe(2) - - applyDirectoryEvent({ - event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.sessionTotal).toBe(2) - }) - - test("cleans session caches when archived", () => { - const message = userMessage("msg_1", "ses_1") - const [store, setStore] = createStore( - baseState({ - session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })], - sessionTotal: 2, - message: { ses_1: [message] }, - part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] }, - session_diff: { ses_1: [] }, - todo: { ses_1: [] }, - permission: { ses_1: [] }, - session_status: { ses_1: { type: "busy" } }, - }), - ) - - applyDirectoryEvent({ - event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.session.map((x) => x.id)).toEqual(["ses_2"]) - expect(store.sessionTotal).toBe(1) - expect(store.message.ses_1).toBeUndefined() - expect(store.part[message.id]).toBeUndefined() - expect(store.session_diff.ses_1).toBeUndefined() - expect(store.todo.ses_1).toBeUndefined() - expect(store.permission.ses_1).toBeUndefined() - expect(store.session_status.ses_1).toBeUndefined() - }) - - test("ignores an archived session absent from a passive directory store", () => { - const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 })) - - applyDirectoryEvent({ - event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.session).toEqual([]) - expect(store.sessionTotal).toBe(0) - }) - - test("cleans session caches when deleted and decrements only root totals", () => { - const cases = [ - { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, - { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, - ] - - for (const item of cases) { - const message = userMessage("msg_1", item.info.id) - const [store, setStore] = createStore( - baseState({ - session: [ - rootSession({ id: "ses_1" }), - rootSession({ id: "ses_2", parentID: "ses_1" }), - rootSession({ id: "ses_3" }), - ], - sessionTotal: 2, - message: { [item.info.id]: [message] }, - part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, - session_diff: { [item.info.id]: [] }, - todo: { [item.info.id]: [] }, - permission: { [item.info.id]: [] }, - session_status: { [item.info.id]: { type: "busy" } }, - }), - ) - + test("updates vcs and routes refresh events", () => { + const [store, setStore] = createStore({ vcs: { branch: "old" } } as State) + const pushed: string[] = [] + let lsp = 0 + let references = 0 + const apply = (type: string, properties?: unknown) => applyDirectoryEvent({ - event: { - type: "session.deleted", - properties: item.current ? { sessionID: item.info.id } : { info: item.info }, - }, + event: { type, properties }, store, setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, + directory: "/repo", + push: (directory) => pushed.push(directory), + loadLsp: () => lsp++, + loadReferences: () => references++, }) - expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined() - expect(store.sessionTotal).toBe(item.expectedTotal) - expect(store.message[item.info.id]).toBeUndefined() - expect(store.part[message.id]).toBeUndefined() - expect(store.session_diff[item.info.id]).toBeUndefined() - expect(store.todo[item.info.id]).toBeUndefined() - expect(store.permission[item.info.id]).toBeUndefined() - expect(store.session_status[item.info.id]).toBeUndefined() - } - }) + apply("vcs.branch.updated", { branch: "main" }) + apply("server.instance.disposed") + apply("lsp.updated") + apply("reference.updated") - test("cleans caches for trimmed sessions on session.created", () => { - const dropped = rootSession({ id: "ses_b" }) - const kept = rootSession({ id: "ses_a" }) - const message = userMessage("msg_1", dropped.id) - const todos: string[] = [] - const [store, setStore] = createStore( - baseState({ - limit: 1, - session: [dropped], - message: { [dropped.id]: [message] }, - part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] }, - session_diff: { [dropped.id]: [] }, - todo: { [dropped.id]: [] }, - permission: { [dropped.id]: [] }, - session_status: { [dropped.id]: { type: "busy" } }, - }), - ) - - applyDirectoryEvent({ - event: { type: "session.created", properties: { info: kept } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - setSessionTodo(sessionID, value) { - if (value !== undefined) return - todos.push(sessionID) - }, - }) - - expect(store.session.map((x) => x.id)).toEqual([kept.id]) - expect(store.message[dropped.id]).toBeUndefined() - expect(store.part[message.id]).toBeUndefined() - expect(store.session_diff[dropped.id]).toBeUndefined() - expect(store.todo[dropped.id]).toBeUndefined() - expect(store.permission[dropped.id]).toBeUndefined() - expect(store.session_status[dropped.id]).toBeUndefined() - expect(todos).toEqual([dropped.id]) - }) - - test("cleanupDroppedSessionCaches clears part-only orphan state", () => { - const [store, setStore] = createStore( - baseState({ - session: [rootSession({ id: "ses_keep" })], - part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] }, - }), - ) - - cleanupDroppedSessionCaches(store, setStore, store.session) - - expect(store.part.msg_1).toBeUndefined() - }) - - test("upserts and removes messages while clearing orphaned parts", () => { - const sessionID = "ses_1" - const [store, setStore] = createStore( - baseState({ - message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] }, - part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] }, - }), - ) - - applyDirectoryEvent({ - event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"]) - - applyDirectoryEvent({ - event: { - type: "message.updated", - properties: { - info: { - ...userMessage("msg_a", sessionID, 2), - role: "assistant", - } as Message, - }, - }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant") - - applyDirectoryEvent({ - event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"]) - expect(store.part.msg_a).toBeUndefined() - }) - - test("upserts and prunes message parts", () => { - const sessionID = "ses_1" - const messageID = "msg_1" - const [store, setStore] = createStore( - baseState({ - part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] }, - }), - ) - - applyDirectoryEvent({ - event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"]) - - applyDirectoryEvent({ - event: { - type: "message.part.updated", - properties: { - part: { - ...textPart("prt_2", sessionID, messageID), - text: "changed", - } as Part, - }, - }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - const updated = store.part[messageID]?.find((x) => x.id === "prt_2") - expect(updated?.type).toBe("text") - if (updated?.type === "text") expect(updated.text).toBe("changed") - - applyDirectoryEvent({ - event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - applyDirectoryEvent({ - event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - applyDirectoryEvent({ - event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.part[messageID]).toBeUndefined() - }) - - test("tracks permission request lifecycles", () => { - const sessionID = "ses_1" - const [store, setStore] = createStore( - baseState({ - permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, - }), - ) - - applyDirectoryEvent({ - event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"]) - - applyDirectoryEvent({ - event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.action).toBe("updated") - - applyDirectoryEvent({ - event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) - }) - - test("updates vcs branch in store and cache", () => { - const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } })) - const [cacheStore, setCacheStore] = createStore({ - value: { branch: "main", default_branch: "main" } as State["vcs"], - }) - - applyDirectoryEvent({ - event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - vcsCache: { - store: cacheStore, - setStore: setCacheStore, - ready: () => true, - }, - }) - - expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" }) - expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" }) - }) - - test("routes disposal and lsp events to side-effect handlers", () => { - const [store, setStore] = createStore(baseState()) - const pushes: string[] = [] - let lspLoads = 0 - - applyDirectoryEvent({ - event: { type: "server.instance.disposed" }, - store, - setStore, - push(directory) { - pushes.push(directory) - }, - directory: "/tmp", - loadLsp() { - lspLoads += 1 - }, - }) - - applyDirectoryEvent({ - event: { type: "lsp.updated" }, - store, - setStore, - push(directory) { - pushes.push(directory) - }, - directory: "/tmp", - loadLsp() { - lspLoads += 1 - }, - }) - - expect(pushes).toEqual(["/tmp"]) - expect(lspLoads).toBe(1) + expect(store.vcs?.branch).toBe("main") + expect(pushed).toEqual(["/repo"]) + expect(lsp).toBe(1) + expect(references).toBe(1) }) }) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 95a747cf77..623a1f32cd 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,27 +1,7 @@ import { Binary } from "@opencode-ai/core/util/binary" -import { Worktree } from "@opencode-ai/schema/worktree" -import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import type { Message, Part, Project, Todo } from "@/types" -import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise" +import { produce, type SetStoreFunction, type Store } from "solid-js/store" +import type { Project } from "@/types" import type { State, VcsCache } from "./types" -import { trimSessions } from "./session-trim" -import { dropSessionCaches } from "./session-cache" -import { diffs as list, message as clean } from "@/utils/diffs" -import { messageKey } from "@/utils/session-message" - -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) -const SESSION_CONTENT_EVENTS = new Set([ - "session.diff", - "todo.updated", - "session.status", - "message.updated", - "message.removed", - "message.part.updated", - "message.part.removed", - "message.part.delta", - "permission.asked", - "permission.replied", -]) export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -35,7 +15,7 @@ export function applyGlobalEvent(input: { } if (input.event.type !== "project.updated") return const properties = input.event.properties as Project - const result = Binary.search(input.project, properties.id, (s) => s.id) + const result = Binary.search(input.project, properties.id, (project) => project.id) if (result.found) { input.setGlobalProject( produce((draft) => { @@ -51,48 +31,6 @@ export function applyGlobalEvent(input: { ) } -function cleanupSessionCaches( - setStore: SetStoreFunction, - sessionID: string, - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { - if (!sessionID) return - setSessionTodo?.(sessionID, undefined) - setStore( - produce((draft) => { - dropSessionCaches(draft, [sessionID]) - }), - ) -} - -export function cleanupDroppedSessionCaches( - store: Store, - setStore: SetStoreFunction, - next: SessionInfo[], - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void, -) { - const keep = new Set(next.map((item) => item.id)) - const stale = [ - ...Object.keys(store.message), - ...Object.keys(store.session_diff), - ...Object.keys(store.todo), - ...Object.keys(store.permission), - ...Object.keys(store.session_status), - ...Object.values(store.part) - .map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID) - .filter((sessionID): sessionID is string => !!sessionID), - ].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index) - if (stale.length === 0) return - for (const sessionID of stale) { - setSessionTodo?.(sessionID, undefined) - } - setStore( - produce((draft) => { - dropSessionCaches(draft, stale) - }), - ) -} - export function applyDirectoryEvent(input: { event: { type: string; properties?: unknown } store: Store @@ -102,339 +40,24 @@ export function applyDirectoryEvent(input: { loadLsp: () => void loadReferences?: () => void vcsCache?: VcsCache - setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void - retainedLimit?: number - sessionContent?: boolean - permission?: State["permission"] }) { - const event = input.event - if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return - const limit = Math.max(input.store.limit, input.retainedLimit ?? 0) - switch (event.type) { - case "server.instance.disposed": { + switch (input.event.type) { + case "server.instance.disposed": input.push(input.directory) - return - } - case "session.created": { - const info = (event.properties as { info: SessionInfo }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) - if (result.found) { - input.setStore("session", result.index, reconcile(info)) - break - } - const next = input.store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) - input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) - if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1) break - } - case "session.updated": { - const info = (event.properties as { info: SessionInfo }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) - if (info.time.archived) { - if (!result.found) break - if (input.store.session[result.index]!.time.archived === info.time.archived) break - input.setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break - input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - if (result.found) { - input.setStore("session", result.index, reconcile(info)) - break - } - const next = input.store.session.slice() - next.splice(result.index, 0, info) - const trimmed = trimSessions(next, { limit, permission: input.permission ?? input.store.permission }) - input.setStore("session", reconcile(trimmed, { key: "id" })) - cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo) - break - } - case "session.deleted": { - const properties = event.properties as { sessionID?: string; info?: SessionInfo } - const sessionID = properties.info?.id ?? properties.sessionID - if (!sessionID) break - const result = Binary.search(input.store.session, sessionID, (s) => s.id) - const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) - if (result.found) { - input.setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - } - cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) - if (info?.parentID) break - input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - case "worktree.resolved": { - const properties = event.properties as { projectID: string; directory: string; previous: string } - input.store.session.forEach((session, index) => { - const adopted = Worktree.adopt( - { projectID: session.projectID, directory: session.location.directory }, - properties, - ) - if (!adopted) return - input.setStore("session", index, (current) => ({ ...current, ...adopted })) - }) - break - } - case "session.renamed": { - const properties = event.properties as { sessionID: string; title: string } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - input.setStore("session", result.index, (session) => ({ - ...session, - title: properties.title, - time: { ...session.time, updated: Date.now() }, - })) - break - } - case "session.usage.updated": { - const properties = event.properties as Pick & { sessionID: string } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - input.setStore("session", result.index, (session) => ({ - ...session, - cost: properties.cost, - tokens: properties.tokens, - })) - break - } - // case "session.archived": { - // const properties = event.properties as { sessionID: string } - // const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - // if (!result.found) break - // const info = input.store.session[result.index] - // input.setStore( - // "session", - // produce((draft) => void draft.splice(result.index, 1)), - // ) - // cleanupSessionCaches(input.setStore, properties.sessionID) - // if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - // break - // } - case "session.moved": { - const properties = event.properties as { - sessionID: string - location: { directory: string; workspaceID?: string } - projectID?: string - subpath?: string - } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - if (properties.location.directory === input.directory) { - input.setStore("session", result.index, (session) => ({ - ...session, - projectID: properties.projectID ?? session.projectID, - location: properties.location, - subpath: properties.subpath, - time: { ...session.time, updated: Date.now() }, - })) - break - } - const info = input.store.session[result.index] - input.setStore( - "session", - produce((draft) => void draft.splice(result.index, 1)), - ) - if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } - case "session.diff": { - const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) - break - } - case "todo.updated": { - const props = event.properties as { sessionID: string; todos: Todo[] } - input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - input.setSessionTodo?.(props.sessionID, props.todos) - break - } - case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } - input.setStore("session_status", props.sessionID, reconcile(props.status)) - break - } - case "message.updated": { - const info = clean((event.properties as { info: Message }).info) - const messages = input.store.message[info.sessionID] - if (!messages) { - input.setStore("message", info.sessionID, [info]) - break - } - const result = Binary.search(messages, messageKey(info), messageKey) - if (result.found) { - input.setStore("message", info.sessionID, result.index, reconcile(info)) - break - } - input.setStore( - "message", - info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, info) - }), - ) - break - } - case "message.removed": { - const props = event.properties as { sessionID: string; messageID: string } - input.setStore( - produce((draft) => { - const messages = draft.message[props.sessionID] - if (messages) { - const index = messages.findIndex((message) => message.id === props.messageID) - if (index >= 0) messages.splice(index, 1) - } - const parts = draft.part[props.messageID] - if (parts) { - for (const part of parts) { - delete draft.part_text_accum_delta[part.id] - } - } - delete draft.part[props.messageID] - }), - ) - break - } - case "message.part.updated": { - const part = (event.properties as { part: Part }).part - if (SKIP_PARTS.has(part.type)) break - input.setStore( - produce((draft) => { - delete draft.part_text_accum_delta[part.id] - }), - ) - const parts = input.store.part[part.messageID] - if (!parts) { - input.setStore("part", part.messageID, [part]) - break - } - const result = Binary.search(parts, part.id, (item) => item.id) - if (result.found) { - input.setStore("part", part.messageID, result.index, reconcile(part)) - break - } - input.setStore( - "part", - part.messageID, - produce((draft) => { - draft.splice(result.index, 0, part) - }), - ) - break - } - case "message.part.removed": { - const props = event.properties as { messageID: string; partID: string } - input.setStore( - produce((draft) => { - delete draft.part_text_accum_delta[props.partID] - }), - ) - const parts = input.store.part[props.messageID] - if (!parts) break - const result = Binary.search(parts, props.partID, (part) => part.id) - if (result.found) { - input.setStore( - produce((draft) => { - const list = draft.part[props.messageID] - if (!list) return - const next = Binary.search(list, props.partID, (part) => part.id) - if (!next.found) return - list.splice(next.index, 1) - if (list.length === 0) delete draft.part[props.messageID] - }), - ) - } - break - } - case "message.part.delta": { - const props = event.properties as { messageID: string; partID: string; field: string; delta: string } - const parts = input.store.part[props.messageID] - if (!parts) break - const result = Binary.search(parts, props.partID, (part) => part.id) - if (!result.found) break - const field = props.field as keyof (typeof parts)[number] - const current = parts[result.index]?.[field] - input.setStore( - "part_text_accum_delta", - props.partID, - (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, - ) - input.setStore( - "part", - props.messageID, - produce((draft) => { - const part = draft[result.index] - const field = props.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + props.delta - }), - ) - break - } case "vcs.branch.updated": { - const props = event.properties as { branch?: string } - if (input.store.vcs?.branch === props.branch) break - const next = { ...input.store.vcs, branch: props.branch } + const properties = input.event.properties as { branch?: string } + if (input.store.vcs?.branch === properties.branch) break + const next = { ...input.store.vcs, branch: properties.branch } input.setStore("vcs", next) - if (input.vcsCache) input.vcsCache.setStore("value", next) + input.vcsCache?.setStore("value", next) break } - case "permission.asked": { - const permission = event.properties as PermissionRequest - const permissions = input.store.permission[permission.sessionID] - if (!permissions) { - input.setStore("permission", permission.sessionID, [permission]) - break - } - const result = Binary.search(permissions, permission.id, (p) => p.id) - if (result.found) { - input.setStore("permission", permission.sessionID, result.index, reconcile(permission)) - break - } - input.setStore( - "permission", - permission.sessionID, - produce((draft) => { - draft.splice(result.index, 0, permission) - }), - ) - break - } - case "permission.replied": { - const props = event.properties as { sessionID: string; requestID: string } - const permissions = input.store.permission[props.sessionID] - if (!permissions) break - const result = Binary.search(permissions, props.requestID, (p) => p.id) - if (!result.found) break - input.setStore( - "permission", - props.sessionID, - produce((draft) => { - draft.splice(result.index, 1) - }), - ) - break - } - case "lsp.updated": { + case "lsp.updated": input.loadLsp() break - } - case "reference.updated": { + case "reference.updated": input.loadReferences?.() break - } } } diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts index 42a88b1af1..b95352eb87 100644 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ b/packages/app/src/context/global-sync/home-session-index.test.ts @@ -1,146 +1,53 @@ import { describe, expect, test } from "bun:test" import type { SessionInfo } from "@opencode-ai/client/promise" import { - applyHomeSessionEvent, - appendHomeSessionEvent, HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, - homeSessionIndexSessions, - homeSessionIndexRefresh, parseHomeSessionIndex, retainHomeSessions, } from "./home-session-index" -const session = (input: { - id: string - directory?: string - parentID?: string - archived?: number - updated?: number -}) => ({ - id: input.id, - parentID: input.parentID, - projectID: "project", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: input.updated ?? 1, archived: input.archived }, - title: input.id, - location: { directory: input.directory ?? "/project" }, -}) +const session = (id: string, input: Partial = {}) => + ({ + id, + projectID: "project", + title: id, + time: { created: 1, updated: 1 }, + location: { directory: "/repo" }, + ...input, + }) as SessionInfo describe("Home V2 session index", () => { - test("loads the Home index with one global V2 request", async () => { - const calls: unknown[] = [] + test("loads all pages", async () => { + const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`)) + const calls: Array<{ cursor?: string }> = [] const result = await loadHomeSessionIndex(async (input) => { calls.push(input) - return { data: [session({ id: "root" })], cursor: {} } + if (!input.cursor) return { data: first, cursor: { next: "next" } } + return { data: [session("last")], cursor: {} } }) - expect(result.sessions).toHaveLength(1) - expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) + expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) + expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"]) }) - test("loads subsequent pages until the session index is complete", async () => { - const calls: unknown[] = [] - const controller = new AbortController() - const result = await loadHomeSessionIndex( - async (input, options) => { - calls.push({ input, signal: options.signal }) - if (!("cursor" in input)) { - return { - data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session({ id: `page-1-${index}` })), - cursor: { next: "next-page" }, - } - } - return { data: [session({ id: "page-2" })], cursor: {} } - }, - 0, - controller.signal, - ) - - expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) - expect(calls).toEqual([ - { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal }, - { - input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, - signal: controller.signal, - }, - ]) - }) - - test("maps visible roots to Home session summaries", () => { - const activeNull = { - ...session({ id: "active-null", updated: 20 }), - time: { created: 1, updated: 20, archived: null }, - } as unknown as SessionInfo - const result = parseHomeSessionIndex([ - session({ id: "root", updated: 30 }), - activeNull, - session({ id: "child", parentID: "root", updated: 40 }), - session({ id: "archived", archived: 50, updated: 50 }), - ]) - - expect(result).toEqual([ - expect.objectContaining({ - id: "root", - location: { directory: "/project" }, - projectID: "project", - title: "root", - time: { created: 1, updated: 30 }, - }), - expect.objectContaining({ - id: "active-null", - time: { created: 1, updated: 20, archived: null }, - }), - ]) - }) - - test("preserves the per-directory Home retention limit", () => { - const now = 10 * 60 * 60 * 1000 - const sessions = Array.from({ length: 80 }, (_, index) => ({ - ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], - location: { directory: index % 2 === 0 ? "/one" : "/two" }, - })) - - const retained = retainHomeSessions(sessions, 10, now) - expect(retained.filter((item) => item.location.directory === "/one")).toHaveLength(10) - expect(retained.filter((item) => item.location.directory === "/two")).toHaveLength(10) - }) - - test("replays session events over the loaded index", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const created = { ...initial[0], id: "new", title: "new", time: { created: 2, updated: 2 } } - - const afterCreate = applyHomeSessionEvent(initial, { - type: "session.created", - properties: { sessionID: created.id, info: created }, - }) + test("keeps only visible roots", () => { expect( - applyHomeSessionEvent(afterCreate, { - type: "session.deleted", - properties: { sessionID: initial[0]!.id, info: initial[0]! }, - }), - ).toEqual([created]) + parseHomeSessionIndex([ + session("root"), + session("child", { parentID: "root" }), + session("archived", { time: { created: 1, updated: 1, archived: 2 } }), + ]).map((item) => item.id), + ).toEqual(["root"]) }) - test("applies only events newer than the index baseline", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const stale = { ...initial[0], title: "stale" } - const current = { ...initial[0], title: "current" } - const first = appendHomeSessionEvent(undefined, { - type: "session.updated", - properties: { sessionID: stale.id, info: stale }, - }) - const events = appendHomeSessionEvent(first, { - type: "session.updated", - properties: { sessionID: current.id, info: current }, - }) - - expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") - }) - - test("refetches after reconnect", () => { - expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) - expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) + test("preserves the per-directory retention limit", () => { + const now = Date.now() + const result = retainHomeSessions( + [session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })], + 1, + now, + ) + expect(result.map((item) => item.id)).toEqual(["b"]) }) }) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 1f6bc95f7d..611c0437e4 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,33 +1,16 @@ -import type { Event } from "@/types" import type { SessionInfo, SessionsResponse } from "@opencode-ai/client/promise" -import type { QueryClient } from "@tanstack/solid-query" -import { trimSessions } from "./session-trim" import { pathKey } from "@/utils/path-key" +import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 -export type HomeSessionEvent = { - type: "session.created" | "session.updated" | "session.deleted" - properties: { sessionID: string; info?: SessionInfo } -} -export type HomeSessionEvents = { - sequence: number - entries: Array<{ sequence: number; event: HomeSessionEvent }> -} -export type HomeSessionIndex = { - sessions: SessionInfo[] - eventSequence: number -} - export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const -export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const export async function loadHomeSessionIndex( list: ( input: { limit: number; order: "desc"; cursor?: string }, options: { signal?: AbortSignal }, ) => Promise, - eventSequence = 0, signal?: AbortSignal, ) { const data: SessionInfo[] = [] @@ -42,115 +25,28 @@ export async function loadHomeSessionIndex( }, { signal }, ) - const page = response - data.push(...page.data) - if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) - return { sessions: parseHomeSessionIndex(data), eventSequence } - cursor = page.cursor.next + data.push(...response.data) + if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data) + cursor = response.cursor.next } } -export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { - const sequence = (current?.sequence ?? 0) + 1 - return { - sequence, - entries: [...(current?.entries ?? []), { sequence, event }], - } -} - -export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { - return { - sequence: current?.sequence ?? sequence, - entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), - } -} - -export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - if (!index) return [] - return (events?.entries ?? []) - .filter((entry) => entry.sequence > index.eventSequence) - .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) -} - -export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { - if (event === "server.connected") return { connected: true, refetch: connected } - return { connected, refetch: false } -} - -export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { - const indexKey = homeSessionIndexKey(server) - const eventsKey = homeSessionEventsKey(server) - let connected = false - - return { - indexKey, - eventsKey, - eventSequence() { - return queryClient.getQueryData(eventsKey)?.sequence ?? 0 - }, - complete(sequence: number) { - // Keep events received after the fetch began so its response cannot overwrite them. - queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) - }, - sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - return homeSessionIndexSessions(index, events) - }, - apply(event: HomeSessionEvent) { - if (!queryClient.getQueryState(indexKey)) return - const next = appendHomeSessionEvent(queryClient.getQueryData(eventsKey), event) - if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { - queryClient.setQueryData(eventsKey, next) - return - } - - const index = queryClient.getQueryData(indexKey) - if (index) { - queryClient.setQueryData(indexKey, { - sessions: homeSessionIndexSessions(index, next), - eventSequence: next.sequence, - }) - } - queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) - }, - refresh(event: Event["type"]) { - const result = homeSessionIndexRefresh(event, connected) - connected = result.connected - if (!result.refetch) return - void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" }) - }, - } -} - -// TODO(v2): This deliberately dumb full-table scan is necessary because the -// current V2 API orders by creation time and cannot filter roots, archives, or -// multiple directories. A bounded page could omit an old session updated today. -// Once released, use client.v2.project.list() and client.v2.session.list({ -// parentID: null, order: "desc" }) and replace this full-table scan. -export function parseHomeSessionIndex(sessions: SessionInfo[]): SessionInfo[] { - return sessions.flatMap((item) => { - if (item.parentID || typeof item.time.archived === "number") return [] - return [item] - }) +// The V2 API cannot yet filter roots, archives, or several directories, so Home +// seeds createData from a full scan and derives its visible index there. +export function parseHomeSessionIndex(sessions: SessionInfo[]) { + return sessions.filter((session) => !session.parentID && typeof session.time.archived !== "number") } export function retainHomeSessions(sessions: SessionInfo[], limit: number, now: number) { - const grouped = Map.groupBy(sessions, (session) => pathKey(session.location.directory)) - return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) -} - -export function applyHomeSessionEvent(sessions: SessionInfo[], event: HomeSessionEvent) { - const info = event.properties.info - const index = sessions.findIndex((session) => session.id === (info?.id ?? event.properties.sessionID)) - if (event.type === "session.deleted") { - if (index === -1) return sessions - return sessions.toSpliced(index, 1) - } - if (!info) return sessions - if (info.parentID || typeof info.time.archived === "number") { - if (index === -1) return sessions - return sessions.toSpliced(index, 1) - } - if (event.type !== "session.created" && event.type !== "session.updated") return sessions - if (index === -1) return [...sessions, info] - return sessions.with(index, info) + return [...Map.groupBy(sessions, (session) => pathKey(session.location.directory)).values()].flatMap((items) => { + const sorted = items.toSorted((a, b) => { + const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + return updated || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) + }) + const recent = sorted + .slice(limit) + .filter((session) => (session.time.updated ?? session.time.created) > now - SESSION_RECENT_WINDOW) + .slice(0, SESSION_RECENT_LIMIT) + return [...sorted.slice(0, limit), ...recent] + }) } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts deleted file mode 100644 index 20bdc1eb1e..0000000000 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Message, Part, Todo } from "@/types" -import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise" -import type { FileDiffInfo } from "@opencode-ai/client/promise" -import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" - -const msg = (id: string, sessionID: string) => - ({ - id, - sessionID, - role: "user", - time: { created: 1 }, - agent: "assistant", - model: { providerID: "openai", modelID: "gpt" }, - }) as Message - -const part = (id: string, sessionID: string, messageID: string) => - ({ - id, - sessionID, - messageID, - type: "text", - text: id, - }) as Part - -describe("app session cache", () => { - test("dropSessionCaches clears orphaned parts without message rows", () => { - const store: { - session_status: Record - session_diff: Record - todo: Record - message: Record - session_message: Record - part: Record - permission: Record - form: Record - part_text_accum_delta: Record - } = { - session_status: { ses_1: { type: "busy" } as SessionStatus }, - session_diff: { ses_1: [] }, - todo: { ses_1: [] as Todo[] }, - message: {}, - session_message: {}, - part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, - permission: { ses_1: [] as PermissionRequest[] }, - form: { ses_1: [] as FormInfo[] }, - part_text_accum_delta: { prt_1: "streamed text" }, - } - - dropSessionCaches(store, ["ses_1"]) - - expect(store.message.ses_1).toBeUndefined() - expect(store.part.msg_1).toBeUndefined() - expect(store.part_text_accum_delta.prt_1).toBeUndefined() - expect(store.todo.ses_1).toBeUndefined() - expect(store.session_diff.ses_1).toBeUndefined() - expect(store.session_status.ses_1).toBeUndefined() - expect(store.permission.ses_1).toBeUndefined() - expect(store.form.ses_1).toBeUndefined() - }) - - test("dropSessionCaches clears message-backed parts", () => { - const m = msg("msg_1", "ses_1") - const store: { - session_status: Record - session_diff: Record - todo: Record - message: Record - session_message: Record - part: Record - permission: Record - form: Record - part_text_accum_delta: Record - } = { - session_status: {}, - session_diff: {}, - todo: {}, - message: { ses_1: [m] }, - session_message: {}, - part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, - permission: {}, - form: {}, - part_text_accum_delta: {}, - } - - dropSessionCaches(store, ["ses_1"]) - - expect(store.message.ses_1).toBeUndefined() - expect(store.part[m.id]).toBeUndefined() - }) - - test("pickSessionCacheEvictions preserves requested sessions", () => { - const seen = new Set(["ses_1", "ses_2", "ses_3"]) - - const stale = pickSessionCacheEvictions({ - seen, - keep: "ses_4", - limit: 2, - preserve: ["ses_1"], - }) - - expect(stale).toEqual(["ses_2", "ses_3"]) - expect([...seen]).toEqual(["ses_1", "ses_4"]) - }) -}) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts deleted file mode 100644 index 5f51cfe1e4..0000000000 --- a/packages/app/src/context/global-sync/session-cache.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Message, Part, Todo } from "@/types" -import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise" -import type { FileDiffInfo } from "@opencode-ai/client/promise" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" - -export const SESSION_CACHE_LIMIT = 40 - -type SessionCache = { - session_status: Record - session_diff: Record - todo: Record - message: Record - session_message: Record - part: Record - permission: Record - form?: Record - part_text_accum_delta: Record -} - -export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { - const stale = new Set(Array.from(sessionIDs).filter(Boolean)) - if (stale.size === 0) return - - for (const key of Object.keys(store.part)) { - const parts = store.part[key] - if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue - for (const part of parts) { - delete store.part_text_accum_delta[part.id] - } - delete store.part[key] - } - - for (const sessionID of stale) { - delete store.message[sessionID] - delete store.todo[sessionID] - delete store.session_message[sessionID] - delete store.session_diff[sessionID] - delete store.session_status[sessionID] - delete store.permission[sessionID] - if (store.form) delete store.form[sessionID] - } -} - -export function pickSessionCacheEvictions(input: { - seen: Set - keep: string - limit: number - preserve?: Iterable -}) { - const stale: string[] = [] - const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])]) - if (input.seen.has(input.keep)) input.seen.delete(input.keep) - input.seen.add(input.keep) - for (const id of input.seen) { - if (input.seen.size - stale.length <= input.limit) break - if (keep.has(id)) continue - stale.push(id) - } - for (const id of stale) { - input.seen.delete(id) - } - return stale -} diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts deleted file mode 100644 index b448599cce..0000000000 --- a/packages/app/src/context/global-sync/session-load.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/promise" - -export async function loadRootSessions(input: { api: Pick; directory: string; limit: number }) { - const result = await input.api.list({ - directory: input.directory, - parentID: null, - limit: input.limit, - order: "desc", - }) - return { - data: result.data, - limit: input.limit, - limited: true, - } as const -} - -export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { - if (!input.limited) return input.count - if (input.count < input.limit) return input.count - return input.count + 1 -} diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts deleted file mode 100644 index 1aa6c90a52..0000000000 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise" -import { trimSessions } from "./session-trim" - -const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) => - ({ - id: input.id, - parentID: input.parentID, - time: { - created: input.created, - updated: input.updated, - archived: input.archived, - }, - }) as SessionInfo - -describe("trimSessions", () => { - test("keeps base roots and recent roots beyond the limit", () => { - const now = 1_000_000 - const list = [ - session({ id: "a", created: now - 100_000 }), - session({ id: "b", created: now - 90_000 }), - session({ id: "c", created: now - 80_000 }), - session({ id: "d", created: now - 70_000, updated: now - 1_000 }), - session({ id: "e", created: now - 60_000, archived: now - 10 }), - ] - - const result = trimSessions(list, { limit: 2, permission: {}, now }) - expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"]) - }) - - test("keeps children when root is kept, permission exists, or child is recent", () => { - const now = 1_000_000 - const list = [ - session({ id: "root-1", created: now - 1000 }), - session({ id: "root-2", created: now - 2000 }), - session({ id: "z-root", created: now - 30_000_000 }), - session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }), - session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }), - session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }), - session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }), - ] - - const result = trimSessions(list, { - limit: 2, - permission: { - "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest], - }, - now, - }) - - expect(result.map((x) => x.id)).toEqual([ - "child-kept-by-permission", - "child-kept-by-recency", - "child-kept-by-root", - "root-1", - "root-2", - ]) - }) -}) diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts deleted file mode 100644 index 57136e276e..0000000000 --- a/packages/app/src/context/global-sync/session-trim.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { SessionInfo } from "@opencode-ai/client/promise" -import type { PermissionRequest } from "@opencode-ai/client/promise" -import { cmp } from "./utils" -import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" - -export function sessionUpdatedAt(session: SessionInfo) { - return session.time.updated ?? session.time.created -} - -export function compareSessionRecent(a: SessionInfo, b: SessionInfo) { - const aUpdated = sessionUpdatedAt(a) - const bUpdated = sessionUpdatedAt(b) - if (aUpdated !== bUpdated) return bUpdated - aUpdated - return cmp(a.id, b.id) -} - -export function takeRecentSessions(sessions: SessionInfo[], limit: number, cutoff: number) { - if (limit <= 0) return [] as SessionInfo[] - const selected: SessionInfo[] = [] - const seen = new Set() - for (const session of sessions) { - if (!session?.id) continue - if (seen.has(session.id)) continue - seen.add(session.id) - if (sessionUpdatedAt(session) <= cutoff) continue - const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) - if (index === -1) selected.push(session) - if (index !== -1) selected.splice(index, 0, session) - if (selected.length > limit) selected.pop() - } - return selected -} - -export function trimSessions( - input: SessionInfo[], - options: { limit: number; permission: Record; now?: number }, -) { - const limit = Math.max(0, options.limit) - const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW - const all = input - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => cmp(a.id, b.id)) - const roots = all.filter((s) => !s.parentID) - roots.sort(compareSessionRecent) - const children = all.filter((s) => !!s.parentID) - const base = roots.slice(0, limit) - const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff) - const keepRoots = [...base, ...recent] - const keepRootIds = new Set(keepRoots.map((s) => s.id)) - const keepChildren = children.filter((s) => { - if (s.parentID && keepRootIds.has(s.parentID)) return true - const perms = options.permission[s.id] ?? [] - if (perms.length > 0) return true - return sessionUpdatedAt(s) > cutoff - }) - return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) -} diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 89687d2477..c3f6b28f95 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,13 +1,7 @@ -import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types" -import type { - FileDiffInfo, - PermissionRequest, - ReferenceInfo, - SessionInfo, - SessionStatus, -} from "@opencode-ai/client/promise" +import type { Agent, Config, LspStatus, Path, VcsInfo } from "@/types" +import type { ReferenceInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { CommandInfo, McpResource, McpServer } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -34,21 +28,6 @@ export type State = { provider: NormalizedProviderListResponse config: Config path: Path - session: SessionInfo[] - sessionTotal: number - session_status: { - [sessionID: string]: SessionStatus - } - session_working(id: string): boolean - session_diff: { - [sessionID: string]: FileDiffInfo[] - } - todo: { - [sessionID: string]: Todo[] - } - permission: { - [sessionID: string]: PermissionRequest[] - } mcp_ready: boolean mcp: { [name: string]: McpServer["status"] @@ -59,19 +38,6 @@ export type State = { lsp_ready: boolean lsp: LspStatus[] vcs: VcsInfo | undefined - limit: number - message: { - [sessionID: string]: Message[] - } - session_message: { - [sessionID: string]: SessionMessageInfo[] - } - part: { - [messageID: string]: Part[] - } - part_text_accum_delta: { - [partID: string]: string - } } export type VcsCache = { diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index e79cb86a5c..405f6f4f3d 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import type { AgentListOutput, - ModelDefaultOutput, ModelListOutput, ProviderListOutput, } from "@opencode-ai/client/promise" @@ -75,7 +74,6 @@ describe("normalizeProviderList", () => { limit: { context: 1, output: 1 }, }, ] as ModelListOutput["data"], - { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], ) expect(result.connected).toEqual(["openai"]) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 6b7c344afc..16cf33f705 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,6 +1,5 @@ import type { AgentListOutput, - ModelDefaultOutput, ModelListOutput, ProviderListOutput, } from "@opencode-ai/client/promise" @@ -38,7 +37,6 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag export function normalizeProviderList( providers: ProviderListOutput["data"] | ProviderListResponse, models?: ModelListOutput["data"], - defaultModel?: ModelDefaultOutput["data"], ): NormalizedProviderListResponse { if (!Array.isArray(providers)) { return providers @@ -113,10 +111,7 @@ export function normalizeProviderList( connected: providers.map((provider) => provider.id), default: Object.fromEntries( providers.flatMap((provider) => { - const model = - defaultModel?.providerID === provider.id - ? defaultModel - : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + const model = models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") return model ? [[provider.id, model.id]] : [] }), ), diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 3c1962b0c5..3d2d96448c 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" import { getOwner } from "solid-js/web" +import { createData } from "@opencode-ai/client/solid" import type { ServerScope } from "@/utils/server-scope" import { createServerPermissionState } from "./permission" import { createServerNotificationState } from "./notification" @@ -99,9 +100,15 @@ function createServerController( ) { const connKey = ServerConnection.key(conn) const sdk = createServerSdkContext(conn, scope) - const sync = createServerSyncContext(sdk) - const permission = createServerPermissionState({ sdk, sync }) - const notification = createServerNotificationState({ sdk, sync, key: connKey }) + const data = createData({ + api: () => sdk.api, + event: sdk.event, + connection: sdk.connection, + directory: "", + }) + const sync = createServerSyncContext(sdk, data) + const permission = createServerPermissionState({ sdk, sync, data }) + const notification = createServerNotificationState({ sdk, data, key: connKey }) function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = sync.child(project.worktree, { bootstrap: false }) @@ -134,6 +141,7 @@ function createServerController( (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) return { + data, sdk, sync, isLocal, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 2fa2d83224..4fb52c067e 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -3,7 +3,6 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr import { useLocation } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { makeEventListener } from "@solid-primitives/event-listener" -import { useServerSync } from "./server-sync" import { ServerConnection, useServers } from "./servers" import { usePlatform } from "./platform" import type { Project } from "@/types" @@ -68,7 +67,6 @@ type SessionView = { reviewFile?: string pendingMessage?: string pendingMessageAt?: number - todoCollapsed?: boolean } export type LocalProject = Partial & { worktree: string; expanded: boolean } @@ -145,7 +143,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( name: "Layout", gate: false, init: () => { - // const serverSync = useServerSync() const servers = useServers() const platform = usePlatform() @@ -700,18 +697,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setScroll(tab: string, pos: SessionScroll) { scroll.setScroll(key(), tab, pos) }, - todoCollapsed: { - get: () => s().todoCollapsed ?? false, - set(collapsed: boolean) { - const session = key() - const current = store.sessionView[session] - if (!current) { - setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) - } else { - setStore("sessionView", session, "todoCollapsed", collapsed) - } - }, - }, terminal: { opened: terminalOpened, open() { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index ac0dfda14a..99c4ec20a8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -9,8 +9,9 @@ import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" import { hasCustomAgent, resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" -import { useSDK } from "./sdk" -import { useSync } from "./sync" +import { useWorkspaceLocation } from "./location" +import { useData } from "./server" +import { normalizeAgentList } from "./global-sync/utils" import { useServerSDK } from "./server-sdk" import { ScopedKey, type ServerScope } from "@/utils/server-scope" @@ -59,15 +60,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { const params = useParams() - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() + const data = useData() const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const models = useModels() const settings = useSettings() const id = createMemo(() => params.id || undefined) - const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) + const list = createMemo(() => + normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter( + (item) => item.mode !== "subagent" && !item.hidden, + ), + ) const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) @@ -148,14 +153,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setStore("promoting", undefined) }) - const configuredModel = () => { - const configured = sync().data.config.model - if (!configured) return - const [providerID, modelID] = configured.split("/") - const model = { providerID, modelID } - if (validModel(model)) return model - } - const recentModel = () => { for (const item of models.recent.list()) { if (validModel(item)) return item @@ -163,14 +160,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const defaultModel = () => { - const defaults = providers.default() for (const provider of providers.connected()) { - const configured = defaults[provider.id] - if (configured) { - const model = { providerID: provider.id, modelID: configured } - if (validModel(model)) return model - } - const first = Object.values(provider.models)[0] if (!first) continue const model = { providerID: provider.id, modelID: first.id } @@ -178,7 +168,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const fallback = createMemo(() => configuredModel() ?? recentModel() ?? defaultModel()) + const fallback = createMemo(() => recentModel() ?? defaultModel()) const agent = { list, diff --git a/packages/app/src/context/location.tsx b/packages/app/src/context/location.tsx new file mode 100644 index 0000000000..12eaa0c7bf --- /dev/null +++ b/packages/app/src/context/location.tsx @@ -0,0 +1,60 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise" +import { type Accessor, createEffect, createMemo, createSignal } from "solid-js" +import { type LocationContext, useServerSDK } from "./server-sdk" +import { useData } from "./server" +export type { LocationContext } from "./server-sdk" + +export type WorkspaceLocation = LocationContext & { + readonly ref: LocationRef + readonly current: LocationGetOutput | undefined + readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined +} + +const context = createSimpleContext({ + name: "Location", + init: (props: { + directory: string | Accessor + workspaceID?: string | Accessor + }) => { + const serverSDK = useServerSDK() + const data = useData() + const ref = createMemo(() => ({ + directory: typeof props.directory === "function" ? props.directory() : props.directory, + workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID, + })) + const current = createMemo(() => data.location.info(ref())) + const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>() + let generation = 0 + + createEffect(() => { + const location = ref() + if (serverSDK.connection.status() !== "connected") return + const attempt = ++generation + setError(undefined) + void data.location.sync(location).catch((cause) => { + const latest = ref() + if ( + generation !== attempt || + latest.directory !== location.directory || + latest.workspaceID !== location.workspaceID + ) + return + setError({ location, cause }) + }) + }) + + const location = createMemo(() => + serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory), + ) + return createMemo(() => ({ + ...location(), + ref: ref(), + current: current(), + error: error(), + })) + }, +}) + +export const useWorkspaceLocation: () => Accessor = context.use +export const LocationProvider = context.provider diff --git a/packages/app/src/context/mcp.ts b/packages/app/src/context/mcp.ts index af34b49886..85b2ff7a14 100644 --- a/packages/app/src/context/mcp.ts +++ b/packages/app/src/context/mcp.ts @@ -1,14 +1,45 @@ import { useMutation } from "@tanstack/solid-query" +import type { Accessor } from "solid-js" import { useLanguage } from "@/context/language" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { usePlatform } from "@/context/platform" import { showToast } from "@/utils/toast" -export function useMcpToggle() { - const sync = useSync() +export function useMcpToggle(directory?: Accessor, onSuccess?: () => unknown) { + const data = useData() + const serverSDK = useServerSDK() + const platform = usePlatform() const language = useLanguage() + const location = () => { + const value = directory ? directory() : data.location.default().directory + return value ? { directory: value } : undefined + } return useMutation(() => ({ - mutationFn: sync().mcp.toggle, + mutationFn: async (name: string) => { + const ref = location() + const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name) + if (!server || server.status.status === "pending") return + if (server.status.status === "connected") { + await serverSDK.api.mcp.disconnect({ server: name, location: ref }) + } else if (server.status.status === "needs_auth" && server.integrationID) { + const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref }) + const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length) + if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`) + const attempt = await serverSDK.api.integration.oauth.connect({ + integrationID: server.integrationID, + methodID: method.id, + location: ref, + }) + platform.openExternal(attempt.data.url) + } else { + await serverSDK.api.mcp.connect({ server: name, location: ref }) + } + data.location.mcp.server.invalidate(ref) + data.location.mcp.resource.invalidate(ref) + await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()]) + }, onError: (error) => showToast({ variant: "error", diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 008b6d9d1c..3fe97977cd 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" -import type { ServerSync } from "./server-sync" +import type { Data } from "@opencode-ai/client/solid" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" @@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) { return index } -export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) { +export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) { const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser }) } - const lookup = async (directory: string, sessionID?: string) => { + const lookup = async (sessionID?: string) => { if (!sessionID) return undefined - const sync = input.sync.ensureDirSyncContext(directory) - const session = sync.session.get(sessionID) + const session = input.data.session.get(sessionID) if (session) return session - return sync.session + return input.data.session .sync(sessionID) - .then(() => sync.session.get(sessionID)) + .then(() => input.data.session.get(sessionID)) .catch(() => undefined) } @@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => { const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { + void lookup(sessionID).then((session) => { if (meta.disposed) return if (!session) return if (session.parentID) return @@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser time: number, ) => { const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { + void lookup(sessionID).then((session) => { if (meta.disposed) return if (session?.parentID) return @@ -278,7 +277,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser }) } - const unsub = input.sdk.event.listen((e) => { + const unsub = input.sdk.eventByDir.listen((e) => { const event = e.details if ( event.type !== "session.execution.succeeded" && diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 2e7a01deaf..ebbcc185e4 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/client/promise" import { Persist, persisted } from "@/utils/persist" import type { ServerSDK } from "@/context/server-sdk" import type { ServerSync } from "./server-sync" +import type { Data } from "@opencode-ai/client/solid" import { useParams, useSearchParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" import { useGlobal } from "./global" @@ -53,9 +54,9 @@ function hasPermissionPromptRules(permission: unknown) { return Object.values(config).some(isNonAllowRule) } -type PermissionEvent = Parameters[0]>[0] +type PermissionEvent = Parameters[0]>[0] -export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { +export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) { const [store, setStore, _, ready] = persisted( { ...Persist.serverGlobal(input.sdk.scope, "permission"), @@ -143,10 +144,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve }) } - function sessions(directory?: string) { - const info = Object.values(input.sync.session.data.info).filter((session) => !!session) - if (!directory) return info - return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session] + function sessions(_directory?: string) { + return input.data.session.list() } function autoAccept(directory?: string) { @@ -169,16 +168,21 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve } function isPending(permission: PermissionRequest) { - const pending = input.sync.session.data.permission[permission.sessionID] + const pending = input.data.session.permission.list(permission.sessionID) return pending === undefined || pending.some((item) => item.id === permission.id) } async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory) if (override !== undefined) return override - if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) - const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) - if (meta.disposed || !lineage) return false + const loaded = new Set() + while (!loaded.has(input.data.session.root(permission.sessionID))) { + const root = input.data.session.root(permission.sessionID) + loaded.add(root) + if (input.data.session.get(root)) break + await input.data.session.sync(root).catch(() => undefined) + } + if (meta.disposed || !input.data.session.get(permission.sessionID)) return false return shouldAutoRespond(permission, directory) } @@ -206,7 +210,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve void respondPending(event.properties, event.current?.location?.directory) } - const unsubscribe = input.sdk.event.listen((event) => { + const unsubscribe = input.sdk.eventByDir.listen((event) => { if (ready()) { handlePermission(event) return diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 689ddb6212..a0d57136d9 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -5,7 +5,7 @@ import { createMemo, createResource, createRoot, getOwner, onCleanup } from "sol import { requireServerKey } from "@/utils/session-route" import { ServerConnection } from "./servers" import { useServerSDK } from "./server-sdk" -import { useSDK } from "./sdk" +import { useWorkspaceLocation } from "./location" import { useTabs, type Tab } from "./tabs" import type { ServerScope } from "@/utils/server-scope" import { @@ -75,7 +75,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams<{ serverKey?: string; id?: string }>() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() const tabs = useTabs() diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx deleted file mode 100644 index cbb0636229..0000000000 --- a/packages/app/src/context/sdk.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createSimpleContext } from "@opencode-ai/ui/context" -import { type Accessor, createMemo } from "solid-js" -import { type DirectorySDK, useServerSDK } from "./server-sdk" -export type { DirectorySDK } from "./server-sdk" - -const context = createSimpleContext({ - name: "SDK", - // Resolves the directory-scoped SDK reactively from the (possibly changing) server. - init: (props: { directory: string | Accessor }) => { - const serverSDK = useServerSDK() - return createMemo(() => { - const directory = typeof props.directory === "function" ? props.directory() : props.directory - return serverSDK.ensureDirSdkContext(directory) - }) - }, -}) - -export const useSDK: () => Accessor = context.use -export const SDKProvider = context.provider diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 0c533fee9a..b075fd5a36 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -16,9 +16,9 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent } -type ServerEventMap = { [Type in ServerEvent["type"]]: Extract } -type ServerEventEmitter = ReturnType> -type ServerLocationEventEmitter = ReturnType> +type ServerEventEmitter = ReturnType> +type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract } +type CurrentEventEmitter = ReturnType> export type ServerConnectionStatus = ClientConnectionStatus type ServerSDKBase = { server: ServerConnection.Any @@ -30,29 +30,28 @@ type ServerSDKBase = { attempt: Accessor error: Accessor } - event: { + eventByDir: { on: ServerEventEmitter["on"] listen: ServerEventEmitter["listen"] - location: { - on: ServerLocationEventEmitter["on"] - } + } + event: { + on: CurrentEventEmitter["on"] + listen: CurrentEventEmitter["listen"] } } function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const api = createApiForServer({ server: server.http, fetch: platform.fetch }) - const emitter = createGlobalEmitter() - const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>() + const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>() + const emitter = createGlobalEmitter() const connection = createClientConnection(api, { flushInterval: 16, pageLifecycle: true, onEvent(event) { - const adapted = adaptServerEvent(event) - emitter.emit(adapted.type, adapted) - const directory = event.location?.directory - if (directory) locations.emit(directory, adapted) + emitter.emit(event.type, event) + dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event)) }, log: { info(message, data) { @@ -63,8 +62,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS }) onCleanup(() => { + dirEmitter.clear() emitter.clear() - locations.clear() }) return { @@ -73,12 +72,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS url: server.http.url, api, connection, + eventByDir: { + on: dirEmitter.on.bind(dirEmitter), + listen: dirEmitter.listen.bind(dirEmitter), + }, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), - location: { - on: locations.on.bind(locations), - }, }, } } @@ -103,29 +103,21 @@ type SDKEventMap = { [key in Event["type"]]: Extract } -export type DirectorySDK = { - scope: ServerScope +export type LocationContext = { directory: string - api: ServerApi event: ReturnType> - readonly url: string } -function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { +function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext { const emitter = createGlobalEmitter() - const unsub = serverSDK.event.location.on(directory, (event) => { + const unsub = serverSDK.eventByDir.on(directory, (event) => { emitter.emit(event.type, event) }) onCleanup(unsub) return { - scope: serverSDK.scope, directory, - api: serverSDK.api, event: emitter, - get url() { - return serverSDK.url - }, } } diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts deleted file mode 100644 index 2d805a6c2d..0000000000 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" -import { createV2SessionReducer } from "./server-session-v2-reducer" - -const event = (input: object) => input as OpenCodeEvent -const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } - -describe("v2 session reducer", () => { - test("moves a repeated inbox payload to the current event position", () => { - const reducer = createV2SessionReducer() - const result = reducer.reduce( - [ - { id: "msg_user", type: "user", text: "local", time: { created: 0 } }, - { id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } }, - ], - event({ - ...base, - id: "evt_admitted", - type: "session.inbox.enqueued", - data: { - sessionID: "ses_1", - inboxID: "msg_user", - item: { type: "user", delivery: "steer", payload: { text: "durable" } }, - }, - }), - ) - - expect(result?.messages).toEqual([ - { id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } }, - { id: "msg_user", type: "user", text: "durable", time: { created: 1 } }, - ]) - expect(result?.touched).toEqual(["msg_user"]) - }) - - test("projects promoted input and streaming assistant content", () => { - const reducer = createV2SessionReducer() - let messages: SessionMessageInfo[] = [] - const apply = (input: object) => { - const result = reducer.reduce(messages, event(input)) - if (result) messages = result.messages - return result - } - - apply({ - ...base, - id: "evt_admitted", - type: "session.inbox.enqueued", - data: { - sessionID: "ses_1", - inboxID: "msg_user", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - }) - apply({ - ...base, - id: "evt_promoted", - type: "session.inbox.delivered", - data: { sessionID: "ses_1", inboxID: "msg_user" }, - }) - apply({ - ...base, - id: "evt_step", - type: "session.step.started", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - ...base, - id: "evt_text_start", - type: "session.text.started", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, - }) - apply({ - ...base, - id: "evt_text_delta", - type: "session.text.delta", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, - }) - apply({ - ...base, - id: "evt_text_end", - type: "session.text.ended", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, - }) - - expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) - expect(messages[1]).toMatchObject({ - id: "msg_assistant", - type: "assistant", - content: [{ type: "text", text: "hello" }], - }) - }) - - test("prefers durable selection predecessors and derives them for older events", () => { - const source: SessionMessageInfo[] = [ - { id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } }, - { - id: "msg_previous_model", - type: "model-switched", - model: { id: "old", providerID: "provider" }, - time: { created: 1 }, - }, - ] - const reducer = createV2SessionReducer() - - const agent = reducer.reduce( - source, - event({ - ...base, - id: "evt_agent", - type: "session.agent.selected", - data: { sessionID: "ses_1", agent: "plan", previous: "review" }, - }), - ) - const model = reducer.reduce( - source, - event({ - ...base, - id: "evt_model", - type: "session.model.selected", - data: { - sessionID: "ses_1", - model: { id: "new", providerID: "provider" }, - previous: { id: "durable", providerID: "provider" }, - }, - }), - ) - const derivedAgent = reducer.reduce( - source, - event({ - ...base, - id: "evt_derived_agent", - type: "session.agent.selected", - data: { sessionID: "ses_1", agent: "plan" }, - }), - ) - - expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" }) - expect(model?.messages.at(-1)).toMatchObject({ - type: "model-switched", - model: { id: "new" }, - previous: { id: "durable" }, - }) - expect(derivedAgent?.messages.at(-1)).toMatchObject({ - type: "agent-switched", - agent: "plan", - previous: "build", - }) - }) - - test("folds tool, retry, and completion events", () => { - const reducer = createV2SessionReducer() - let messages: SessionMessageInfo[] = [] - const apply = (input: object) => { - const result = reducer.reduce(messages, event(input)) - if (result) messages = result.messages - } - - apply({ - ...base, - id: "evt_step", - type: "session.step.started", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - ...base, - id: "evt_tool_start", - type: "session.tool.input.started", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" }, - }) - apply({ - ...base, - id: "evt_tool_delta", - type: "session.tool.input.delta", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" }, - }) - apply({ - ...base, - id: "evt_tool_called", - type: "session.tool.called", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true }, - }) - apply({ - ...base, - id: "evt_tool_success", - type: "session.tool.success", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - id: "call_1", - metadata: {}, - content: [{ type: "text", text: "done" }], - executed: true, - }, - }) - apply({ - ...base, - id: "evt_retry", - type: "session.retry.scheduled", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - attempt: 2, - at: 10, - error: { type: "ProviderError", message: "retry" }, - }, - }) - apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) - - expect(messages[0]).toMatchObject({ - type: "assistant", - retry: undefined, - content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], - }) - }) - - test("requests hydration when promotion admission was missed", () => { - const result = createV2SessionReducer().reduce( - [], - event({ - ...base, - id: "evt_promoted", - type: "session.inbox.delivered", - data: { sessionID: "ses_1", inboxID: "msg_user" }, - }), - ) - - expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) - }) - - test("projects rendered instruction updates", () => { - const reducer = createV2SessionReducer() - const result = reducer.reduce( - [], - event({ - ...base, - id: "evt_instructions", - type: "session.instructions.updated", - data: { sessionID: "ses_1", delta: { agents: "hash" }, text: "Changed instructions" }, - }), - ) - - expect(result?.messages).toEqual([ - { - id: "msg_instructions", - type: "system", - text: "Changed instructions", - description: "Instructions updated: agents", - metadata: undefined, - time: { created: 1 }, - }, - ]) - }) - - test("projects session movement with the previous location", () => { - const result = createV2SessionReducer().reduce( - [], - event({ - ...base, - id: "evt_moved", - type: "session.moved", - data: { - sessionID: "ses_1", - projectID: "project_2", - location: { directory: "/repo-2" }, - subpath: "packages/app", - }, - }), - { projectID: "project_1", location: { directory: "/repo-1" } }, - ) - - expect(result?.messages).toMatchObject([ - { - id: "msg_moved", - type: "location-switched", - projectID: "project_2", - location: { directory: "/repo-2" }, - previous: { projectID: "project_1", location: { directory: "/repo-1" } }, - }, - ]) - }) - - test("removes cancelled input from the pending promotion fold", () => { - const reducer = createV2SessionReducer() - reducer.reduce( - [], - event({ - ...base, - id: "evt_admitted", - type: "session.inbox.enqueued", - data: { - sessionID: "ses_1", - inboxID: "msg_user", - item: { type: "user", delivery: "queue", payload: { text: "cancel me" } }, - }, - }), - ) - reducer.reduce( - [], - event({ - ...base, - id: "evt_cancelled", - type: "session.inbox.cancelled", - data: { sessionID: "ses_1", inboxID: "msg_user" }, - }), - ) - const result = reducer.reduce( - [], - event({ - ...base, - id: "evt_promoted", - type: "session.inbox.delivered", - data: { sessionID: "ses_1", inboxID: "msg_user" }, - }), - ) - - expect(result).toMatchObject({ missing: "msg_user" }) - }) - - test("keeps steered input available to the promotion fold", () => { - const reducer = createV2SessionReducer() - reducer.reduce( - [], - event({ - ...base, - id: "evt_admitted", - type: "session.inbox.enqueued", - data: { - sessionID: "ses_1", - inboxID: "msg_user", - item: { type: "user", delivery: "queue", payload: { text: "steer me" } }, - }, - }), - ) - reducer.reduce( - [], - event({ - ...base, - id: "evt_steered", - type: "session.inbox.delivery.changed", - data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" }, - }), - ) - reducer.reduce( - [], - event({ - ...base, - id: "evt_queued", - type: "session.inbox.delivery.changed", - data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" }, - }), - ) - - const result = reducer.reduce( - [], - event({ - ...base, - id: "evt_promoted", - type: "session.inbox.delivered", - data: { sessionID: "ses_1", inboxID: "msg_user" }, - }), - ) - - expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }]) - }) -}) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts deleted file mode 100644 index c94443c819..0000000000 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ /dev/null @@ -1,592 +0,0 @@ -import type { - OpenCodeEvent, - SessionInboxInfo, - SessionInboxItem, - SessionInfo, - SessionMessageInfo, -} from "@opencode-ai/client/promise" - -type Assistant = Extract -type Compaction = Extract -type Shell = Extract - -export type V2SessionReduction = { - sessionID: string - messages: SessionMessageInfo[] - touched: string[] - removed?: string[] - missing?: string -} - -export function createV2SessionReducer() { - const pending = new Map() - - const reduce = ( - source: readonly SessionMessageInfo[], - event: OpenCodeEvent, - session?: Pick, - ): V2SessionReduction | undefined => { - if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return - const sessionID = event.data.sessionID - const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ - sessionID, - messages, - touched, - }) - const append = (message: SessionMessageInfo) => - result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) - const replace = (message: SessionMessageInfo) => - result([...source.filter((item) => item.id !== message.id), message], [message.id]) - - switch (event.type) { - case "session.inbox.enqueued": - pending.set(key(sessionID, event.data.inboxID), event.data.item) - if (event.data.item.type === "user") - return replace({ - id: event.data.inboxID, - type: "user", - metadata: event.data.item.payload.metadata, - text: event.data.item.payload.text, - files: event.data.item.payload.files, - agents: event.data.item.payload.agents, - time: { created: event.created }, - }) - if (event.data.item.type !== "synthetic") return result([...source]) - return replace({ - id: event.data.inboxID, - type: "synthetic", - metadata: event.data.item.payload.metadata, - text: event.data.item.payload.text, - description: event.data.item.payload.description, - time: { created: event.created }, - }) - case "session.inbox.cancelled": - pending.delete(key(sessionID, event.data.inboxID)) - return { - ...result(source.filter((item) => item.id !== event.data.inboxID)), - removed: source.some((item) => item.id === event.data.inboxID) ? [event.data.inboxID] : [], - } - case "session.inbox.delivered": { - const input = pending.get(key(sessionID, event.data.inboxID)) - pending.delete(key(sessionID, event.data.inboxID)) - const existing = source.find((item) => item.id === event.data.inboxID) - if (existing) { - const promoted = { ...existing, time: { ...existing.time, created: event.created } } - return result([...source.filter((item) => item.id !== existing.id), promoted], [existing.id]) - } - if (!input) return { ...result([...source]), missing: event.data.inboxID } - if (input.type === "user") - return append({ - id: event.data.inboxID, - type: "user", - metadata: input.payload.metadata, - text: input.payload.text, - files: input.payload.files, - agents: input.payload.agents, - time: { created: event.created }, - }) - if (input.type !== "synthetic") return result([...source]) - return append({ - id: event.data.inboxID, - type: "synthetic", - metadata: input.payload.metadata, - text: input.payload.text, - description: input.payload.description, - time: { created: event.created }, - }) - } - case "session.agent.selected": - return append({ - id: messageID(event.id), - type: "agent-switched", - metadata: event.metadata, - agent: event.data.agent, - previous: - event.data.previous ?? - source.findLast( - (item): item is Extract => - item.type === "agent-switched" || item.type === "assistant", - )?.agent, - time: { created: event.created }, - }) - case "session.model.selected": - return append({ - id: messageID(event.id), - type: "model-switched", - metadata: event.metadata, - model: event.data.model, - previous: - event.data.previous ?? - source.findLast( - (item): item is Extract => - item.type === "model-switched" || item.type === "assistant", - )?.model, - time: { created: event.created }, - }) - case "session.moved": - if (!session) return - return append({ - id: messageID(event.id), - type: "location-switched", - metadata: event.metadata, - location: event.data.location, - projectID: event.data.projectID, - subpath: event.data.subpath, - previous: { - location: session.location, - projectID: session.projectID, - subpath: session.subpath, - }, - time: { created: event.created }, - }) - case "session.instructions.updated": - if (event.data.text === undefined) return - return append({ - id: messageID(event.id), - type: "system", - text: event.data.text, - description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`, - metadata: event.metadata, - time: { created: event.created }, - }) - case "session.synthetic": - return append({ - id: messageID(event.id), - type: "synthetic", - metadata: event.data.metadata, - text: event.data.text, - description: event.data.description, - time: { created: event.created }, - }) - case "session.skill.activated": - return append({ - id: messageID(event.id), - type: "skill", - metadata: event.metadata, - skill: event.data.id, - name: event.data.name, - text: event.data.text, - time: { created: event.created }, - }) - case "session.shell.started": - return append({ - id: messageID(event.id), - type: "shell", - metadata: event.metadata, - shellID: event.data.shell.id, - command: event.data.shell.command, - status: event.data.shell.status, - exit: event.data.shell.exit, - time: { created: event.created }, - }) - case "session.shell.ended": - return updateMessage( - source, - (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, - (item) => ({ - ...item, - status: event.data.shell.status, - exit: event.data.shell.exit, - output: event.data.output, - time: { ...item.time, completed: event.created }, - }), - sessionID, - ) - case "session.step.started": { - const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) - const completed = - current && current.id !== event.data.assistantMessageID - ? update(source, current.id, (item) => - item.type === "assistant" - ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } - : item, - ) - : [...source] - const existing = completed.find((item) => item.id === event.data.assistantMessageID) - if (existing?.type === "assistant") - return result( - update(completed, existing.id, (item) => - item.type === "assistant" - ? { - ...item, - agent: event.data.agent, - model: event.data.model, - retry: undefined, - error: undefined, - finish: undefined, - snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, - time: { ...item.time, completed: undefined }, - } - : item, - ), - current && current.id !== existing.id ? [current.id, existing.id] : [existing.id], - ) - return result( - [ - ...completed, - { - id: event.data.assistantMessageID, - type: "assistant", - metadata: event.metadata, - agent: event.data.agent, - model: event.data.model, - content: [], - snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, - time: { created: event.created }, - }, - ], - current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID], - ) - } - case "session.step.ended": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - finish: event.data.finish, - cost: event.data.cost, - tokens: event.data.tokens, - snapshot: - event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, - time: { ...item.time, completed: event.created }, - })) - case "session.step.failed": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - finish: "error", - error: event.data.error, - retry: undefined, - cost: event.data.cost ?? item.cost, - tokens: event.data.tokens ?? item.tokens, - snapshot: - event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, - time: { ...item.time, completed: event.created }, - })) - case "session.text.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }), - })) - case "session.text.delta": - return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ - ...item, - text: item.text + event.data.delta, - })) - case "session.text.ended": - return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ - ...item, - text: event.data.text, - })) - case "session.reasoning.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: insertOrdinal(item.content, "reasoning", event.data.ordinal, { - type: "reasoning", - text: "", - state: event.data.state, - time: { created: event.created }, - }), - })) - case "session.reasoning.delta": - return updateContent( - source, - event.data.assistantMessageID, - sessionID, - "reasoning", - event.data.ordinal, - (item) => ({ - ...item, - text: item.text + event.data.delta, - }), - ) - case "session.reasoning.ended": - return updateContent( - source, - event.data.assistantMessageID, - sessionID, - "reasoning", - event.data.ordinal, - (item) => ({ - ...item, - text: event.data.text, - state: event.data.state ?? item.state, - time: { created: item.time?.created ?? event.created, completed: event.created }, - }), - ) - case "session.tool.input.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: item.content.some((content) => content.type === "tool" && content.id === event.data.id) - ? item.content - : [ - ...item.content, - { - type: "tool", - id: event.data.id, - name: event.data.name, - state: { status: "streaming", input: "" }, - time: { created: event.created }, - }, - ], - })) - case "session.tool.input.delta": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => - tool.state.status === "streaming" - ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } } - : tool, - ) - case "session.tool.input.ended": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => - tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool, - ) - case "session.tool.called": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({ - ...tool, - executed: event.data.executed, - providerState: event.data.state, - // structured: {}, content: [] - state: { status: "running", input: event.data.input, metadata: {} }, - time: { ...tool.time, ran: event.created }, - })) - case "session.tool.progress": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => - tool.state.status === "running" - ? { - ...tool, - // state: { ...tool.state, structured: event.data.structured, content: event.data.content }, - state: { ...tool.state, metadata: event.data.metadata }, - } - : tool, - ) - case "session.tool.success": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => { - if (tool.state.status !== "running") return tool - return { - ...tool, - executed: event.data.executed || tool.executed === true, - providerResultState: event.data.resultState, - state: { - status: "completed", - input: tool.state.input, - // structured: event.data.structured, - metadata: event.data.metadata, - content: event.data.content, - // result: event.data.result, - }, - time: { ...tool.time, completed: event.created }, - } - }) - case "session.tool.failed": - return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => { - if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool - return { - ...tool, - executed: event.data.executed || tool.executed === true, - providerResultState: event.data.resultState, - state: { - status: "error", - input: typeof tool.state.input === "string" ? {} : tool.state.input, - // structured: tool.state.status === "running" ? tool.state.structured : {}, - metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}), - content: event.data.content, - error: event.data.error, - // result: event.data.result, - }, - time: { ...tool.time, completed: event.created }, - } - }) - case "session.retry.scheduled": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error }, - })) - case "session.execution.succeeded": - case "session.execution.failed": - case "session.execution.interrupted": { - const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) - if (!current?.retry) return result([...source]) - return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined })) - } - case "session.compaction.started": - return append({ - id: event.data.inputID ?? messageID(event.id), - type: "compaction", - status: "running", - metadata: event.metadata, - reason: event.data.reason, - summary: "", - recent: event.data.recent ?? "", - time: { created: event.created }, - }) - case "session.compaction.delta": - return updateMessage>( - source, - (item): item is Extract => - item.type === "compaction" && item.status === "running", - (item) => ({ - ...item, - summary: item.summary + event.data.text, - }), - sessionID, - ) - case "session.compaction.ended": { - const current = source.findLast( - (item): item is Extract => - item.type === "compaction" && item.status === "running", - ) - if (!current) - return append({ - id: messageID(event.id), - type: "compaction", - status: "completed", - metadata: event.metadata, - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - time: { created: event.created }, - }) - return result( - update(source, current.id, () => ({ - ...current, - status: "completed", - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - })), - [current.id], - ) - } - case "session.compaction.failed": { - const current = source.findLast( - (item): item is Extract => - item.type === "compaction" && item.status === "running", - ) - const failed: Extract = { - id: current?.id ?? event.data.inputID ?? messageID(event.id), - type: "compaction", - status: "failed", - metadata: current?.metadata ?? event.metadata, - reason: event.data.reason ?? "manual", - error: event.data.error ?? { - type: "compaction.failed", - message: "Compaction failed before recording an error", - }, - time: current?.time ?? { created: event.created }, - } - if (!current) return append(failed) - return result( - update(source, current.id, () => failed), - [failed.id], - ) - } - default: - return - } - } - - return { - reduce, - confirm(item: SessionInboxInfo) { - pending.set(key(item.sessionID, item.id), item) - }, - clear(sessionID: string) { - for (const id of pending.keys()) { - if (id.startsWith(`${sessionID}:`)) pending.delete(id) - } - }, - } -} - -function key(sessionID: string, inputID: string) { - return `${sessionID}:${inputID}` -} - -function messageID(eventID: string) { - return eventID.replace(/^evt_/, "msg_") -} - -function update( - source: readonly SessionMessageInfo[], - id: string, - apply: (item: SessionMessageInfo) => SessionMessageInfo, -) { - return source.map((item) => (item.id === id ? apply(item) : item)) -} - -function updateMessage( - source: readonly SessionMessageInfo[], - matches: (item: SessionMessageInfo) => item is T, - apply: (item: T) => T, - sessionID: string, -): V2SessionReduction { - const current = source.findLast(matches) - if (!current) return { sessionID, messages: [...source], touched: [] } - return { - sessionID, - messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)), - touched: [current.id], - } -} - -function updateAssistant( - source: readonly SessionMessageInfo[], - id: string, - sessionID: string, - apply: (item: Assistant) => Assistant, -): V2SessionReduction { - return { - sessionID, - messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)), - touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], - } -} - -function updateContent( - source: readonly SessionMessageInfo[], - messageID: string, - sessionID: string, - type: T, - ordinal: number, - apply: ( - item: Extract, - ) => Extract, -) { - return updateAssistant(source, messageID, sessionID, (assistant) => { - let index = -1 - return { - ...assistant, - content: assistant.content.map((item) => { - if (item.type !== type || ++index !== ordinal) return item - return apply(item as Extract) - }), - } - }) -} - -function updateTool( - source: readonly SessionMessageInfo[], - messageID: string, - callID: string, - sessionID: string, - apply: ( - item: Extract, - ) => Extract, -) { - return updateAssistant(source, messageID, sessionID, (assistant) => ({ - ...assistant, - content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)), - })) -} - -function insertOrdinal( - source: Assistant["content"], - type: T, - ordinal: number, - item: Extract, -) { - const matches = source.filter((content) => content.type === type) - if (matches[ordinal]) return source - return [...source, item] -} diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts deleted file mode 100644 index 3da2615e9b..0000000000 --- a/packages/app/src/context/server-session.test.ts +++ /dev/null @@ -1,1808 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { retry } from "@opencode-ai/core/util/retry" -import type { - FormInfo, - OpenCodeEvent, - SessionApi, - SessionInboxInfo, - SessionInfo, - SessionMessageAssistant, - SessionMessageAssistantTool, - SessionMessageInfo, -} from "@opencode-ai/client/promise" -import type { Message, Part } from "@/types" -import { createServerSession } from "./server-session" -import type { ServerApi } from "@/utils/server" - -type MessageApi = ServerApi["message"] - -const session = (id: string, parentID?: string): SessionInfo => ({ - id, - projectID: "project", - location: { directory: "/repo" }, - title: id, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - parentID, - time: { created: 1, updated: 1 }, -}) - -type UserMessage = Extract -type TextPart = Extract -type CurrentToolObject = Extract["input"] -type MessageResponse = { - data: { info: Message; parts: Part[] }[] - response: { headers: Headers } -} - -function sessionInfo(value: SessionInfo): SessionInfo { - return value -} - -function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] { - return data.flatMap((item): SessionMessageInfo[] => { - if (item.info.role === "user") { - return [ - { - id: `${item.info.id}:agent`, - type: "agent-switched", - agent: item.info.agent, - time: item.info.time, - }, - { - id: `${item.info.id}:model`, - type: "model-switched", - model: { - id: item.info.model.modelID, - providerID: item.info.model.providerID, - variant: item.info.model.variant, - }, - time: item.info.time, - }, - { - id: item.info.id, - type: "user", - text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), - time: item.info.time, - }, - ] - } - return [ - { - id: item.info.id, - type: "assistant", - agent: item.info.agent, - model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, - content: item.parts.flatMap((part): SessionMessageAssistant["content"] => { - if (part.type === "text") return [{ type: "text", text: part.text }] - if (part.type === "reasoning") - return [ - { - type: "reasoning", - text: part.text, - time: part.time ? { created: part.time.start, completed: part.time.end } : undefined, - }, - ] - if (part.type !== "tool") return [] - const state: SessionMessageAssistantTool["state"] = (() => { - if (part.state.status === "pending") - return { status: "streaming" as const, input: JSON.stringify(part.state.input) } - if (part.state.status === "running") - return { - status: "running" as const, - input: part.state.input as CurrentToolObject, - metadata: (part.state.metadata ?? {}) as CurrentToolObject, - } - if (part.state.status === "error") - return { - status: "error" as const, - input: part.state.input as CurrentToolObject, - error: { type: "tool_error", message: part.state.error }, - metadata: part.state.metadata as CurrentToolObject | undefined, - } - return { - status: "completed" as const, - input: part.state.input as CurrentToolObject, - content: [{ type: "text" as const, text: part.state.output }], - metadata: part.state.metadata as CurrentToolObject, - } - })() - return [ - { - id: part.id, - type: "tool" as const, - name: part.tool, - state, - time: { - created: part.state.status === "pending" ? item.info.time.created : part.state.time.start, - ran: part.state.status === "pending" ? undefined : part.state.time.start, - completed: - part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined, - }, - }, - ] - }), - time: item.info.time, - cost: item.info.cost, - tokens: item.info.tokens, - finish: item.info.finish as - | "stop" - | "length" - | "tool-calls" - | "content-filter" - | "error" - | "unknown" - | undefined, - }, - ] - }) -} - -function currentPage(value: MessageResponse) { - return { - data: currentMessages(value.data).toReversed(), - cursor: { next: value.response.headers.get("x-next-cursor") ?? undefined }, - } -} - -const userMessage = (id: string, input: Partial = {}): UserMessage => ({ - id, - sessionID: "child", - role: "user", - time: { created: 1 }, - agent: "build", - model: { providerID: "provider", modelID: "model", variant: undefined }, - ...input, -}) - -const textPart = (messageID: string, input: Partial = {}): TextPart => ({ - sessionID: "child", - messageID, - type: "text", - text: "text", - ...input, - id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`, -}) - -const promptEcho = (messageID: string, text = "hello") => ({ - sessionID: "child", - messageID, - text, - displayText: text, - agent: "build", - model: { providerID: "provider", modelID: "model" }, - comments: [], -}) - -const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ - data, - response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, -}) - -const deferredResponse = () => Promise.withResolvers() - -function messageClient(...responses: Array>) { - let index = 0 - const pages = responses.map((value) => - value instanceof Promise ? value.then(currentPage) : Promise.resolve(currentPage(value)), - ) - const requests: unknown[] = [] - const waiting = new Map void>() - const client = { - session: { - get: async () => sessionInfo(session("child", "root")), - message: async () => { - throw new Error("Unexpected single message request") - }, - }, - message: { - list: async (input: unknown) => { - requests.push(input) - waiting.get(requests.length)?.() - waiting.delete(requests.length) - return pages[index++]! - }, - }, - } as unknown as { session: SessionApi; message: MessageApi } - return Object.assign(client, { - requests, - requested(count: number) { - if (requests.length >= count) return Promise.resolve() - return new Promise((resolve) => waiting.set(count, resolve)) - }, - }) -} - -const retryImmediately: typeof retry = async (task, options = {}) => { - const attempts = options.attempts ?? 3 - for (let attempt = 0; ; attempt++) { - try { - return await task() - } catch (error) { - if (attempt === attempts - 1) throw error - } - } -} - -function setup(sessions: Record) { - const get: unknown[] = [] - const messages: unknown[] = [] - const client = { - session: { - get: async (input: unknown) => { - get.push(input) - const id = (input as { sessionID: string }).sessionID - return sessionInfo(sessions[id]!) - }, - message: async () => { - throw new Error("Unexpected single message request") - }, - }, - message: { - list: async (input: unknown) => { - messages.push(input) - return currentPage(response()) - }, - }, - } as unknown as { session: SessionApi; message: MessageApi } - return { get, messages, store: createServerSession(client) } -} - -describe("server session", () => { - test("hydrates session info after a native session.created event", async () => { - const ctx = setup({ created: session("created") }) - - ctx.store.apply({ - type: "session.created", - properties: { - sessionID: "created", - projectID: "project", - location: { directory: "/repo" }, - slug: "created", - version: "test", - }, - }) - - expect(ctx.store.get("created")).toBeUndefined() - await ctx.store.resolve("created") - expect(ctx.store.get("created")?.location.directory).toBe("/repo") - expect(ctx.get).toEqual([{ sessionID: "created" }]) - }) - - test("projects V2 session events into native and rendering message state", () => { - const ctx = setup({ child: session("child") }) - ctx.store.remember(session("child")) - ctx.store.set("session_message", "child", [ - { - id: "msg_1_user", - type: "user", - text: "hello", - time: { created: 1 }, - }, - ]) - const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) - - apply({ - id: "evt_step", - created: 2, - type: "session.step.started", - durable: { aggregateID: "child", seq: 1, version: 1 }, - location: { directory: "/repo" }, - data: { - sessionID: "child", - assistantMessageID: "msg_2_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - id: "evt_text_start", - created: 3, - type: "session.text.started", - durable: { aggregateID: "child", seq: 2, version: 1 }, - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, - }) - apply({ - id: "evt_text_delta", - created: 4, - type: "session.text.delta", - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, - }) - apply({ - id: "evt_tool_z", - created: 5, - type: "session.tool.input.started", - durable: { aggregateID: "child", seq: 3, version: 1 }, - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" }, - }) - apply({ - id: "evt_tool_a", - created: 6, - type: "session.tool.input.started", - durable: { aggregateID: "child", seq: 4, version: 1 }, - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" }, - }) - - expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ - id: "msg_2_assistant", - type: "assistant", - content: [ - { type: "text", text: "world" }, - { type: "tool", id: "call_z" }, - { type: "tool", id: "call_a" }, - ], - }) - expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) - expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([ - "msg_2_assistant:text:0", - "call_z", - "call_a", - ]) - }) - - test("projects V2 pending inputs and forms", () => { - const ctx = setup({ child: session("child") }) - const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) - - apply({ - id: "evt_admitted", - created: 1, - type: "session.inbox.enqueued", - data: { - sessionID: "child", - inboxID: "msg_input", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - }) - apply({ - id: "evt_form", - created: 2, - type: "form.created", - data: { form: { id: "frm_1", sessionID: "child", title: "Choose", fields: [] } }, - }) - - expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input", delivery: "steer" }]) - expect(ctx.store.data.input.child).toEqual(["msg_input"]) - expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1", title: "Choose" }]) - expect(ctx.store.data.message.child).toMatchObject([{ id: "msg_input" }]) - - apply({ - id: "evt_cancelled", - created: 3, - type: "session.inbox.cancelled", - data: { sessionID: "child", inboxID: "msg_input" }, - }) - apply({ - id: "evt_form_done", - created: 4, - type: "form.cancelled", - data: { sessionID: "child", id: "frm_1" }, - }) - - expect(ctx.store.data.pending.child).toEqual([]) - expect(ctx.store.data.input.child).toEqual([]) - expect(ctx.store.data.form.child).toEqual([]) - expect(ctx.store.data.message.child).toEqual([]) - }) - - test("does not let transient hydration overwrite newer events", async () => { - const ctx = setup({ child: session("child") }) - const response = Promise.withResolvers<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>() - const form: FormInfo = { - id: "frm_1", - sessionID: "child", - title: "Choose", - fields: [{ key: "choice", type: "string" as const, title: "Choice" }], - } - let loads = 0 - const hydration = ctx.store.hydrateTransient("child", () => { - loads += 1 - if (loads === 1) return response.promise - return Promise.resolve({ pending: ctx.store.data.pending.child ?? [], forms: [form] }) - }) - - ctx.store.applyV2({ - id: "evt_admitted", - created: 1, - type: "session.inbox.enqueued", - data: { - sessionID: "child", - inboxID: "msg_input", - item: { type: "user", delivery: "queue", payload: { text: "new" } }, - }, - } as OpenCodeEvent) - response.resolve({ - pending: [], - forms: [form], - }) - await hydration - - expect(ctx.store.data.pending.child).toMatchObject([{ id: "msg_input" }]) - expect(ctx.store.data.form.child).toMatchObject([{ id: "frm_1" }]) - expect(loads).toBe(2) - }) - - test("removes only the compaction input named by the start event", () => { - const ctx = setup({ child: session("child") }) - const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) - apply({ - id: "evt_compaction_inbox", - created: 1, - type: "session.inbox.enqueued", - data: { - sessionID: "child", - inboxID: "msg_compaction", - item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } }, - }, - }) - - expect(ctx.store.data.input.child).toBeUndefined() - expect(ctx.store.data.pending.child).toHaveLength(1) - - apply({ - id: "evt_compaction_inbox_2", - created: 2, - type: "session.inbox.enqueued", - data: { - sessionID: "child", - inboxID: "msg_compaction_2", - item: { type: "compaction", delivery: "queue", payload: { reason: "manual" } }, - }, - }) - apply({ - id: "evt_compaction_ended", - created: 3, - type: "session.compaction.ended", - data: { sessionID: "child", reason: "manual", text: "summary", recent: "recent" }, - }) - - expect(ctx.store.data.pending.child).toHaveLength(2) - - apply({ - id: "evt_compaction_started", - created: 4, - type: "session.compaction.started", - data: { sessionID: "child", inputID: "msg_compaction", reason: "manual" }, - }) - - expect(ctx.store.data.pending.child?.map((item) => item.id)).toEqual(["msg_compaction_2"]) - }) - - test("projects committed revert before server reconciliation", () => { - const ctx = setup({ child: session("child") }) - ctx.store.remember({ ...session("child"), revert: { messageID: "msg_2", partID: "prt_1" } }) - ctx.store.set("input", "child", ["msg_1", "msg_2"]) - ctx.store.set("session_message", "child", [ - { id: "msg_1", type: "user", text: "keep", time: { created: 1 } }, - { id: "msg_2", type: "user", text: "remove", time: { created: 2 } }, - ]) - - ctx.store.applyV2({ - id: "evt_revert", - created: 3, - type: "session.revert.committed", - data: { sessionID: "child", to: "msg_2" }, - } as OpenCodeEvent) - - expect(ctx.store.data.info.child?.revert).toBeUndefined() - expect(ctx.store.data.input.child).toEqual(["msg_1"]) - expect(ctx.store.data.session_message.child?.map((message) => message.id)).toEqual(["msg_1"]) - }) - - test("does not restore a message hydrated before a committed revert", async () => { - const response = Promise.withResolvers() - const store = createServerSession({ - session: { - get: async () => session("child"), - message: () => response.promise, - } as unknown as SessionApi, - message: { - list: async () => currentPage({ data: [], response: { headers: new Headers() } }), - } as unknown as MessageApi, - }) - store.remember(session("child")) - - store.applyV2({ - id: "evt_delivered", - created: 1, - type: "session.inbox.delivered", - data: { sessionID: "child", inboxID: "msg_2" }, - } as OpenCodeEvent) - store.applyV2({ - id: "evt_revert", - created: 2, - type: "session.revert.committed", - data: { sessionID: "child", to: "msg_2" }, - } as OpenCodeEvent) - response.resolve({ id: "msg_2", type: "user", text: "stale", time: { created: 1 } }) - await response.promise - await Bun.sleep(0) - - expect(store.data.session_message.child ?? []).toEqual([]) - }) - - test("resolves lineage by session ID without directory", async () => { - const ctx = setup({ child: session("child", "root"), root: session("root") }) - - const result = await ctx.store.lineage.resolve("child") - - expect(result.root.id).toBe("root") - expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }]) - expect(ctx.store.lineage.peek("child")).toEqual(result) - }) - - test("applies moved session locations without evicting cached state", () => { - const current = { ...session("child"), location: { directory: "/repo/worktree" } } - const ctx = setup({ child: current }) - ctx.store.remember(current) - - ctx.store.applyV2({ - id: "evt_moved", - created: 2, - type: "session.moved", - durable: { aggregateID: "child", seq: 1, version: 1 }, - location: current.location, - data: { sessionID: "child", location: { directory: "/repo" }, projectID: "project", subpath: "packages/app" }, - } satisfies Extract) - - expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" }) - }) - - test("loads session content through the server client", async () => { - const ctx = setup({ root: session("root") }) - - await ctx.store.sync("root") - - expect(ctx.get).toEqual([{ sessionID: "root" }]) - expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }]) - expect(ctx.store.data.message.root).toEqual([]) - }) - - test("reloads cached sessions after reconnect invalidation", async () => { - const ctx = setup({ root: session("root") }) - await ctx.store.sync("root") - - ctx.store.invalidate() - await ctx.store.sync("root") - - expect(ctx.store.data.message.root).toEqual([]) - expect(ctx.get).toHaveLength(2) - expect(ctx.messages).toEqual([ - { sessionID: "root", limit: 200, order: "desc" }, - { sessionID: "root", limit: 200, order: "desc" }, - ]) - }) - - test("keeps a fixed page size after the local message cache exceeds the API limit", async () => { - const client = messageClient(response(), response()) - const store = createServerSession(client) - await store.sync("child") - Array.from({ length: 428 }, (_, index) => - store.apply({ - type: "message.updated", - properties: { info: userMessage(`message-${index}`, { time: { created: index } }) }, - }), - ) - - expect(store.data.message.child).toHaveLength(428) - await store.sync("child", { force: true }) - - expect(client.requests).toEqual([ - { sessionID: "child", limit: 200, order: "desc" }, - { sessionID: "child", limit: 200, order: "desc" }, - ]) - }) - - test("loads current session content through the current message API", async () => { - const requests: unknown[] = [] - const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } - const assistant = { - id: "msg_a_assistant", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "hi" }], - time: { created: 2, completed: 3 }, - } - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return { data: [assistant, user], cursor: { previous: null, next: null } } - }, - } as unknown as MessageApi - const store = createServerSession({} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }]) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - }) - - test("preserves assistant content order from message history", async () => { - const source = [ - { id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } }, - { - id: "msg_assistant", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [ - { type: "text", text: "I will inspect it." }, - { - type: "tool", - id: "call_z", - name: "shell", - state: { status: "streaming", input: "" }, - time: { created: 2 }, - }, - { - type: "tool", - id: "call_a", - name: "shell", - state: { status: "streaming", input: "" }, - time: { created: 3 }, - }, - ], - time: { created: 2 }, - }, - ] satisfies SessionMessageInfo[] - const messageApi = { - list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }), - } as unknown as MessageApi - const store = createServerSession({} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"]) - }) - - test("extends a current page to include the user for split assistant turns", async () => { - const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const - const assistant = (id: string, created: number) => ({ - id, - type: "assistant" as const, - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text" as const, text: id }], - time: { created, completed: created }, - }) - const assistants = [ - assistant("msg_2_assistant", 2), - assistant("msg_3_assistant", 3), - assistant("msg_4_assistant", 4), - ] - const pages = [ - { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, - { data: [assistants[0], user], cursor: { previous: null, next: null } }, - ] - const requests: unknown[] = [] - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return pages.shift()! - }, - } as unknown as MessageApi - const store = createServerSession({} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([ - { sessionID: "root", limit: 200, order: "desc" }, - { sessionID: "root", limit: 200, cursor: "older" }, - ]) - expect(store.data.message.root.map((message) => message.id)).toEqual([ - user.id, - ...assistants.map((item) => item.id), - ]) - expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) - }) - - test("loads older messages by cursor with the fixed page size", async () => { - const older = userMessage("message-1") - const latest = userMessage("message-2", { time: { created: 2 } }) - const client = messageClient( - response([{ info: latest, parts: [] }], "older"), - response([{ info: older, parts: [] }]), - ) - const store = createServerSession(client) - await store.sync("child") - - await store.history.loadMore("child") - - expect(client.requests).toEqual([ - { sessionID: "child", limit: 200, order: "desc" }, - { sessionID: "child", limit: 200, cursor: "older" }, - ]) - expect(store.data.message.child).toEqual([older, latest]) - expect(store.history.more("child")).toBe(false) - }) - - test("merges live events into the initial page", async () => { - const pending = deferredResponse() - const user = userMessage("message-1") - const live = userMessage("message-2", { time: { created: 2 } }) - const livePart = textPart(live.id, { text: "live" }) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) - pending.resolve(response([{ info: user, parts: [] }])) - await loading - - expect(store.data.message.child).toEqual([user, live]) - expect(store.data.part[live.id]).toEqual([livePart]) - }) - - test("preserves same-ID live updates over the initial page", async () => { - const pending = deferredResponse() - const fetched = userMessage("message") - const fetchedPart = textPart(fetched.id, { text: "fetched" }) - const live = { ...fetched, time: { created: 2 } } - const livePart = { ...fetchedPart, text: "live" } - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) - pending.resolve(response([{ info: fetched, parts: [fetchedPart] }])) - await loading - - expect(store.data.message.child).toEqual([live]) - expect(store.data.part[live.id]).toEqual([livePart]) - }) - - test("preserves removals received during the initial load", async () => { - const pending = deferredResponse() - const removed = userMessage("message-1") - const kept = { ...removed, id: "message-2" } - const part = textPart(kept.id, { text: "removed" }) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } }) - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: kept.id, partID: part.id }, - }) - pending.resolve( - response([ - { info: removed, parts: [] }, - { info: kept, parts: [part] }, - ]), - ) - await loading - - expect(store.data.message.child).toEqual([kept]) - expect(store.data.part[kept.id]).toBeUndefined() - }) - - test("keeps removal tracking isolated across load generations", async () => { - const firstResponse = deferredResponse() - const secondResponse = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) - const first = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ - type: "session.deleted", - properties: { sessionID: "child", info: session("child", "root") }, - }) - const second = store.sync("child") - - firstResponse.resolve(response()) - await first - secondResponse.resolve(response([{ info: message, parts: [] }])) - await second - - expect(store.data.message.child).toEqual([message]) - }) - - test("tracks removals in a replacement load generation", async () => { - const firstResponse = deferredResponse() - const secondResponse = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) - const first = store.sync("child") - store.apply({ - type: "session.deleted", - properties: { sessionID: "child", info: session("child", "root") }, - }) - const second = store.sync("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - firstResponse.resolve(response()) - await first - secondResponse.resolve(response([{ info: message, parts: [] }])) - await second - - expect(store.data.message.child).toEqual([]) - }) - - test("preserves remove then re-add when a refresh omits the message", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.updated", properties: { info: message } }) - pending.resolve(response()) - await refreshing - - expect(store.data.message.child).toEqual([message]) - }) - - test("preserves a re-added message without restoring removed parts", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.updated", properties: { info: message } }) - pending.resolve(response([{ info: message, parts: [part] }])) - await refreshing - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("drops stale event content omitted by a complete initial page", async () => { - const stale = userMessage("stale") - const store = createServerSession(messageClient(response())) - store.apply({ type: "message.updated", properties: { info: stale } }) - - await store.sync("child") - - expect(store.data.message.child).toEqual([]) - }) - - test("preserves event content outside an incomplete initial page", async () => { - const live = userMessage("message-1") - const fetched = userMessage("message-2", { time: { created: 2 } }) - const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older"))) - store.apply({ type: "message.updated", properties: { info: live } }) - - await store.sync("child") - - expect(store.data.message.child).toEqual([live, fetched]) - }) - - test("echoes a prompt without changing durable message order", () => { - const store = setup({ child: session("child") }).store - - store.inbox.echo({ - ...promptEcho("msg_prompt"), - text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this", - files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }], - agents: [{ name: "explore" }], - comments: [ - { - path: "src/foo.ts", - selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 }, - comment: "check this", - preview: "const value = 1", - origin: "review", - }, - ], - }) - - expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }]) - expect(store.data.input.child).toEqual(["msg_prompt"]) - expect(store.data.session_message.child).toBeUndefined() - expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"]) - expect(store.data.part.msg_prompt).toMatchObject([ - { id: "msg_prompt:text:0", type: "text", text: "hello" }, - { id: "msg_prompt:file:0", type: "file", filename: "foo.ts" }, - { id: "msg_prompt:agent:0", type: "agent", name: "explore" }, - { - id: "msg_prompt:comment:0", - type: "text", - synthetic: true, - metadata: { - opencodeComment: { - path: "src/foo.ts", - selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 }, - comment: "check this", - preview: "const value = 1", - origin: "review", - }, - }, - }, - ]) - - store.applyV2({ - id: "evt_prompt", - created: 2, - type: "session.inbox.enqueued", - durable: { aggregateID: "child", seq: 1, version: 1 }, - data: { - sessionID: "child", - inboxID: "msg_prompt", - item: { - type: "user", - delivery: "steer", - payload: { - text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this", - }, - }, - }, - } as OpenCodeEvent) - - expect(store.data.part.msg_prompt).toMatchObject([ - { id: "msg_prompt:text:0", type: "text", text: "hello" }, - { id: "msg_prompt:comment:0", type: "text", synthetic: true }, - ]) - }) - - test("preserves a local echo while message history omits pending input", async () => { - const store = createServerSession(messageClient(response())) - store.inbox.echo(promptEcho("msg_prompt")) - store.inbox.confirm({ - id: "msg_prompt", - sessionID: "child", - timeCreated: 1, - type: "user", - delivery: "steer", - payload: { text: "hello" }, - }) - - await store.sync("child") - - expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"]) - expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }]) - }) - - test("preserves local comment presentation through message refresh", async () => { - const note = "The user made the following comment regarding line 4 of src/foo.ts: check this" - const message = userMessage("msg_prompt") - const store = createServerSession( - messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])), - ) - store.inbox.echo({ - ...promptEcho(message.id), - text: `hello\n${note}`, - comments: [ - { - path: "src/foo.ts", - selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 }, - comment: "check this", - origin: "review", - }, - ], - }) - - await store.sync("child") - - expect(store.data.part.msg_prompt).toMatchObject([ - { id: "msg_prompt:text:0", type: "text", text: "hello" }, - { id: "msg_prompt:comment:0", type: "text", synthetic: true }, - ]) - }) - - test("retires an admitted echo absent from authoritative reconnect state", async () => { - const store = createServerSession(messageClient(response())) - store.inbox.echo(promptEcho("msg_prompt")) - store.inbox.confirm({ - id: "msg_prompt", - sessionID: "child", - timeCreated: 1, - type: "user", - delivery: "steer", - payload: { text: "hello" }, - }) - - await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))]) - store.inbox.reconcile("child") - - expect(store.data.pending.child).toEqual([]) - expect(store.data.message.child).toEqual([]) - expect(store.data.part.msg_prompt).toBeUndefined() - }) - - test("retires a stale enqueued message when inbox hydration finishes after history", async () => { - const store = createServerSession(messageClient(response())) - store.applyV2({ - id: "evt_prompt", - created: 1, - type: "session.inbox.enqueued", - durable: { aggregateID: "child", seq: 1, version: 1 }, - data: { - sessionID: "child", - inboxID: "msg_prompt", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - } as OpenCodeEvent) - - await store.sync("child") - await store.hydrateTransient("child", async () => ({ pending: [], forms: [] })) - store.inbox.reconcile("child") - - expect(store.data.pending.child).toEqual([]) - expect(store.data.session_message.child).toEqual([]) - expect(store.data.message.child).toEqual([]) - expect(store.data.part.msg_prompt).toBeUndefined() - }) - - test("deduplicates the durable admission event against its local echo", () => { - const store = setup({ child: session("child") }).store - store.inbox.echo(promptEcho("msg_prompt")) - - store.applyV2({ - id: "evt_prompt", - created: 2, - type: "session.inbox.enqueued", - durable: { aggregateID: "child", seq: 1, version: 1 }, - data: { - sessionID: "child", - inboxID: "msg_prompt", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - } as OpenCodeEvent) - - expect(store.data.pending.child).toHaveLength(1) - expect(store.data.input.child).toEqual(["msg_prompt"]) - expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1) - expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1) - expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }]) - }) - - test("uses the prompt response when the admission event was missed", () => { - const store = setup({ child: session("child") }).store - store.inbox.echo(promptEcho("msg_prompt")) - store.inbox.confirm({ - id: "msg_prompt", - sessionID: "child", - timeCreated: 2, - type: "user", - delivery: "steer", - payload: { text: "hello" }, - }) - - store.applyV2({ - id: "evt_delivered", - created: Date.now() + 1, - type: "session.inbox.delivered", - durable: { aggregateID: "child", seq: 2, version: 1 }, - data: { sessionID: "child", inboxID: "msg_prompt" }, - } as OpenCodeEvent) - - expect(store.data.pending.child).toEqual([]) - expect(store.data.input.child).toEqual([]) - expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }]) - expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1) - expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }]) - }) - - test("keeps a durable admission when the HTTP request later fails", () => { - const store = setup({ child: session("child") }).store - store.inbox.echo(promptEcho("msg_prompt")) - store.applyV2({ - id: "evt_prompt", - created: 2, - type: "session.inbox.enqueued", - durable: { aggregateID: "child", seq: 1, version: 1 }, - data: { - sessionID: "child", - inboxID: "msg_prompt", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - } as OpenCodeEvent) - - expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false) - expect(store.data.pending.child).toHaveLength(1) - expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"]) - }) - - test("places durable admission after delayed selection events", () => { - const store = setup({ child: session("child") }).store - store.remember(session("child")) - store.inbox.echo(promptEcho("msg_prompt")) - store.applyV2({ - id: "evt_agent", - created: 1, - type: "session.agent.selected", - durable: { aggregateID: "child", seq: 1, version: 1 }, - data: { sessionID: "child", agent: "review" }, - } as OpenCodeEvent) - store.applyV2({ - id: "evt_model", - created: 2, - type: "session.model.selected", - durable: { aggregateID: "child", seq: 2, version: 1 }, - data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } }, - } as OpenCodeEvent) - store.applyV2({ - id: "evt_prompt", - created: 3, - type: "session.inbox.enqueued", - durable: { aggregateID: "child", seq: 3, version: 1 }, - data: { - sessionID: "child", - inboxID: "msg_prompt", - item: { type: "user", delivery: "steer", payload: { text: "hello" } }, - }, - } as OpenCodeEvent) - - expect(store.data.session_message.child?.map((message) => message.type)).toEqual([ - "agent-switched", - "model-switched", - "user", - ]) - expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({ - agent: "review", - model: { providerID: "new-provider", modelID: "new-model" }, - }) - }) - - test("removes an echoed prompt when submission fails", () => { - const store = setup({ child: session("child") }).store - store.inbox.echo(promptEcho("msg_prompt")) - - expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true) - - expect(store.data.pending.child).toEqual([]) - expect(store.data.input.child).toEqual([]) - expect(store.data.session_message.child).toBeUndefined() - expect(store.data.message.child).toEqual([]) - expect(store.data.part.msg_prompt).toBeUndefined() - }) - - test("removes a response-confirmed echo when the server cancels it", () => { - const store = setup({ child: session("child") }).store - store.inbox.echo(promptEcho("msg_prompt")) - store.inbox.confirm({ - id: "msg_prompt", - sessionID: "child", - timeCreated: 1, - type: "user", - delivery: "steer", - payload: { text: "hello" }, - }) - - store.applyV2({ - id: "evt_cancelled", - created: 2, - type: "session.inbox.cancelled", - durable: { aggregateID: "child", seq: 2, version: 1 }, - data: { sessionID: "child", inboxID: "msg_prompt" }, - } as OpenCodeEvent) - - expect(store.data.pending.child).toEqual([]) - expect(store.data.message.child).toEqual([]) - expect(store.data.part.msg_prompt).toBeUndefined() - }) - - test("clears stale parts when the initial page has none", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(pending.promise)) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) - const loading = store.sync("child") - - pending.resolve(response([{ info: message, parts: [] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("clears delta buffers for parts omitted by the initial page", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const kept = textPart(message.id, { id: "part-1", text: "kept" }) - const removed: Part = { ...kept, id: "part-2", text: "removed" } - const store = createServerSession(messageClient(pending.promise)) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" }, - }) - const loading = store.sync("child") - - pending.resolve(response([{ info: message, parts: [kept] }])) - await loading - - expect(store.data.part[message.id]).toEqual([kept]) - expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined() - }) - - test("clears a stale delta buffer when a refresh replaces its part", async () => { - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const fetched = { ...stale, text: "fetched" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() - }) - - test("preserves a non-durable delta received before refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) - expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta") - }) - - test("accepts fetched text that intentionally replaces an accumulated prefix", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "abc" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("preserves an unpersisted delta suffix after partial server catch-up", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "a" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }]) - expect(store.data.part_text_accum_delta[part.id]).toBe("abc") - }) - - test("clears delta state after exact server catch-up", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "a" }) - const fetched = { ...part, text: "ab" } - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toEqual([fetched]) - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("uses the successful retry response over events from a failed attempt", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const intermediate = { ...stale, text: "intermediate" } - const fetched = { ...stale, text: "fetched" } - const client = messageClient(failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } }) - const loading = store.sync("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } }) - failed.reject(new Error("failed to fetch")) - await client.requested(2) - retried.resolve(response([{ info: message, parts: [fetched] }])) - await loading - - expect(store.data.part[message.id]).toEqual([fetched]) - }) - - test("preserves non-durable deltas across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const client = messageClient(failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) - const loading = store.sync("child") - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(2) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) - }) - - test("preserves part removals across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: message.id, partID: part.id }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves message removals across message retries", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("accepts part omission from a successful retry after an earlier delta", async () => { - const failed = Promise.withResolvers() - const retried = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) - const store = createServerSession(client, { retry: retryImmediately }) - await store.sync("child") - const loading = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - failed.reject(new Error("failed to fetch")) - await client.requested(3) - retried.resolve(response([{ info: message, parts: [] }])) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("clears load-owned orphan parts when all retries fail", async () => { - const first = Promise.withResolvers() - const second = Promise.withResolvers() - const third = Promise.withResolvers() - const message = userMessage("message") - const part = textPart(message.id) - const client = messageClient(first.promise, second.promise, third.promise) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child").catch((error) => error) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - first.reject(new Error("failed to fetch")) - await client.requested(2) - second.reject(new Error("failed to fetch")) - await client.requested(3) - third.reject(new Error("failed to fetch")) - await loading - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves live updates during a forced refresh", async () => { - const pending = deferredResponse() - const stale = userMessage("message") - const stalePart = textPart(stale.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - const live = { ...stale, time: { created: 2 } } - - store.apply({ type: "message.updated", properties: { info: live } }) - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" }, - }) - pending.resolve(response([{ info: stale, parts: [stalePart] }])) - await refreshing - - expect(store.data.message.child).toEqual([live]) - expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }]) - }) - - test("keeps fetched message metadata when only a part changes", async () => { - const pending = deferredResponse() - const stale = userMessage("message") - const fetched = { ...stale, time: { created: 2 } } - const part = textPart(stale.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" }, - }) - pending.resolve(response([{ info: fetched, parts: [part] }])) - await refreshing - - expect(store.data.message.child).toEqual([fetched]) - expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }]) - }) - - test("preserves a part update when a forced refresh omits its message", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const stale = textPart(message.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise)) - await store.sync("child") - const refreshing = store.sync("child", { force: true }) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - pending.resolve(response()) - await refreshing - - expect(store.data.message.child).toEqual([message]) - expect(store.data.part[message.id]).toEqual([live]) - }) - - test("ignores a late part update after its message is removed", async () => { - const pending = deferredResponse() - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession(messageClient(pending.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - pending.resolve(response([{ info: message, parts: [part] }])) - await loading - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("ignores a late part update after a completed message removal", () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = setup({ child: session("child") }).store - store.apply({ type: "message.updated", properties: { info: message } }) - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not restore a completed message removal from a stale refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), - ) - await store.sync("child") - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("does not restore a completed part removal from a stale refresh", async () => { - const message = userMessage("message") - const part = textPart(message.id) - const store = createServerSession( - messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), - ) - await store.sync("child") - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: message.id, partID: part.id }, - }) - - await store.sync("child", { force: true }) - - expect(store.data.part[message.id]).toBeUndefined() - }) - - test("preserves removals during history prepend", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = { ...latest, id: "message-1", time: { created: 1 } } - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } }) - pending.resolve(response([{ info: older, parts: [] }])) - await loading - - expect(store.data.message.child).toEqual([latest]) - }) - - test("does not scan cached messages for user roots during history prepend", async () => { - const guard = { active: false } - const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), { - get(target, property, receiver) { - if (guard.active && property === "role") throw new Error("cached role accessed") - return Reflect.get(target, property, receiver) - }, - }) - const older = userMessage("message-1") - const store = createServerSession( - messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])), - ) - await store.sync("child") - guard.active = true - - await store.history.loadMore("child") - - guard.active = false - expect(store.data.message.child).toEqual([older, latest]) - }) - - test("preserves loaded history during an incomplete refresh", async () => { - const older = userMessage("message-1") - const latest = userMessage("message-2", { time: { created: 2 } }) - const fresh = userMessage("message-3", { time: { created: 3 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: older, parts: [] }, - { info: latest, parts: [] }, - ], - "older", - ), - response( - [ - { info: latest, parts: [] }, - { info: fresh, parts: [] }, - ], - "older", - ), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([older, latest, fresh]) - }) - - test("drops stale recent messages omitted by an incomplete refresh", async () => { - const third = userMessage("message-3", { time: { created: 3 } }) - const fourth = userMessage("message-4", { time: { created: 4 } }) - const stale = userMessage("message-5", { time: { created: 5 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: fourth, parts: [] }, - { info: stale, parts: [] }, - ], - "older", - ), - response( - [ - { info: third, parts: [] }, - { info: fourth, parts: [] }, - ], - "older", - ), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([third, fourth]) - }) - - test("uses message creation time for incomplete refresh boundaries", async () => { - const older = userMessage("msg_z", { time: { created: 1 } }) - const boundary = userMessage("msg_m", { time: { created: 2 } }) - const stale = userMessage("msg_a", { time: { created: 3 } }) - const store = createServerSession( - messageClient( - response( - [ - { info: older, parts: [] }, - { info: stale, parts: [] }, - ], - "older", - ), - response([{ info: boundary, parts: [] }], "older"), - ), - ) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([older, boundary]) - }) - - test("preserves a part update for a message being loaded from history", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const stale = textPart(older.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - pending.resolve(response([{ info: older, parts: [stale] }])) - await loading - - expect(store.data.part[older.id]).toEqual([live]) - }) - - test("does not clear newer orphan parts after terminal history prepend", async () => { - const pending = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const newer = userMessage("message-3", { time: { created: 3 } }) - const part = textPart(newer.id, { text: "live" }) - const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) - await store.sync("child") - const loading = store.history.loadMore("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } }) - pending.resolve(response([{ info: older, parts: [] }])) - await loading - store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } }) - - expect(store.data.part[newer.id]).toEqual([part]) - }) - - test("accepts an authoritative history part after an earlier unknown-parent update", async () => { - const pending = deferredResponse() - const history = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const part = textPart(older.id, { text: "live" }) - const store = createServerSession(messageClient(pending.promise, history.promise)) - const loading = store.sync("child") - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) - pending.resolve(response([{ info: latest, parts: [] }], "older")) - await loading - - expect(store.data.part[older.id]).toEqual([part]) - - const loadingHistory = store.history.loadMore("child") - history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }])) - await loadingHistory - - expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }]) - }) - - test("preserves an unknown-parent part removal across pages", async () => { - const initial = deferredResponse() - const history = deferredResponse() - const latest = userMessage("message-2", { time: { created: 2 } }) - const older = userMessage("message-1") - const part = textPart(older.id) - const store = createServerSession(messageClient(initial.promise, history.promise)) - const loading = store.sync("child") - - store.apply({ - type: "message.part.removed", - properties: { sessionID: "child", messageID: older.id, partID: part.id }, - }) - initial.resolve(response([{ info: latest, parts: [] }], "older")) - await loading - const loadingHistory = store.history.loadMore("child") - history.resolve(response([{ info: older, parts: [part] }])) - await loadingHistory - - expect(store.data.part[older.id]).toBeUndefined() - }) - - test("clears orphaned parts when a refresh drops a message", async () => { - const message = userMessage("message") - const part = textPart(message.id, { text: "stale" }) - const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response())) - await store.sync("child") - store.apply({ - type: "message.part.delta", - properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, - }) - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([]) - expect(store.data.part[message.id]).toBeUndefined() - expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() - }) - - test("applies events without a directory store", () => { - const ctx = setup({}) - ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) - ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) - - expect(ctx.store.get("root")?.location.directory).toBe("/repo") - expect(ctx.store.data.session_working("root")).toBe(true) - expect(ctx.get).toEqual([]) - }) - - test("preserves pinned session content under server-wide cache pressure", () => { - const ctx = setup({}) - ctx.store.pin("active") - ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" }) - - for (let index = 0; index < 50; index++) { - ctx.store.remember(session(`session-${index}`)) - ctx.store.apply({ - type: "session.status", - properties: { sessionID: `session-${index}`, status: { type: "idle" } }, - }) - } - - expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) - expect(ctx.store.data.session_status["session-0"]).toBeUndefined() - }) -}) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts deleted file mode 100644 index 1b7535e412..0000000000 --- a/packages/app/src/context/server-session.ts +++ /dev/null @@ -1,1468 +0,0 @@ -import { Binary } from "@opencode-ai/core/util/binary" -import { Worktree } from "@opencode-ai/schema/worktree" -import { retry } from "@opencode-ai/core/util/retry" -import type { - FormInfo, - OpenCodeEvent, - SessionApi, - SessionInfo, - SessionInboxInfo, - SessionMessageInfo, -} from "@opencode-ai/client/promise" -import type { Message, Part, Todo } from "@/types" -import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise" -import { batch } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" -import { rootSession } from "@/utils/session-route" -import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message" -import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" -import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" -import type { ServerApi } from "@/utils/server" -import { - createCommentMetadata, - formatCommentNote, - parseCommentNote, - readCommentMetadata, - type PromptComment, -} from "@/utils/comment-note" - -type MessageApi = ServerApi["message"] - -const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) -const messagePageSize = 200 -const sessionInfoLimit = 2_048 -const emptyIDs: ReadonlySet = new Set() - -function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { - const boundary = source.find( - (message) => - message.type === "user" || - message.type === "shell" || - message.type === "assistant" || - (message.type === "synthetic" && message.description?.trim()), - ) - return boundary?.type === "assistant" -} - -type MessagePage = { - session: Message[] - part: { id: string; part: Part[] }[] - source?: SessionMessageInfo[] - sourceMode?: "latest" | "older" - projectSource?: boolean - cursor?: string - complete: boolean -} - -export type PromptEcho = { - sessionID: string - messageID: string - text: string - displayText: string - agent: string - model: { providerID: string; modelID: string; variant?: string } - files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[] - agents?: { name: string; mention?: { start: number; end: number; text: string } }[] - comments: PromptComment[] -} - -// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. -type MessageLoadState = { - touchedMessages: Set - removedMessages: Set - retainedMessages: Set - touchedParts: Map> - deltaParts: Map> - carriedDeltaParts: Map> - removedParts: Map> - orphanParents: Set - clearedMessageParts: Set - touchedSource: Set -} - -type MessageLoadBaseline = Pick< - MessageLoadState, - "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" -> - -function runInflight(map: Map>, key: string, task: () => Promise) { - const pending = map.get(key) - if (pending) return pending - const promise = task().finally(() => { - if (map.get(key) === promise) map.delete(key) - }) - map.set(key, promise) - return promise -} - -function merge(a: readonly T[], b: readonly T[]) { - const items = new Map(a.map((item) => [item.id, item] as const)) - for (const item of b) items.set(item.id, item) - return [...items.values()].sort((x, y) => cmp(x.id, y.id)) -} - -function reconcileFetched( - fetched: T[], - current: readonly T[], - options: { - touched?: ReadonlySet - retained?: ReadonlySet - removed?: ReadonlySet - preserveUnfetched?: boolean | ((item: T) => boolean) - compare?: (a: T, b: T) => number - } = {}, -) { - const result = new Map(fetched.map((item) => [item.id, item])) - const live = new Map(current.map((item) => [item.id, item])) - if (options.preserveUnfetched) { - for (const item of current) { - if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item))) - result.set(item.id, item) - } - } - for (const id of options.retained ?? emptyIDs) { - if (result.has(id)) continue - const item = live.get(id) - if (item) result.set(id, item) - } - // Events observed while the request is pending are the freshest client state for those identities. - for (const id of options.touched ?? emptyIDs) { - const item = live.get(id) - if (item) result.set(id, item) - if (!item) result.delete(id) - } - for (const id of options.removed ?? emptyIDs) result.delete(id) - const items = [...result.values()] - return options.compare ? items.sort(options.compare) : items -} - -type ServerSessionOptions = { retry?: typeof retry } -type ServerSessionApis = { session: SessionApi; message: MessageApi } - -export function createServerSession( - api: SessionApi | ServerSessionApis, - messageApiOrOptions?: MessageApi | ServerSessionOptions, - currentOptions?: ServerSessionOptions, -) { - const bundled = "session" in api - const sessionApi = bundled ? api.session : api - const messageApi = bundled ? api.message : (messageApiOrOptions as MessageApi) - const options = bundled ? (messageApiOrOptions as ServerSessionOptions | undefined) : currentOptions - const [data, setData] = createStore({ - info: {} as Record, - session_status: {} as Record, - session_diff: {} as Record, - todo: {} as Record, - permission: {} as Record, - form: {} as Record, - pending: {} as Record, - input: {} as Record, - message: {} as Record, - session_message: {} as Record, - // Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only. - part: {} as Record, - part_text_accum_delta: {} as Record, - session_working(id: string) { - return (this.session_status[id]?.type ?? "idle") !== "idle" - }, - }) - const requests = new Map>() - const inflight = new Map>() - const inflightTodo = new Map>() - const v2 = createV2SessionReducer() - const pendingRevision = new Map() - const formRevision = new Map() - const messageHydrationRevision = new Map() - const invalidated = new Set() - let invalidationRevision = 0 - const messageLoads = new Map() - const pendingParts = new Map>>() - const orphanParts = new Map>() - const removedMessages = new Map>() - const echoes = new Map>() - const messageSnapshots = new Map>() - const settledInputs = new Map>() - const deltaBases = new Map() - const markEcho = (sessionID: string, messageID: string) => { - const messages = echoes.get(sessionID) ?? new Map() - messages.set(messageID, "sending") - echoes.set(sessionID, messages) - } - const confirmEcho = (sessionID: string, messageID: string) => { - const messages = echoes.get(sessionID) - if (!messages?.has(messageID)) return false - messages.set(messageID, "admitted") - return true - } - const releaseEcho = (sessionID: string, messageID: string) => { - const messages = echoes.get(sessionID) - const state = messages?.get(messageID) - if (!messages || !state) return - messages.delete(messageID) - if (messages.size === 0) echoes.delete(sessionID) - return state - } - const present = (messageID: string, parts: Part[]) => { - const local = data.part[messageID] ?? [] - const comments = local.filter( - (part) => - part.type === "text" && - part.synthetic && - (readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined), - ) - if (!comments.length) return parts - const text = local.find((part) => part.type === "text" && !part.synthetic) - const projected = parts.flatMap((part) => { - if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part] - return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : [] - }) - return [...projected, ...comments] - } - const deleteMessageParts = ( - cache: { part: Record; part_text_accum_delta: Record }, - messageID: string, - ) => { - for (const part of cache.part[messageID] ?? []) { - delete cache.part_text_accum_delta[part.id] - deltaBases.delete(part.id) - } - delete cache.part[messageID] - } - const seen = new Set() - const infoSeen = new Set() - const pinned = new Map() - const generations = new Map() - const generation = (sessionID: string) => { - const current = generations.get(sessionID) - if (current) return current - const created = {} - generations.set(sessionID, created) - return created - } - const [meta, setMeta] = createStore({ - cursor: {} as Record, - complete: {} as Record, - loading: {} as Record, - at: {} as Record, - }) - - const remember = (session: SessionInfo) => { - setData("info", session.id, reconcile(session)) - infoSeen.delete(session.id) - infoSeen.add(session.id) - if (infoSeen.size > sessionInfoLimit) { - const preserve = new Set([ - ...pinned.keys(), - ...requests.keys(), - ...inflight.keys(), - ...inflightTodo.keys(), - ...messageLoads.keys(), - ...echoes.keys(), - ...Object.entries(data.permission) - .filter(([, items]) => items.length > 0) - .map(([sessionID]) => sessionID), - ...Object.entries(data.form) - .filter(([, items]) => items.length > 0) - .map(([sessionID]) => sessionID), - ...Object.entries(data.session_status) - .filter(([, status]) => status.type !== "idle") - .map(([sessionID]) => sessionID), - ]) - for (const sessionID of preserve) { - let current = data.info[sessionID] - while (current) { - preserve.add(current.id) - current = current.parentID ? data.info[current.parentID] : undefined - } - } - const stale: string[] = [] - for (const sessionID of infoSeen) { - if (infoSeen.size - stale.length <= sessionInfoLimit) break - if (!preserve.has(sessionID)) stale.push(sessionID) - } - stale.forEach((sessionID) => infoSeen.delete(sessionID)) - stale.forEach((sessionID) => generations.delete(sessionID)) - setData( - "info", - produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), - ) - } - return session - } - - const resolve = (sessionID: string, options?: { force?: boolean }) => { - const cached = data.info[sessionID] - if (cached && !options?.force) return Promise.resolve(cached) - const pending = requests.get(sessionID) - if (pending) return pending - const active = generation(sessionID) - const request = sessionApi.get({ sessionID }) - const resolved = request.then((result) => { - if (generations.get(sessionID) !== active) return result - return remember(result) - }) - requests.set(sessionID, resolved) - const cleanup = () => { - if (requests.get(sessionID) === resolved) requests.delete(sessionID) - if ( - generations.get(sessionID) === active && - !data.info[sessionID] && - !requests.has(sessionID) && - !messageLoads.has(sessionID) && - !inflight.has(sessionID) && - !inflightTodo.has(sessionID) - ) - generations.delete(sessionID) - } - void resolved.then(cleanup, cleanup) - return resolved - } - - const peekLineage = (sessionID: string) => { - const session = data.info[sessionID] - if (!session) return - const seen = new Set([session.id]) - let root = session - while (root.parentID) { - if (seen.has(root.parentID)) throw new Error(`Session parent cycle: ${root.parentID}`) - seen.add(root.parentID) - const parent = data.info[root.parentID] - if (!parent) return - root = parent - } - return { session, root } - } - - const trackPartChange = (sessionID: string, messageID: string, partID: string) => { - const load = messageLoads.get(sessionID) - if (!load) return - // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. - const messages = data.message[sessionID] - if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) - const parts = load.touchedParts.get(messageID) - if (parts) { - parts.add(partID) - return - } - load.touchedParts.set(messageID, new Set([partID])) - } - - const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => { - load.touchedMessages.clear() - load.retainedMessages.clear() - load.touchedParts.clear() - load.carriedDeltaParts.clear() - load.clearedMessageParts.clear() - for (const messageID of load.removedMessages) { - load.touchedMessages.add(messageID) - load.clearedMessageParts.add(messageID) - } - for (const [messageID, parts] of load.deltaParts) { - load.touchedParts.set(messageID, new Set(parts)) - load.carriedDeltaParts.set(messageID, new Set(parts)) - const messages = data.message[sessionID] - if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) - } - for (const [messageID, parts] of load.removedParts) { - const touched = load.touchedParts.get(messageID) ?? new Set() - parts.forEach((partID) => touched.add(partID)) - load.touchedParts.set(messageID, touched) - const messages = data.message[sessionID] - if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) - } - baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) - baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) - baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) - baseline?.touchedParts.forEach((parts, messageID) => { - const touched = load.touchedParts.get(messageID) ?? new Set() - parts.forEach((partID) => touched.add(partID)) - load.touchedParts.set(messageID, touched) - }) - } - - const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({ - touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)), - retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)), - touchedParts: new Map( - [...load.touchedParts] - .filter(([messageID]) => messageID !== exclude) - .map(([messageID, parts]) => [messageID, new Set(parts)]), - ), - clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)), - }) - - const evict = (sessionIDs: string[]) => { - if (sessionIDs.length === 0) return - const evicted = new Set(sessionIDs) - for (const [partID, item] of deltaBases) { - if (evicted.has(item.sessionID)) deltaBases.delete(partID) - } - sessionIDs.forEach((sessionID) => { - messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) - generations.delete(sessionID) - echoes.delete(sessionID) - messageSnapshots.delete(sessionID) - settledInputs.delete(sessionID) - requests.delete(sessionID) - inflight.delete(sessionID) - inflightTodo.delete(sessionID) - messageLoads.delete(sessionID) - v2.clear(sessionID) - pendingParts.delete(sessionID) - orphanParts.delete(sessionID) - removedMessages.delete(sessionID) - }) - setData( - produce((draft) => { - dropSessionCaches(draft, sessionIDs) - }), - ) - setMeta( - produce((draft) => { - for (const sessionID of sessionIDs) { - delete draft.cursor[sessionID] - delete draft.complete[sessionID] - delete draft.loading[sessionID] - delete draft.at[sessionID] - } - }), - ) - } - - const protectedSessions = () => - new Set([ - ...pinned.keys(), - ...requests.keys(), - ...inflight.keys(), - ...inflightTodo.keys(), - ...messageLoads.keys(), - ...echoes.keys(), - ...Object.entries(data.permission) - .filter(([, items]) => items.length > 0) - .map(([sessionID]) => sessionID), - ...Object.entries(data.form) - .filter(([, items]) => items.length > 0) - .map(([sessionID]) => sessionID), - ...Object.entries(data.session_status) - .filter(([, status]) => status.type !== "idle") - .map(([sessionID]) => sessionID), - ]) - - const touch = (sessionID: string) => - evict( - pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), - ) - - const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => { - const request = (cursor?: string) => - (options?.retry ?? retry)(() => { - onAttempt?.() - return messageApi.list( - cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" }, - ) - }) - const first = await request(before) - const pages = [first] - while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { - const response = await request(pages.at(-1)!.cursor.next ?? undefined) - pages.push(response) - if (!response.data.length) break - } - const response = pages.at(-1)! - const source = pages.flatMap((page) => page.data).toReversed() - const normalized = normalizeSessionMessages(sessionID, source) - return { - session: normalized.messages.sort(compareMessages), - part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)), - source, - sourceMode: before ? ("older" as const) : ("latest" as const), - projectSource: true, - cursor: response.cursor.next ?? undefined, - complete: !response.cursor.next, - } - } - - const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { - const response = await (options?.retry ?? retry)(() => { - onAttempt?.() - return sessionApi.message({ sessionID, messageID }) - }) - const normalized = normalizeSessionMessages(sessionID, [response]) - const message = normalized.messages[0] - if (!message) throw new Error(`Message not found: ${messageID}`) - return { message, parts: normalized.parts.get(messageID) ?? [] } - } - - const replaceMessages = (sessionID: string, messages: Message[]) => { - const messageIDs = new Set(messages.map((message) => message.id)) - const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) - setData("message", sessionID, reconcile(messages, { key: "id" })) - setData( - produce((draft) => { - for (const message of dropped) deleteMessageParts(draft, message.id) - }), - ) - return messageIDs - } - - const replaceParts = ( - sessionID: string, - items: MessagePage["part"], - messageIDs: Set, - load?: MessageLoadState, - ) => { - for (const item of items) { - if (!messageIDs.has(item.id)) continue - const fetched = present( - item.id, - load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)), - ) - const fetchedIDs = new Set(fetched.map((part) => part.id)) - const pending = pendingParts.get(sessionID)?.get(item.id) - const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) - for (const part of fetched) { - const accumulated = data.part_text_accum_delta[part.id] - const base = deltaBases.get(part.id)?.base - const preserveDelta = - base !== undefined && - accumulated !== undefined && - "text" in part && - typeof part.text === "string" && - part.text.startsWith(base) && - accumulated.startsWith(part.text) && - accumulated !== part.text - if (preserveDelta) touched.add(part.id) - if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id) - } - for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) { - if (!fetchedIDs.has(partID)) touched.delete(partID) - } - const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched }) - if (!parts.length) { - orphanParts.get(sessionID)?.delete(item.id) - setData(produce((draft) => deleteMessageParts(draft, item.id))) - continue - } - const partIDs = new Set(parts.map((part) => part.id)) - setData( - "part_text_accum_delta", - produce((draft) => { - for (const part of data.part[item.id] ?? []) { - if (!partIDs.has(part.id) || !touched.has(part.id)) { - delete draft[part.id] - deltaBases.delete(part.id) - } - } - }), - ) - setData("part", item.id, reconcile(parts, { key: "id" })) - orphanParts.get(sessionID)?.delete(item.id) - } - } - - const applyMessagePage = ( - sessionID: string, - page: MessagePage, - load: MessageLoadState | undefined, - preserveUnfetched: boolean | ((message: Message) => boolean), - cleanupOrphans: boolean, - ) => { - if (page.sourceMode === "latest") - messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id))) - page.source?.forEach((message) => releaseEcho(sessionID, message.id)) - const source = page.source - ? (() => { - const incoming = new Map(page.source.map((message) => [message.id, message])) - const existing = data.session_message[sessionID] ?? [] - const boundary = Math.min(...page.source.map((message) => message.time.created)) - const inbox = new Set(data.input[sessionID] ?? []) - const current = existing.filter( - (message) => - !incoming.has(message.id) && - !inbox.has(message.id) && - (page.sourceMode === "older" || - load?.touchedSource.has(message.id) || - (!page.complete && message.time.created < boundary)), - ) - // message.list never returns admitted-but-undelivered inbox entries; keep them after the - // fetched history until a delivered or cancelled event resolves them. - const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id)) - const combined = - page.sourceMode === "older" - ? [...page.source, ...current, ...admitted] - : [...current, ...page.source, ...admitted] - const live = new Map(existing.map((message) => [message.id, message])) - return combined.map((message) => - load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message, - ) - })() - : undefined - const merged = - page.projectSource && source - ? (() => { - const normalized = normalizeSessionMessages(sessionID, source) - return { - ...page, - session: normalized.messages.sort(compareMessages), - part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)), - } - })() - : page - const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) - const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { - touched: touchedMessages, - retained: load?.retainedMessages, - removed: load?.removedMessages, - preserveUnfetched: (message) => - echoes.get(sessionID)?.has(message.id) === true || - preserveUnfetched === true || - (typeof preserveUnfetched === "function" && preserveUnfetched(message)), - compare: compareMessages, - }) - batch(() => { - if (source) setData("session_message", sessionID, reconcile(source)) - const messageIDs = replaceMessages(sessionID, messages) - replaceParts(sessionID, merged.part, messageIDs, load) - const orphans = orphanParts.get(sessionID) - if (cleanupOrphans && page.complete && orphans) { - for (const messageID of orphans) { - if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID))) - } - orphanParts.delete(sessionID) - } - setMeta("cursor", sessionID, merged.cursor) - setMeta("complete", sessionID, merged.complete) - setMeta("at", sessionID, Date.now()) - }) - } - - const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => { - if (meta.loading[sessionID]) return - const active = generation(sessionID) - const load: MessageLoadState = { - touchedMessages: new Set(), - removedMessages: new Set(), - retainedMessages: new Set(), - touchedParts: new Map(), - deltaParts: new Map(), - carriedDeltaParts: new Map(), - removedParts: new Map(), - orphanParents: new Set(), - clearedMessageParts: new Set(), - touchedSource: new Set(), - } - messageLoads.set(sessionID, load) - setMeta("loading", sessionID, true) - let applied = false - try { - const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load)) - const first = page.session.reduce( - (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), - undefined, - ) - if (generations.get(sessionID) !== active) return - - const parents = [] as Awaited>[] - if (mode !== "prepend") { - const users = new Set([ - ...page.session.filter((message) => message.role === "user").map((message) => message.id), - ...(data.message[sessionID] ?? []) - .filter((message) => message.role === "user" && load.touchedMessages.has(message.id)) - .map((message) => message.id), - ]) - const parentIDs = [ - ...new Set( - page.session.flatMap((message) => - message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], - ), - ), - ] - for (const parentID of parentIDs) { - if (generations.get(sessionID) !== active) break - const parent = await fetchMessage(sessionID, parentID, () => - resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), - ).catch((error) => { - const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined - if (cause && "status" in cause && cause.status === 404) { - load.removedMessages.add(parentID) - return - } - throw error - }) - if (!parent) continue - if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) - parents.push(parent) - } - } - if (generations.get(sessionID) !== active) return - const result = - mode === "prepend" - ? page - : { - ...page, - session: merge( - page.session, - parents.map((parent) => parent.message), - ).sort(compareMessages), - part: merge( - page.part, - parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), - ), - } - const preserveUnfetched = - mode === "prepend" || - (!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0))) - applyMessagePage( - sessionID, - result, - messageLoads.get(sessionID) === load ? load : undefined, - preserveUnfetched, - mode !== "prepend", - ) - applied = true - } finally { - if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { - for (const messageID of load.orphanParents) { - if (!orphanParts.get(sessionID)?.has(messageID)) continue - setData(produce((draft) => deleteMessageParts(draft, messageID))) - orphanParts.get(sessionID)?.delete(messageID) - } - if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) - } - if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) - if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) - } - } - - const sync = (sessionID: string, options?: { force?: boolean }) => { - touch(sessionID) - return runInflight(inflight, sessionID, async () => { - const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined - const invalid = invalidated.has(sessionID) - const revision = invalidationRevision - if (cached && data.info[sessionID] && !invalid && !options?.force) return - await Promise.all([ - resolve(sessionID, invalid ? { ...options, force: true } : options), - cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID), - ]) - if (invalid && invalidationRevision === revision) invalidated.delete(sessionID) - }) - } - - const prefetch = async (sessionID: string, messageCount: number) => { - touch(sessionID) - await inflight.get(sessionID) - if ( - Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && - (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount) - ) - return - await runInflight(inflight, sessionID, () => loadMessages(sessionID)) - } - - const eventSessionID = (event: { type: string; properties?: unknown }) => { - const properties = event.properties - if (!properties || typeof properties !== "object") return - if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID - if ( - "info" in properties && - properties.info && - typeof properties.info === "object" && - "sessionID" in properties.info && - typeof properties.info.sessionID === "string" - ) - return properties.info.sessionID - if ( - "part" in properties && - properties.part && - typeof properties.part === "object" && - "sessionID" in properties.part && - typeof properties.part.sessionID === "string" - ) - return properties.part.sessionID - } - - const projectV2 = (reduction: V2SessionReduction) => { - reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) - setData("session_message", reduction.sessionID, reconcile(reduction.messages)) - if (reduction.touched.length === 0 && !reduction.removed?.length) return - - const touched = new Set(reduction.touched) - let parentID: string | undefined - for (const message of reduction.messages) { - if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) - parentID = message.id - if (message.type === "shell") { - if (touched.has(message.id)) touched.add(`${message.id}:assistant`) - parentID = undefined - } - if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) - if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) - } - - const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) - batch(() => { - for (const messageID of reduction.removed ?? []) { - apply({ type: "message.removed", properties: { sessionID: reduction.sessionID, messageID } }) - } - for (const message of normalized.messages) { - if (!touched.has(message.id)) continue - apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) - } - for (const messageID of touched) { - const next = present(messageID, normalized.parts.get(messageID) ?? []) - const nextIDs = new Set(next.map((part) => part.id)) - for (const part of next) { - apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) - } - for (const part of [...(data.part[messageID] ?? [])]) { - if (nextIDs.has(part.id)) continue - apply({ - type: "message.part.removed", - properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, - }) - } - } - }) - } - - const hydrateV2Message = (sessionID: string, messageID: string) => { - if (!sessionApi) return - const active = generation(sessionID) - const revision = messageHydrationRevision.get(sessionID) ?? 0 - void sessionApi - .message({ sessionID, messageID }) - .then((message) => { - if (generations.get(sessionID) !== active) return - if ((messageHydrationRevision.get(sessionID) ?? 0) !== revision) return - if (removedMessages.get(sessionID)?.has(message.id)) return - const current = data.session_message[sessionID] ?? [] - const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) - projectV2({ sessionID, messages, touched: [message.id] }) - }) - .catch(() => {}) - } - - const removeEcho = (sessionID: string, messageID: string) => { - if (!releaseEcho(sessionID, messageID)) return false - pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1) - const load = messageLoads.get(sessionID) - load?.touchedMessages.add(messageID) - load?.removedMessages.add(messageID) - load?.clearedMessageParts.add(messageID) - batch(() => { - setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID)) - setData("input", sessionID, (items) => items?.filter((id) => id !== messageID)) - setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID)) - setData(produce((draft) => deleteMessageParts(draft, messageID))) - }) - return true - } - - const confirmInbox = (item: SessionInboxInfo) => { - if (!confirmEcho(item.sessionID, item.id)) return false - v2.confirm(item) - pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1) - const current = data.pending[item.sessionID] ?? [] - const index = current.findIndex((entry) => entry.id === item.id) - if (index < 0) setData("pending", item.sessionID, [...current, item]) - if (index >= 0) setData("pending", item.sessionID, index, reconcile(item)) - return true - } - - const reconcileInbox = (sessionID: string) => { - const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id)) - const fetched = messageSnapshots.get(sessionID) ?? new Set() - const removed = [...(settledInputs.get(sessionID) ?? [])].filter( - (messageID) => !pending.has(messageID) && !fetched.has(messageID), - ) - settledInputs.delete(sessionID) - if (removed.length) { - const ids = new Set(removed) - const source = data.session_message[sessionID] ?? [] - projectV2({ - sessionID, - messages: source.filter((message) => !ids.has(message.id)), - touched: [], - removed: source.filter((message) => ids.has(message.id)).map((message) => message.id), - }) - } - - const messages = echoes.get(sessionID) - if (!messages) return - const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id)) - for (const [messageID, state] of messages) { - if (projected.has(messageID)) { - releaseEcho(sessionID, messageID) - continue - } - if (pending.has(messageID)) { - confirmEcho(sessionID, messageID) - continue - } - if (state === "admitted") removeEcho(sessionID, messageID) - } - } - - const applyV2 = (event: OpenCodeEvent) => { - if (event.type === "form.created") { - formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1) - const current = data.form[event.data.form.sessionID] ?? [] - if (!current.some((form) => form.id === event.data.form.id)) - setData("form", event.data.form.sessionID, [...current, event.data.form]) - return - } - if (event.type === "form.replied" || event.type === "form.cancelled") { - formRevision.set(event.data.sessionID, (formRevision.get(event.data.sessionID) ?? 0) + 1) - setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id)) - return - } - if (event.type === "worktree.resolved") { - Object.values(data.info).forEach((info) => { - if (!info) return - const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data) - if (adopted) remember({ ...info, ...adopted }) - }) - return - } - if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return - const sessionID = event.data.sessionID - if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered") - releaseEcho(sessionID, event.data.inboxID) - if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID) - if ( - event.type === "session.inbox.enqueued" || - event.type === "session.inbox.delivery.changed" || - event.type === "session.inbox.cancelled" || - event.type === "session.inbox.delivered" || - event.type === "session.compaction.started" || - event.type === "session.compaction.failed" - ) - pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1) - if (event.type === "session.inbox.enqueued") { - const current = data.pending[sessionID] ?? [] - const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item } - const index = current.findIndex((entry) => entry.id === event.data.inboxID) - if (index < 0) setData("pending", sessionID, [...current, item]) - if (index >= 0) setData("pending", sessionID, index, reconcile(item)) - if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID)) - setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID]) - } - if (event.type === "session.inbox.delivery.changed") - setData("pending", sessionID, (items) => - items?.map((item) => (item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item)), - ) - if (event.type === "session.inbox.cancelled" || event.type === "session.inbox.delivered") { - setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inboxID)) - setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inboxID)) - } - if (event.type === "session.compaction.started" || event.type === "session.compaction.failed") { - setData("pending", sessionID, (items) => items?.filter((item) => item.id !== event.data.inputID)) - setData("input", sessionID, (items) => items?.filter((id) => id !== event.data.inputID)) - } - const info = data.info[sessionID] - const reduction = v2.reduce(data.session_message[sessionID] ?? [], event, info) - if (reduction) { - projectV2(reduction) - if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) - } - - if (event.type === "session.agent.selected" && info) remember({ ...info, agent: event.data.agent }) - if (event.type === "session.model.selected") { - if (info) remember({ ...info, model: event.data.model }) - if (data.session_message[sessionID]) hydrateV2Message(sessionID, event.id.replace(/^evt_/, "msg_")) - } - if (event.type === "session.renamed" && info) - remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) - if (event.type === "session.renamed" && !info) - void resolve(sessionID) - .then((current) => - remember({ ...current, title: event.data.title, time: { ...current.time, updated: event.created } }), - ) - .catch(() => undefined) - if (event.type === "session.moved" && info) - remember({ - ...info, - projectID: event.data.projectID, - location: event.data.location, - subpath: event.data.subpath, - time: { ...info.time, updated: event.created }, - }) - if (event.type === "session.usage.updated" && info) - remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) - // if (event.type === "session.archived") { - // if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) - // evict([sessionID]) - // } - if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) - if ( - event.type === "session.execution.succeeded" || - event.type === "session.execution.failed" || - event.type === "session.execution.interrupted" - ) - setData("session_status", sessionID, { type: "idle" }) - if (event.type === "session.retry.scheduled") - setData("session_status", sessionID, { - type: "retry", - attempt: event.data.attempt, - message: event.data.error.message, - next: event.data.at, - }) - if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) - if (event.type === "session.revert.staged" && info) remember({ ...info, revert: event.data.revert }) - if (event.type === "session.revert.cleared" && info) remember({ ...info, revert: undefined }) - if (event.type === "session.revert.committed") { - messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) - if (info) remember({ ...info, revert: undefined }) - setData("input", sessionID, (items) => items?.filter((id) => id < event.data.to)) - const source = data.session_message[sessionID] ?? [] - const removed = source.filter((message) => message.id >= event.data.to).map((message) => message.id) - removedMessages.set(sessionID, new Set([...(removedMessages.get(sessionID) ?? []), ...removed])) - projectV2({ - sessionID, - messages: source.filter((message) => message.id < event.data.to), - touched: [], - removed, - }) - } - if ( - event.type === "session.revert.staged" || - event.type === "session.revert.cleared" || - event.type === "session.revert.committed" - ) - void resolve(sessionID, { force: true }).catch(() => {}) - if (event.type === "session.revert.committed") void sync(sessionID, { force: true }).catch(() => {}) - } - - const apply = (event: { type: string; properties?: unknown }) => { - const eventID = eventSessionID(event) - if (eventID) { - touch(eventID) - if ( - !data.info[eventID] && - event.type !== "session.created" && - event.type !== "session.updated" && - event.type !== "session.deleted" - ) - void resolve(eventID).catch(() => {}) - } - switch (event.type) { - case "session.created": - if ((event.properties as { info?: SessionInfo }).info) - remember((event.properties as { info: SessionInfo }).info) - return - case "session.updated": { - const info = (event.properties as { info: SessionInfo }).info - remember(info) - if (info.time.archived) evict([info.id]) - return - } - case "session.deleted": { - const properties = event.properties as { sessionID?: string; info?: SessionInfo } - const sessionID = properties.info?.id ?? properties.sessionID - if (!sessionID) return - infoSeen.delete(sessionID) - setData( - "info", - produce((draft) => void delete draft[sessionID]), - ) - evict([sessionID]) - return - } - case "todo.updated": { - const props = event.properties as { sessionID: string; todos: Todo[] } - setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) - return - } - case "session.status": { - const props = event.properties as { sessionID: string; status: SessionStatus } - setData("session_status", props.sessionID, reconcile(props.status)) - return - } - case "message.updated": { - const info = (event.properties as { info: Message }).info - const load = messageLoads.get(info.sessionID) - load?.touchedMessages.add(info.id) - load?.removedMessages.delete(info.id) - const orphans = orphanParts.get(info.sessionID) - orphans?.delete(info.id) - if (orphans?.size === 0) orphanParts.delete(info.sessionID) - const removedMessagesForSession = removedMessages.get(info.sessionID) - removedMessagesForSession?.delete(info.id) - if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID) - const messages = data.message[info.sessionID] - if (!messages) { - setData("message", info.sessionID, [info]) - return - } - const result = Binary.search(messages, messageKey(info), messageKey) - if (result.found) { - setData("message", info.sessionID, result.index, reconcile(info)) - return - } - // Delivery rewrites time.created, changing the sort key; reposition instead of duplicating. - setData("message", info.sessionID, (value = []) => { - const next = value.slice() - const moved = next.findIndex((message) => message.id === info.id) - if (moved >= 0) next.splice(moved, 1) - next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info) - return next - }) - return - } - case "message.removed": { - const props = event.properties as { sessionID: string; messageID: string } - setData("session_message", props.sessionID, (messages) => - messages?.filter((message) => message.id !== props.messageID), - ) - const load = messageLoads.get(props.sessionID) - load?.touchedMessages.add(props.messageID) - load?.removedMessages.add(props.messageID) - load?.clearedMessageParts.add(props.messageID) - load?.deltaParts.delete(props.messageID) - load?.carriedDeltaParts.delete(props.messageID) - load?.removedParts.delete(props.messageID) - pendingParts.get(props.sessionID)?.delete(props.messageID) - if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) - const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set() - removedMessagesForSession.add(props.messageID) - removedMessages.set(props.sessionID, removedMessagesForSession) - setData( - produce((draft) => { - const messages = draft.message[props.sessionID] - if (messages) { - const index = messages.findIndex((message) => message.id === props.messageID) - if (index >= 0) messages.splice(index, 1) - } - deleteMessageParts(draft, props.messageID) - }), - ) - return - } - case "message.part.updated": { - const part = (event.properties as { part: Part }).part - if (SKIP_PARTS.has(part.type)) return - const messages = data.message[part.sessionID] - const load = messageLoads.get(part.sessionID) - const missing = !messages?.some((message) => message.id === part.messageID) - // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. - if ( - missing && - (!load || - load.clearedMessageParts.has(part.messageID) || - removedMessages.get(part.sessionID)?.has(part.messageID)) - ) - return - if (missing) { - const orphans = orphanParts.get(part.sessionID) ?? new Set() - orphans.add(part.messageID) - orphanParts.set(part.sessionID, orphans) - load?.orphanParents.add(part.messageID) - } - const deltas = load?.deltaParts.get(part.messageID) - deltas?.delete(part.id) - if (deltas?.size === 0) load?.deltaParts.delete(part.messageID) - const carried = load?.carriedDeltaParts.get(part.messageID) - carried?.delete(part.id) - if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID) - const removed = load?.removedParts.get(part.messageID) - removed?.delete(part.id) - if (removed?.size === 0) load?.removedParts.delete(part.messageID) - const pending = pendingParts.get(part.sessionID)?.get(part.messageID) - pending?.delete(part.id) - if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) - if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) - deltaBases.delete(part.id) - trackPartChange(part.sessionID, part.messageID, part.id) - setData( - "part_text_accum_delta", - produce((draft) => void delete draft[part.id]), - ) - const parts = data.part[part.messageID] - if (!parts) { - setData("part", part.messageID, [part]) - return - } - const index = parts.findIndex((item) => item.id === part.id) - if (index >= 0) setData("part", part.messageID, index, reconcile(part)) - if (index < 0) setData("part", part.messageID, (value = []) => [...value, part]) - return - } - case "message.part.removed": { - const props = event.properties as { sessionID: string; messageID: string; partID: string } - // Part removal is event-only on the server, so its tombstone lasts until a later update or eviction. - const pending = pendingParts.get(props.sessionID) ?? new Map>() - const parts = pending.get(props.messageID) ?? new Set() - parts.add(props.partID) - pending.set(props.messageID, parts) - pendingParts.set(props.sessionID, pending) - const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID) - deltas?.delete(props.partID) - if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID) - const load = messageLoads.get(props.sessionID) - const carried = load?.carriedDeltaParts.get(props.messageID) - carried?.delete(props.partID) - if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID) - if (load) { - const parts = load.removedParts.get(props.messageID) ?? new Set() - parts.add(props.partID) - load.removedParts.set(props.messageID, parts) - } - trackPartChange(props.sessionID, props.messageID, props.partID) - setData( - produce((draft) => { - delete draft.part_text_accum_delta[props.partID] - deltaBases.delete(props.partID) - const parts = draft.part[props.messageID] - if (!parts) return - const index = parts.findIndex((part) => part.id === props.partID) - if (index >= 0) parts.splice(index, 1) - if (parts.length === 0) delete draft.part[props.messageID] - }), - ) - return - } - case "message.part.delta": { - const props = event.properties as { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } - const parts = data.part[props.messageID] - if (!parts) return - const index = parts.findIndex((part) => part.id === props.partID) - if (index < 0) return - trackPartChange(props.sessionID, props.messageID, props.partID) - const load = messageLoads.get(props.sessionID) - if (load) { - const parts = load.deltaParts.get(props.messageID) ?? new Set() - parts.add(props.partID) - load.deltaParts.set(props.messageID, parts) - const carried = load.carriedDeltaParts.get(props.messageID) - carried?.delete(props.partID) - if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) - } - const field = props.field as keyof (typeof parts)[number] - const current = parts[index]?.[field] - if (!deltaBases.has(props.partID) && typeof current === "string") - deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) - setData( - "part_text_accum_delta", - props.partID, - (value) => (value ?? (typeof current === "string" ? current : "")) + props.delta, - ) - setData( - "part", - props.messageID, - produce((draft) => { - if (!draft) return - const part = draft[index] - const field = props.field as keyof typeof part - ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta - }), - ) - return - } - case "permission.asked": { - const permission = event.properties as PermissionRequest - const permissions = data.permission[permission.sessionID] - if (!permissions) { - setData("permission", permission.sessionID, [permission]) - return - } - const result = Binary.search(permissions, permission.id, (item) => item.id) - if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission)) - if (!result.found) - setData( - "permission", - permission.sessionID, - produce((draft) => void draft.splice(result.index, 0, permission)), - ) - return - } - case "permission.replied": { - const props = event.properties as { sessionID: string; requestID: string } - setData( - "permission", - props.sessionID, - produce((draft) => { - if (!draft) return - const result = Binary.search(draft, props.requestID, (item) => item.id) - if (result.found) draft.splice(result.index, 1) - }), - ) - return - } - } - } - - return { - data, - set: setData, - get: (sessionID: string) => data.info[sessionID], - peek: (sessionID: string) => data.info[sessionID], - remember, - resolve, - lineage: { - peek: peekLineage, - async resolve(sessionID: string) { - const session = await resolve(sessionID) - return { session, root: await rootSession(session, resolve) } - }, - }, - sync, - async hydrateTransient(sessionID: string, load: () => Promise<{ pending: SessionInboxInfo[]; forms: FormInfo[] }>) { - while (true) { - const pendingAt = pendingRevision.get(sessionID) ?? 0 - const formAt = formRevision.get(sessionID) ?? 0 - const previous = new Set(data.input[sessionID] ?? []) - const result = await load() - const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt - const formStable = (formRevision.get(sessionID) ?? 0) === formAt - if (pendingStable) { - const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)) - const settled = settledInputs.get(sessionID) ?? new Set() - previous.forEach((messageID) => { - if (!current.has(messageID)) settled.add(messageID) - }) - if (settled.size) settledInputs.set(sessionID, settled) - result.pending.forEach(v2.confirm) - setData("pending", sessionID, reconcile(result.pending)) - setData("input", sessionID, reconcile([...current])) - } - if (formStable) setData("form", sessionID, reconcile(result.forms)) - if (pendingStable && formStable) return - } - }, - refreshPinned(hydrateTransient: (sessionID: string) => Promise) { - const sessions = [...pinned.keys()] - return Promise.all( - sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]), - ).then(() => sessions.forEach(reconcileInbox)) - }, - invalidate() { - invalidationRevision += 1 - Object.keys(data.info).forEach((sessionID) => invalidated.add(sessionID)) - Object.keys(data.message).forEach((sessionID) => invalidated.add(sessionID)) - setMeta("at", {}) - }, - prefetch, - shouldPrefetch(sessionID: string, messageCount: number) { - if (data.message[sessionID] === undefined) return true - if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true - if (meta.complete[sessionID]) return false - return (data.message[sessionID]?.length ?? 0) <= messageCount - }, - fresh(sessionID: string, ttl: number) { - return Date.now() - (meta.at[sessionID] ?? 0) <= ttl - }, - inbox: { - echo(input: PromptEcho) { - const created = Date.now() - const files = input.files?.map((file) => ({ - data: "", - mime: file.mime, - source: { type: "uri" as const, uri: file.uri }, - name: file.name, - mention: file.mention, - })) - const item: SessionInboxInfo = { - id: input.messageID, - sessionID: input.sessionID, - timeCreated: created, - type: "user", - delivery: "steer", - payload: { text: input.text, files, agents: input.agents }, - } - const projected = normalizeSessionMessages(input.sessionID, [ - { id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } }, - { - id: `${input.messageID}:model`, - type: "model-switched", - model: { - id: input.model.modelID, - providerID: input.model.providerID, - variant: input.model.variant, - }, - time: { created }, - }, - { - id: input.messageID, - type: "user", - text: input.displayText, - files, - agents: input.agents, - time: { created }, - }, - ]) - const message = projected.messages[0]! - const comments: Part[] = input.comments.map((comment, index) => ({ - id: `${input.messageID}:comment:${index}`, - sessionID: input.sessionID, - messageID: input.messageID, - type: "text", - text: formatCommentNote(comment), - synthetic: true, - metadata: createCommentMetadata(comment), - })) - const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments] - removedMessages.get(input.sessionID)?.delete(input.messageID) - markEcho(input.sessionID, input.messageID) - pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1) - batch(() => { - setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item]) - if (!data.input[input.sessionID]?.includes(input.messageID)) - setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID]) - setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages)) - setData("part", input.messageID, parts) - }) - }, - confirm: confirmInbox, - reconcile: reconcileInbox, - clearEcho(input: { sessionID: string; messageID: string }) { - if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false - return removeEcho(input.sessionID, input.messageID) - }, - }, - async todo(sessionID: string, request?: { force?: boolean }) { - touch(sessionID) - if (data.todo[sessionID] !== undefined && !request?.force) return - // TODO: Restore todos when the V2 client exposes a session todo API. - setData("todo", sessionID, []) - }, - history: { - more: (sessionID: string) => - data.message[sessionID] !== undefined && - meta.complete[sessionID] !== undefined && - !meta.complete[sessionID] && - !!meta.cursor[sessionID], - loading: (sessionID: string) => meta.loading[sessionID] ?? false, - async loadMore(sessionID: string) { - touch(sessionID) - if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return - await loadMessages(sessionID, meta.cursor[sessionID], "prepend") - }, - }, - evict(sessionID: string) { - if (protectedSessions().has(sessionID)) return - seen.delete(sessionID) - evict([sessionID]) - }, - pin(sessionID: string) { - pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1) - touch(sessionID) - }, - unpin(sessionID: string) { - const count = pinned.get(sessionID) - if (!count || count === 1) pinned.delete(sessionID) - if (count && count > 1) pinned.set(sessionID, count - 1) - }, - apply, - applyV2, - } -} - -export type ServerSession = ReturnType diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index df68c12ca8..40dcc4e935 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,126 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { - McpListInput, - McpResourceCatalogInput, - OpenCodeEvent, - SessionApi, - SessionInfo, - SessionListInput, -} from "@opencode-ai/client/promise" -import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { - loadActiveSessionsQuery, - loadMcpQuery, - loadMcpResourcesQuery, - reconcileActiveSessionStatuses, - seedActiveSessionStatuses, - sessionListEventDirectories, - shouldRefreshWorkspaceSessions, -} from "./server-sync" -import { ServerScope } from "@/utils/server-scope" -import { createServerSession } from "./server-session" -import type { ServerApi } from "@/utils/server" -import { adaptServerEvent } from "./server-sdk" - -type McpApi = ServerApi["mcp"] - -describe("MCP queries", () => { - test("loads current servers for the requested location", async () => { - const calls: unknown[] = [] - const queryClient = new QueryClient() - const result = await queryClient.fetchQuery( - loadMcpQuery(ServerScope.local, "/project", { - list: async (input: McpListInput = {}) => { - calls.push(input) - return { - location: { directory: "/project", project: { id: "project", directory: "/project" } }, - data: [ - { name: "docs", status: { status: "connected" } }, - { name: "search", status: { status: "pending" } }, - ], - } - }, - } as unknown as McpApi), - ) - - expect(calls).toEqual([{ location: { directory: "/project" } }]) - expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } }) - }) - - test("loads and keys the current resource catalog", async () => { - const calls: unknown[] = [] - const queryClient = new QueryClient() - const result = await queryClient.fetchQuery( - loadMcpResourcesQuery(ServerScope.local, "/project", { - resource: { - catalog: async (input: McpResourceCatalogInput = {}) => { - calls.push(input) - return { - location: { directory: "/project", project: { id: "project", directory: "/project" } }, - data: { - resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }], - templates: [], - }, - } - }, - }, - } as unknown as McpApi), - ) - - expect(calls).toEqual([{ location: { directory: "/project" } }]) - expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } }) - }) -}) - -describe("active session query", () => { - test("loads active sessions immediately and once per server cache", async () => { - let calls = 0 - const queryClient = new QueryClient() - const options = loadActiveSessionsQuery(ServerScope.local, { - active: async () => { - calls++ - return { ses_running: { type: "running" } } - }, - }) - - expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) - expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) - expect(calls).toBe(1) - expect(options.enabled).toBe(true) - expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) - }) - - test("does not overwrite statuses already written by events", () => { - const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"]) - session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) - - seedActiveSessionStatuses(session, { - ses_running: { type: "running" }, - ses_retry: { type: "running" }, - }) - - expect(session.data.session_status.ses_running).toEqual({ type: "busy" }) - expect(session.data.session_status.ses_retry).toEqual({ - type: "retry", - attempt: 2, - message: "retrying", - next: 10, - }) - }) - - test("replaces stale active statuses after reconnect", () => { - const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"]) - session.set("session_status", "ses_stale", { type: "busy" }) - session.set("session_status", "ses_active", { type: "idle" }) - - reconcileActiveSessionStatuses(session, { ses_active: { type: "running" } }) - - expect(session.data.session_status.ses_stale).toEqual({ type: "idle" }) - expect(session.data.session_status.ses_active).toEqual({ type: "busy" }) - }) -}) describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -143,99 +22,6 @@ describe("pickDirectoriesToEvict", () => { }) }) -describe("loadRootSessions", () => { - test("loads a limited page of root sessions", async () => { - const calls: SessionListInput[] = [] - - const result = await loadRootSessions({ - api: { - list: async (query = {}) => { - calls.push(query) - return { data: [sessionInfo("session-1")], cursor: {} } - }, - } satisfies Pick, - directory: "dir", - limit: 10, - }) - - expect(result.data).toEqual([expect.objectContaining({ id: "session-1", location: { directory: "dir" } })]) - expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) - }) - - test("propagates list failures", () => { - expect( - loadRootSessions({ - api: { - list: async () => { - throw new Error("failed") - }, - } satisfies Pick, - directory: "dir", - limit: 25, - }), - ).rejects.toThrow("failed") - }) -}) - -function sessionInfo(id: string) { - return { - id, - projectID: "project-1", - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: id, - location: { directory: "dir" }, - } as SessionInfo -} - -describe("estimateRootSessionTotal", () => { - test("keeps exact total for full fetches", () => { - expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) - }) - - test("marks has-more for full-limit limited fetches", () => { - expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11) - }) - - test("keeps exact total when limited fetch is under limit", () => { - expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9) - }) -}) - -describe("workspace session inventory", () => { - test("refreshes for session identity and location changes", () => { - const event = (type: string, current?: string) => - ({ type, current: current ? { type: current } : undefined }) as Parameters< - typeof shouldRefreshWorkspaceSessions - >[0] - - expect(shouldRefreshWorkspaceSessions(event("session.created"))).toBe(true) - expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true) - expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false) - }) - - test("invalidates both locations when a session moves", () => { - const event = adaptServerEvent({ - id: "evt_moved", - created: 1, - type: "session.moved", - durable: { aggregateID: "ses_1", seq: 1, version: 1 }, - location: { directory: "/source" }, - data: { - sessionID: "ses_1", - location: { directory: "/destination" }, - projectID: "project_2", - }, - } satisfies Extract) - - expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"]) - }) -}) - describe("canDisposeDirectory", () => { test("rejects pinned or inflight directories", () => { expect( diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 1471aa1c57..3c4141e817 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,159 +1,40 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { batch, getOwner, onCleanup, untrack } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { getOwner, onCleanup, untrack } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { InitError } from "../pages/error" -import { type ServerEvent, type ServerSDK } from "./server-sdk" +import { type ServerSDK } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, - clearProviderRev, - loadAgentsQuery, - loadCommands, loadGlobalConfigQuery, - loadIntegrationsQuery, loadPathQuery, - loadProjectsQuery, - loadProvidersQuery, - loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" -import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" -import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" -import { pathKey, PathKey } from "@/utils/path-key" -import { createDirSyncContext } from "./directory-sync" -import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { createRefCountMap } from "@/utils/refcount" -import { ServerConnection } from "./servers" +import { PathKey } from "@/utils/path-key" import type { ServerScope } from "@/utils/server-scope" -import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" import type { ServerApi } from "@/utils/server" -import type { - McpListInput, - McpListOutput, - McpResource, - McpResourceCatalogInput, - McpResourceCatalogOutput, - McpServer, - SessionActiveOutput, - SessionStatus, -} from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" -import { createServerSession, type ServerSession } from "./server-session" -import { createCatalogSync } from "./server-sync/catalog" import { createConnectionSync } from "./server-sync/connection" import { usePlatform } from "./platform" -import { useServer } from "./server" - -export function shouldRefreshWorkspaceSessions(event: ServerEvent) { - const type = event.current?.type ?? event.type - return ( - type === "session.created" || - type === "session.deleted" || - type === "session.moved" || - type === "session.renamed" || - type === "session.forked" - ) -} +import type { Data } from "@opencode-ai/client/solid" type GlobalStore = { - ready: boolean - error?: InitError path: Path project: Project[] - provider: NormalizedProviderListResponse provider_auth: ProviderAuthResponse config: Config reload: undefined | "pending" | "complete" } -const SESSION_LIST_EVENTS = new Set([ - "session.created", - "session.updated", - "session.deleted", - "session.moved", - "session.forked", - "session.renamed", - "session.usage.updated", -]) - -export function sessionListEventDirectories(event: ServerEvent) { - if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return [] - const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined - return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))] -} - -type McpListApi = { - readonly list: (input?: McpListInput) => Promise -} - -type McpResourceApi = { - readonly resource: { - readonly catalog: (input?: McpResourceCatalogInput) => Promise - } -} - -type ApiQueryOptions = SolidQueryOptions & { - initialData?: undefined - queryKey: K -} - -type SessionActiveApi = { - readonly active: () => Promise -} - -export const loadMcpQuery = ( - scope: ServerScope, - directory: string, - api: McpListApi, -): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => - queryOptions< - Record, - Error, - Record, - readonly [ServerScope, string, "mcp"] - >({ - queryKey: [scope, directory, "mcp"] as const, - queryFn: async () => { - return api - .list({ location: { directory } }) - .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) - }, - }) - -export const loadMcpResourcesQuery = ( - scope: ServerScope, - directory: string, - api: McpResourceApi, -): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => - queryOptions< - Record, - Error, - Record, - readonly [ServerScope, string, "mcpResources"] - >({ - queryKey: [scope, directory, "mcpResources"] as const, - queryFn: async () => { - return api.resource - .catalog({ location: { directory } }) - .then((result) => - Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), - ) - }, - placeholderData: {}, - }) - export const loadLspQuery = (scope: ServerScope, directory: string) => queryOptions({ queryKey: [scope, directory, "lsp"] as const, @@ -161,112 +42,32 @@ export const loadLspQuery = (scope: ServerScope, directory: string) => queryFn: async () => [], }) -export const loadActiveSessionsQuery = ( - scope: ServerScope, - api: SessionActiveApi, -): ApiQueryOptions => - queryOptions({ - queryKey: [scope, "activeSessions"] as const, - queryFn: () => api.active(), - enabled: true, - staleTime: Number.POSITIVE_INFINITY, - gcTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }) - -export function seedActiveSessionStatuses( - session: Pick, - active: SessionActiveOutput | Record, -) { - for (const sessionID of Object.keys(active)) { - if (session.data.session_status[sessionID] !== undefined) continue - const status = active[sessionID] - session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) - } -} - -export function reconcileActiveSessionStatuses( - session: Pick, - active: SessionActiveOutput, -) { - Object.keys(session.data.session_status) - .filter((sessionID) => active[sessionID] === undefined && session.data.session_status[sessionID]?.type !== "idle") - .forEach((sessionID) => session.set("session_status", sessionID, { type: "idle" })) - Object.keys(active).forEach((sessionID) => session.set("session_status", sessionID, { type: "busy" })) -} - function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { return { globalConfig: () => loadGlobalConfigQuery(scope), - projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree), - providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), - integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration), - path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), - references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp), - mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp), + path: () => loadPathQuery(scope, null, serverAPI.location), lsp: (directory: PathKey) => loadLspQuery(scope, directory), - sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } } export type QueryOptionsApi = ReturnType -export function createServerSyncContextInner(serverSDK: ServerSDK) { +export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) { const language = useLanguage() const platform = usePlatform() const owner = getOwner() if (!owner) throw new Error("ServerSync must be created within owner") const booting = new Map>() - const sessionLoads = new Map>() - const sessionMeta = new Map() - const sessionRevision = new Map() - - const session = createServerSession(serverSDK.api.session, serverSDK.api.message) const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api) const connected = () => serverSDK.connection.status() === "connected" - const hydrateSessionState = async (sessionID: string) => { - await session.hydrateTransient(sessionID, async () => { - const [pending, forms] = await Promise.all([ - serverSDK.api.session.inbox.list({ sessionID }), - serverSDK.api.form.list({ sessionID }), - ]) - return { pending, forms } - }) - } - const hydrateSession = async (sessionID: string) => { - await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)]) - session.inbox.reconcile(sessionID) - } - const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ + const [configQuery, pathQuery] = useQueries(() => ({ queries: [ { ...queryOptionsApi.globalConfig(), enabled: connected() }, - { ...queryOptionsApi.providers(null), enabled: connected() }, - { ...queryOptionsApi.path(null), enabled: connected() }, + { ...queryOptionsApi.path(), enabled: connected() }, ], })) - const activeSessionsQuery = useQuery(() => ({ - ...loadActiveSessionsQuery(serverSDK.scope, { - active: async () => { - const active = await serverSDK.api.session.active() - reconcileActiveSessionStatuses(session, active) - Object.keys(active).forEach((sessionID) => { - void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined) - }) - return active - }, - }), - enabled: connected(), - })) - const [globalStore, setGlobalStore] = createStore({ - get ready() { - return bootstrap.isSuccess - }, project: [], provider_auth: {}, get path() { @@ -274,11 +75,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { if (pathQuery.isLoading) return EMPTY return pathQuery.data ?? EMPTY }, - get provider() { - const EMPTY = { all: new Map(), connected: [], default: {} } - if (providerQuery.isLoading) return EMPTY - return providerQuery.data ?? EMPTY - }, get config() { if (configQuery.isLoading) return {} return configQuery.data ?? {} @@ -289,7 +85,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) const queryClient = useQueryClient() - const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { setGlobalStore("project", next) } @@ -342,162 +137,56 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { scope: serverSDK.scope, persist: persisted, isBooting: (directory) => booting.has(directory), - isLoadingSessions: (directory) => sessionLoads.has(directory), + isLoadingSessions: () => false, onBootstrap: (directory) => { if (!connected()) return void bootstrapInstance(directory) }, - onMcp: (directory, setStore) => { - void loadCommands(directory, serverSDK.api.command) - .then((commands) => setStore("command", commands)) - .catch((err) => { - showToast({ - variant: "error", - title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), - description: formatServerError(err, language.t), - }) + onMcp: (directory) => { + void Promise.all([ + data.location.command.sync({ directory }), + data.location.mcp.server.sync({ directory }), + data.location.mcp.resource.sync({ directory }), + ]).catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), }) + }) }, onDispose: (directory) => { const key = directoryKey(directory) queue.clear(key) - sessionMeta.delete(key) - clearProviderRev(serverSDK.scope, key) }, translate: language.t, queryOptions: queryOptionsApi, + data, global: { - get provider() { - return globalStore.provider + get path() { + return globalStore.path }, }, }) - const catalog = createCatalogSync({ - scope: serverSDK.scope, - queryClient, - active: () => Object.keys(children.children).filter(children.active).map(pathKey), - load: (directory) => - Promise.all([ - queryClient.fetchQuery(queryOptionsApi.providers(directory)), - queryClient.fetchQuery(queryOptionsApi.integrations(directory)), - ]).then(() => undefined), - }) - const refreshVcs = (directory: string) => - serverSDK.api.vcs - .get({ location: { directory } }) - .then((result) => - children.vcs(directory, { - branch: result.data.branch.current, - default_branch: result.data.branch.default, - }), - ) - .catch(() => undefined) const connection = createConnectionSync({ status: serverSDK.connection.status, invalidate: () => { - session.invalidate() void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope, refetchType: "none", }) }, connected: (info) => { - if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined) - if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch() if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch() Object.keys(children.children) .filter(children.active) .forEach((directory) => { queue.push(directory) - if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory) + void data.location.sync({ directory }).catch(() => undefined) }) }, }) - async function loadCurrentSessions(directory: string, key: PathKey, limit: number) { - while (true) { - const revision = sessionRevision.get(key) ?? 0 - const result = await loadRootSessions({ api: serverSDK.api.session, directory, limit }) - if ((sessionRevision.get(key) ?? 0) === revision) return result - } - } - - async function loadSessions(directory: string, options?: { limit?: number }) { - const key = directoryKey(directory) - const pending = sessionLoads.get(key) - if (pending) { - await pending - return loadSessions(directory, options) - } - - children.pin(key) - const [store, setStore] = children.child(directory, { bootstrap: false }) - const meta = sessionMeta.get(key) - const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) - if (meta && meta.limit >= retainedLimit) { - const next = trimSessions(store.session, { - limit: retainedLimit, - permission: session.data.permission, - }) - if (next.length !== store.session.length) { - setStore("session", reconcile(next, { key: "id" })) - } - children.unpin(key) - return - } - - const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) - const promise = queryClient - .fetchQuery({ - ...queryOptionsApi.sessions(key), - queryFn: () => - loadCurrentSessions(directory, key, limit) - .then((x) => { - const nonArchived = (x.data ?? []) - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0) - const childSessions = store.session.filter((s) => !!s.parentID) - const next = trimSessions([...nonArchived, ...childSessions], { - limit, - permission: session.data.permission, - }) - batch(() => { - next.forEach(session.remember) - setStore( - "sessionTotal", - estimateRootSessionTotal({ - count: nonArchived.length, - limit: x.limit, - limited: x.limited, - }), - ) - setStore("session", reconcile(next, { key: "id" })) - }) - sessionMeta.set(key, { limit }) - }) - .catch((err) => { - console.error("Failed to load sessions", err) - const project = getFilename(directory) - showToast({ - variant: "error", - title: language.t("toast.session.listFailed.title", { project }), - description: formatServerError(err, language.t), - }) - }) - .then(() => null), - }) - .then(() => {}) - - sessionLoads.set(key, promise) - void promise.finally(() => { - sessionLoads.delete(key) - children.unpin(key) - }) - return promise - } - async function bootstrapInstance(directory: string) { const key = directoryKey(directory) if (!key) return @@ -507,8 +196,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { children.pin(key) const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) - const initial = child[0].status === "loading" await Promise.all([ + data.location.sync({ directory }), bootstrapDirectory({ directory, scope: serverSDK.scope, @@ -517,17 +206,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { config: globalStore.config, path: globalStore.path, project: globalStore.project, - provider: globalStore.provider, }, api: serverSDK.api, store: child[0], setStore: child[1], - loadSessions, translate: language.t, queryClient, - session, }), - initial ? refreshVcs(directory) : Promise.resolve(), ]) }) @@ -539,100 +224,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } - const indexSession = (info: Parameters[0]) => { - const key = directoryKey(info.location.directory) - const existing = children.children[key] - if (!existing) return - applyDirectoryEvent({ - event: { type: "session.created", properties: { info } }, - directory: key, - store: existing[0], - setStore: existing[1], - push: queue.push, - retainedLimit: sessionMeta.get(key)?.limit, - sessionContent: false, - permission: session.data.permission, - loadLsp() {}, - }) - } - const updateHomeSession = (info: Parameters[0]) => - homeSessions.apply({ - type: "session.updated", - properties: { sessionID: info.id, info }, - }) - const markSessionListsChanged = (event: ServerEvent) => { - sessionListEventDirectories(event).forEach((directory) => { - const key = directoryKey(directory) - sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1) - }) - } - const toDirectoryEvent = (event: ServerEvent) => { - if (event.current?.type === "session.created") return - if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event - const info = session.get(event.current.data.sessionID) - if (info) return { type: "session.updated", properties: { info } } - return event - } - - const unsub = serverSDK.event.listen((e) => { + const unsub = serverSDK.eventByDir.listen((e) => { + const directory = e.name + const key = directoryKey(directory) const event = e.details - const directory = event.current?.location?.directory const eventType: string = event.type - markSessionListsChanged(event) - if (event.current) session.applyV2(event.current) - session.apply(event) - if (event.current?.type === "session.moved") { - const info = session.get(event.current.data.sessionID) - if (info) indexSession(info) - } - if (shouldRefreshWorkspaceSessions(event)) { - void queryClient.invalidateQueries({ - predicate: (query) => - query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions", - }) - } - if (event.current?.type === "session.created") - void session - .resolve(event.current.data.sessionID, { force: true }) - .then((info) => { - if (!session.get(info.id)) return - indexSession(info) - homeSessions.apply({ - type: "session.created", - properties: { sessionID: info.id, info }, - }) - }) - .catch(() => {}) - if (event.current?.type === "session.deleted") - homeSessions.apply({ - type: "session.deleted", - properties: { sessionID: event.current.data.sessionID }, - }) - if (event.type === "session.created" || event.type === "session.deleted") { - if ("info" in event.properties) homeSessions.apply(event as Parameters[0]) - } - if ( - event.current?.type === "session.renamed" || - event.current?.type === "session.moved" || - event.current?.type === "session.usage.updated" - ) { - const sessionID = event.current.data.sessionID - const info = session.get(sessionID) - if (info) updateHomeSession(info) - if (!info) - void session - .resolve(sessionID) - .then(() => { - const current = session.get(sessionID) - if (current) updateHomeSession(current) - }) - .catch(() => undefined) - } - homeSessions.refresh(event.type) - catalog.handleEvent({ type: eventType, directory }) connection.handleEvent({ type: eventType }) - if (!directory) { + if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, @@ -645,50 +244,25 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } - const key = directoryKey(directory) - if (event.current?.type === "session.forked") - void session - .resolve(event.current.data.sessionID, { force: true }) - .then(indexSession) - .catch(() => {}) - const existing = children.children[key] if (!existing) return children.mark(key) if ( - event.current?.type === "session.moved" || - // event.current?.type === "session.archived" || - event.current?.type === "session.forked" || eventType === "config.updated" || eventType === "agent.updated" ) queue.push(key) - if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) - if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing - if (eventType === "agent.updated") - void queryClient - .fetchQuery(queryOptionsApi.agents(key)) - .then((data) => setStore("agent", data)) - .catch(() => {}) - if (eventType === "command.updated") - void loadCommands(directory, serverSDK.api.command) - .then((commands) => setStore("command", commands)) - .catch(() => {}) if (eventType === "worktree.updated") void bootstrap.refetch() - const projected = toDirectoryEvent(event) - if (projected) + if (eventType !== "vcs.branch.updated") applyDirectoryEvent({ - event: projected, + event, directory, store, setStore, push: (directory) => { if (children.active(directory)) queue.push(directory) }, - retainedLimit: sessionMeta.get(key)?.limit, - sessionContent: false, - permission: session.data.permission, vcsCache: children.vcsCache.get(key), loadLsp: () => { if (!children.active(key)) return @@ -696,7 +270,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, loadReferences: () => { if (!children.active(key)) return - void queryClient.fetchQuery(queryOptionsApi.references(key)) + void data.location.reference.sync({ directory: key }).catch(() => undefined) }, }) }) @@ -712,7 +286,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) const projectApi = { - loadSessions, meta(directory: string, patch: ProjectMeta) { children.projectMeta(directory, patch) }, @@ -729,34 +302,20 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, onSuccess: () => { bootstrap.refetch() - // Invalidate all provider queries so newly configured custom providers - // appear immediately in the available provider list across all directories. - queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] }) - queryClient.invalidateQueries({ - predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", - }) + data.location.provider.invalidate() + data.location.model.invalidate() + void Promise.all([data.location.provider.sync(), data.location.model.sync()]) }, })) return { data: globalStore, set, - get ready() { - return globalStore.ready - }, - get error() { - return globalStore.error - }, child: children.child, - peek: children.peek, disableMcp: children.disableMcp, - queryOptions: queryOptionsApi, - refreshProviders: catalog.refreshActive, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, - session: Object.assign(session, { hydrate: hydrateSession }), - homeSessions, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) @@ -790,8 +349,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { platform.openExternal(attempt.data.url) }, refresh: async () => { - await queryClient.refetchQueries(queryOptionsApi.mcp(key)) - await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) + data.location.mcp.server.invalidate({ directory: key }) + data.location.mcp.resource.invalidate({ directory: key }) + await Promise.all([ + data.location.mcp.server.sync({ directory: key }), + data.location.mcp.resource.sync({ directory: key }), + ]) }, }) }, @@ -799,25 +362,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { } } -export function createServerSyncContext(serverSDK: ServerSDK) { - const inner = createServerSyncContextInner(serverSDK) - return Object.assign(inner, { - ensureDirSyncContext: createRefCountMap( - (dir) => createDirSyncContext(dir, inner, serverSDK), - (dir) => inner.disableMcp(dir), - directoryKey, - ), - }) +export function createServerSyncContext(serverSDK: ServerSDK, data: Data) { + return createServerSyncContextInner(serverSDK, data) } export type ServerSync = ReturnType - -export const useServerSync = () => { - const server = useServer() - return server.ctx.sync -} - -export function useQueryOptions() { - const sync = useServerSync() - return sync.queryOptions -} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 698709a5cc..cf70fc096e 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } }, }) + +export const useData = () => { + const server = useServer() + return server.ctx.data +} diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 813c5698fd..54757c59c9 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,12 +1,108 @@ -import { createMemo } from "solid-js" -import { useServerSync } from "./server-sync" -import { useSDK } from "./sdk" +import { Binary } from "@opencode-ai/core/util/binary" +import type { Message, Part } from "@/types" +import { messageKey } from "@/utils/session-message" -export const useSync = () => { - const serverSync = useServerSync() - const sdk = useSDK() +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) - return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory)) +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) } -export type DirectorySync = ReturnType> +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +type OptimisticStore = { + message: Record + part: Record +} + +type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +type OptimisticItem = { + message: Message + parts: Part[] +} + +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +const hasParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return want.length === 0 + return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +} + +const mergeParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return sortParts(want) + const next = [...parts] + let changed = false + for (const part of want) { + const result = Binary.search(next, part.id, (item) => item.id) + if (result.found) continue + next.splice(result.index, 0, part) + changed = true + } + if (!changed) return parts + return next +} + +export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, confirmed: [] as string[] } + + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) + const confirmed: string[] = [] + + for (const item of items) { + const result = Binary.search(session, messageKey(item.message), messageKey) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + + const current = part.get(item.message.id) + if (found && hasParts(current, item.parts)) { + confirmed.push(item.message.id) + continue + } + + part.set(item.message.id, mergeParts(current, item.parts)) + } + + return { + cursor: page.cursor, + complete: page.complete, + session, + part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), + confirmed, + } +} + +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, messageKey(input.message), messageKey) + messages.splice(result.index, 0, input.message) + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const index = messages.findIndex((message) => message.id === input.messageID) + if (index >= 0) messages.splice(index, 1) + } + delete draft.part[input.messageID] +} diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 249155a64e..fc53d2f546 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,7 +1,7 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" -import { useSDK, type DirectorySDK } from "./sdk" +import { useWorkspaceLocation, type LocationContext } from "./location" import type { Platform } from "./platform" import { useServerSDK } from "./server-sdk" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -119,10 +119,16 @@ export function clearWorkspaceTerminals(dir: string, platform?: Platform, scope: entry?.value.clear() } - void removePersisted(terminalPersistTarget(scope, storageDir), platform) + const target = terminalPersistTarget(scope, storageDir) + void removePersisted({ storage: target.storage, key: target.key }, platform) } -function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: ServerScopeValue) { +function createWorkspaceTerminalSession( + sdk: LocationContext, + serverSDK: ReturnType, + dir: string, + scope: ServerScopeValue, +) { const location = { directory: sdk.directory } const [store, setStore, _, ready] = persisted( @@ -221,7 +227,7 @@ function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: S setStore("all", index, (item) => ({ ...item, ...pty })) } const doUpdate = async () => { - await sdk.api.pty.update({ + await serverSDK.api.pty.update({ ptyID: pty.id, location, title: pty.title, @@ -241,7 +247,7 @@ function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: S const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const data = await sdk.api.pty + const data = await serverSDK.api.pty .create({ location, title: pty.title }) .then((result) => result.data) .catch((error: unknown) => { @@ -284,7 +290,7 @@ function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: S const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined const doCreate = async () => { - return sdk.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data) + return serverSDK.api.pty.create({ location, title: defaultTitle(nextNumber) }).then((result) => result.data) } doCreate() .then((data) => { @@ -388,7 +394,7 @@ function createWorkspaceTerminalSession(sdk: DirectorySDK, dir: string, scope: S }) } - await sdk.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => { + await serverSDK.api.pty.remove({ ptyID: id, location }).catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, @@ -409,7 +415,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont name: "Terminal", gate: false, init: () => { - const sdk = useSDK() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() const cache = new Map() const scope = () => serverSDK.scope @@ -448,7 +454,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk(), dir, serverScope), + value: createWorkspaceTerminalSession(sdk(), serverSDK, dir, serverScope), dispose, })) diff --git a/packages/app/src/hooks/use-integrations.ts b/packages/app/src/hooks/use-integrations.ts index 4966624e89..5a327cdeb6 100644 --- a/packages/app/src/hooks/use-integrations.ts +++ b/packages/app/src/hooks/use-integrations.ts @@ -1,21 +1,22 @@ import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { pathKey } from "@/utils/path-key" -import { createQuery } from "@tanstack/solid-query" -import type { Accessor } from "solid-js" +import { useData } from "@/context/server" +import { createEffect, type Accessor } from "solid-js" export function useIntegrations(directory: Accessor) { const serverSDK = useServerSDK() - const serverSync = useServerSync() - const query = createQuery(() => { + const data = useData() + + createEffect(() => { + if (serverSDK.connection.status() !== "connected") return const value = directory() - return { - ...serverSync.queryOptions.integrations(value ? pathKey(value) : null), - enabled: serverSDK.connection.status() === "connected", - } + void (async () => { + const ref = value ? { directory: value } : undefined + if (!ref) await data.location.syncInfo() + await data.location.integration.sync(ref ?? data.location.default()) + })().catch(() => undefined) }) return { - list: () => (query.isSuccess || query.isRefetchError ? query.data : []), + list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [], } } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 4cd905dcd7..df9a33e39e 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -1,10 +1,10 @@ -import { useQueryOptions } from "@/context/server-sync" +import { useData } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { normalizeProviderList } from "@/context/global-sync/utils" import { Iterable, pipe } from "effect" -import { type Accessor } from "solid-js" +import { createEffect, createMemo, type Accessor } from "solid-js" import { emptyProviderCatalog } from "./provider-catalog" import { useIntegrations } from "./use-integrations" -import { useQuery } from "@tanstack/solid-query" -import { pathKey } from "@/utils/path-key" export const popularProviders = [ "opencode", @@ -19,17 +19,37 @@ export const popularProviders = [ const popularProviderSet = new Set(popularProviders) export function useProviders(directory: Accessor) { - const providersQuery = useQuery(() => { - const queryOpts = useQueryOptions() + const data = useData() + const sdk = useServerSDK() + const location = () => { const dir = directory() - return queryOpts.providers(dir ? pathKey(dir) : null) + return dir ? { directory: dir } : undefined + } + + createEffect(() => { + if (sdk.connection.status() !== "connected") return + const ref = location() + void (async () => { + if (!ref) await data.location.syncInfo() + const resolved = ref ?? data.location.default() + await Promise.all([data.location.provider.sync(resolved), data.location.model.sync(resolved)]) + })().catch(() => undefined) }) const integrations = useIntegrations(directory) - const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data) + const providers = createMemo(() => { + const ref = location() + const provider = data.location.provider.list(ref) + const model = data.location.model.list(ref) + if (!provider || !model) return emptyProviderCatalog + return normalizeProviderList(provider, model) + }) return { - ready: () => providersQuery.isSuccess, + ready: () => { + const ref = location() + return data.location.provider.list(ref) !== undefined && data.location.model.list(ref) !== undefined + }, all: () => providers().all, default: () => providers().default, // V2 servers list only available providers, so the connectable catalog diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 9a8e023299..fb9dde493d 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,7 +1,6 @@ export { AppBaseProviders, AppInterface } from "./app" export { useLayout } from "./context/layout" export { useServerSDK } from "./context/server-sdk" -export { useServerSync } from "./context/server-sync" export { useServers as useServers } from "./context/servers" export { useSettings } from "./context/settings" export { useTabs } from "./context/tabs" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 4185763a65..249a80a7f8 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,13 +1,12 @@ import { DataProvider } from "@opencode-ai/session-ui/context" import { useNavigate, useParams } from "@solidjs/router" -import { createEffect, createResource, onCleanup, type ParentProps, Show } from "solid-js" +import { createMemo, type ParentProps } from "solid-js" import { LocalProvider } from "@/context/local" -import { useSync } from "@/context/sync" import type { ServerConnection } from "@/context/servers" import { sessionHref } from "@/utils/session-route" -import { useServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" -export function DirectoryDataProvider( +export function SessionUIProvider( props: ParentProps<{ directory: string server: ServerConnection.Key @@ -15,41 +14,37 @@ export function DirectoryDataProvider( ) { const navigate = useNavigate() const params = useParams() - const sync = useSync() - const serverSync = useServerSync() + const data = useData() const directory = () => props.directory const href = (sessionID: string) => sessionHref(props.server, sessionID) const navigateToSession = async (sessionID: string) => { - const session = serverSync.session - await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)]) + await data.session.sync(sessionID).catch(() => undefined) navigate(href(sessionID)) } - - createResource( - () => params.id, - (id) => serverSync.session.hydrate(id).catch(() => {}), - ) - - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - serverSync.session.pin(sessionID) - onCleanup(() => serverSync.session.unpin(sessionID)) - }) + const legacyData = createMemo(() => ({ + session: data.session.list(), + session_status: Object.fromEntries( + data.session + .list() + .map((session) => [ + session.id, + data.session.status(session.id) === "running" ? ({ type: "busy" } as const) : ({ type: "idle" } as const), + ]), + ), + session_diff: {}, + message: {}, + part: {}, + })) return ( - - {(directory) => ( - - {props.children} - - )} - + + {props.children} + ) } diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 94a5e483c3..e077a3d1c0 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -3,12 +3,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { skipToken, useQuery } from "@tanstack/solid-query" import { DateTime } from "luxon" import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js" -import { produce } from "solid-js/store" import { useCommand } from "@/context/command" import { + homeSessionIndexKey, loadHomeSessionIndex, + parseHomeSessionIndex, retainHomeSessions, - type HomeSessionEvents, } from "@/context/global-sync/home-session-index" import type { LocalProject } from "@/context/layout" import { useLanguage } from "@/context/language" @@ -18,7 +18,6 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" import { archiveHomeSession } from "../home-session-archive" import type { HomeController } from "./home-controller" @@ -50,33 +49,23 @@ export function createHomeSessionsController(home: HomeController) { const projectByID = createMemo( () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) - const homeSessions = () => home.server.focusedSync()?.homeSessions - const sessionEventLoad = useQuery(() => { - const cache = homeSessions() - return { - queryKey: cache?.eventsKey ?? (["home", "session-events", "unselected"] as const), - queryFn: cache ? async (): Promise => ({ sequence: 0, entries: [] }) : skipToken, - initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, - enabled: false, - } - }) const sessionLoad = useQuery(() => { - const cache = homeSessions() + const ctx = home.server.focusedContext() + const conn = home.server.focused() return { - queryKey: cache?.indexKey ?? (["home", "session-index", "unselected"] as const), - enabled: !!cache && home.server.focusedContext()?.sdk.connection.status() === "connected", + queryKey: conn + ? homeSessionIndexKey(ServerConnection.key(conn)) + : (["home", "session-index", "unselected"] as const), + enabled: !!ctx && ctx.sdk.connection.status() === "connected", queryFn: - cache && home.server.focusedContext() + ctx ? async ({ signal }) => { - const ctx = home.server.focusedContext()! - const eventSequence = cache.eventSequence() const index = await loadHomeSessionIndex( (input, options) => ctx.sdk.api.session.list(input, options), - eventSequence, signal, ) - cache.complete(eventSequence) - return index + index.forEach(ctx.data.session.remember) + return Date.now() } : skipToken, retry: false, @@ -86,9 +75,9 @@ export function createHomeSessionsController(home: HomeController) { } }) const indexedSessions = createMemo(() => { - const cache = homeSessions() - if (!cache) return [] - return retainHomeSessions(cache.sessions(sessionLoad.data, sessionEventLoad.data), HOME_SESSION_LIMIT, Date.now()) + const ctx = home.server.focusedContext() + if (!ctx) return [] + return retainHomeSessions(parseHomeSessionIndex(ctx.data.session.list()), HOME_SESSION_LIMIT, Date.now()) }) const allRecords = createMemo(() => buildHomeSessionRecords({ @@ -112,7 +101,7 @@ export function createHomeSessionsController(home: HomeController) { const key = `${ServerConnection.key(conn)}\0${record.session.id}` if (prefetched.has(key)) return prefetched.add(key) - void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {}) + void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {}) }) }) @@ -179,7 +168,7 @@ export function createHomeSessionsController(home: HomeController) { const directory = project?.worktree ?? session.location.directory const ctx = home.server.focusedContext() if (!ctx) return - ctx.sync.session.remember(session) + ctx.data.session.remember(session) ctx.projects.open(directory) if (options?.background) { tabs.addSessionTab({ server: connKey, sessionId: session.id }) @@ -195,19 +184,12 @@ export function createHomeSessionsController(home: HomeController) { const conn = home.server.focused() const ctx = home.server.focusedContext() if (!conn || !ctx) return - const [, setStore] = ctx.sync.child(session.location.directory) await archiveHomeSession({ server: ServerConnection.key(conn), session, // TODO: Restore archiving when the V2 client exposes a session archive API. archive: async (_sessionID) => Promise.reject(new Error("Session archiving is unavailable")), - remove: () => - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (item) => item.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ), + remove() {}, onError: (cause) => showToast({ title: language.t("common.requestFailed"), diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index dfdbfb115e..65cb4cef28 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -11,32 +11,28 @@ export function useSessionTabAvatarState( const global = useGlobal() const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const serverCtx = useServerCtx(connection) - const sync = () => serverCtx()?.sync const hasPermissions = createMemo(() => { const ctx = serverCtx() if (!ctx) return false - const sync = ctx.sync const permission = ctx.permission - const [store] = sync.child(directory(), { bootstrap: false }) - return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => { + return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => { return !permission.autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { - const serverSync = sync() - if (!serverSync) return false - const [store] = serverSync.child(directory(), { bootstrap: false }) - return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId()) + const data = serverCtx()?.data + if (!data) return false + return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) const unread = createMemo( () => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0, ) const loading = createMemo(() => { - const serverSync = sync() - if (!serverSync) return false + const data = serverCtx()?.data + if (!data) return false if (needsAttention()) return false - return serverSync.session.data.session_working(sessionId()) + return data.session.status(sessionId()) === "running" }) return { unread, loading } } diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts index dbcd4a20dc..3c92222660 100644 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -4,7 +4,6 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useComments } from "@/context/comments" import { useLocal } from "@/context/local" import { usePrompt } from "@/context/prompt" -import { useServerSync } from "@/context/server-sync" import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" import { useSessionKey } from "@/pages/session/session-layout" @@ -17,7 +16,6 @@ export function createNewSessionDraftController(props: { onSubmit: () => void }) { const prompt = usePrompt() - const serverSync = useServerSync() const comments = useComments() const local = useLocal() const route = useSessionKey() @@ -29,7 +27,6 @@ export function createNewSessionDraftController(props: { const controls = createPromptInputController({ sessionKey: route.sessionKey, sessionID: () => route.params.id, - queryOptions: serverSync.queryOptions, model, }) const projectControls = createPromptProjectControls({ diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index f2c0e55066..86f523642d 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -16,8 +16,7 @@ import { } from "@/components/prompt-project-selector" import { StatusPopoverV2 } from "@/components/status-popover" import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { useServerSync } from "@/context/server-sync" +import { useWorkspaceLocation } from "@/context/location" import { useProviders } from "@/hooks/use-providers" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { Persist, persisted } from "@/utils/persist" @@ -110,8 +109,7 @@ export function NewSessionStatus(props: { mount: HTMLElement | null; visible: bo function ProviderTip() { const language = useLanguage() const dialog = useDialog() - const sdk = useSDK() - const serverSync = useServerSync() + const sdk = useWorkspaceLocation() const providers = useProviders(() => sdk().directory) const [persistedState, setPersistedState, , persistedReady] = persisted( Persist.global("new-session.provider-tip"), @@ -119,7 +117,7 @@ function ProviderTip() { ) const visible = createMemo( () => - serverSync.child(sdk().directory)[0].provider_ready && + providers.ready() && persistedReady() && providers.paid().length === 0 && Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts index 9556d21678..80cd92acd0 100644 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -1,9 +1,9 @@ -import { createMemo } from "solid-js" -import { useSDK } from "@/context/sdk" +import { createEffect, createMemo } from "solid-js" +import { useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" +import { useData } from "@/context/server" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { isWorkspaceDirectory, isWorkspaceSelection, @@ -48,23 +48,29 @@ export function createNewSessionWorkspaceController(input: { setSelected: (worktree: string | undefined) => void onViewAll: () => void }) { - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() - const serverSync = useServerSync() + const data = useData() const settings = useSettings() - const localVcs = createMemo(() => serverSync.child(sdk().directory)[0].vcs) + const currentProject = createMemo(() => { + const projectID = data.location.info({ directory: sdk().directory })?.project.id + const current = projectID ? data.project.get(projectID) : undefined + return current ? normalizeProjectInfo(current) : undefined + }) const visible = createMemo(() => - resolveNewSessionGit({ projectVcs: sync().project?.vcs, branch: localVcs()?.branch }), + resolveNewSessionGit({ + projectVcs: currentProject()?.vcs, + branch: data.location.vcs.info({ directory: sdk().directory })?.branch.current, + }), ) const selected = createMemo(() => { - const project = sync().project + const project = currentProject() const worktree = input.selected() if (!project || !worktree) return return isWorkspaceSelection(project, worktree) ? worktree : undefined }) const fallback = createMemo(() => { - const project = sync().project + const project = currentProject() if (!project) return "main" return workspaceDefaultSelection( settings.workspaces.defaultDestination(), @@ -76,21 +82,27 @@ export function createNewSessionWorkspaceController(input: { enabled: visible(), selected: selected(), directory: sdk().directory, - projectWorktree: sync().project?.worktree, + projectWorktree: currentProject()?.worktree, fallback: fallback(), }), ) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch) + const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory) + createEffect(() => { + void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined) + const project = currentProject() + const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory] + directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined)) + }) + const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current) const branch = createMemo(() => resolveNewSessionBranch({ worktree: value(), local: localBranch(), - worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch, + worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current, }), ) const remember = (worktree = value()) => { - const project = sync().project + const project = currentProject() if (!project) return const local = worktree === "main" || sameDirectory(worktree, project.worktree) settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace") @@ -100,21 +112,21 @@ export function createNewSessionWorkspaceController(input: { selection: { value, workspace: createMemo(() => { - const project = sync().project + const project = currentProject() const current = value() return current === "create" || (!!project && isWorkspaceDirectory(project, current)) }), reset: () => input.setSelected(undefined), remember, set: (worktree: string) => { - input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)) + input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree)) remember(worktree) }, }, project: { root: projectRoot, workspaces: () => { - const project = sync().project + const project = currentProject() return project ? workspaceDirectories(project) : [] }, git: visible, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 428e8628ac..35c04c58ec 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FilePart, Project, UserMessage } from "@/types" +import type { FilePart, UserMessage } from "@/types" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -38,32 +38,31 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { showToast } from "@/utils/toast" import { checksum } from "@opencode-ai/core/util/encode" -import { isWorkspaceDirectory } from "@/utils/workspace" +import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace" import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" import { useCommand } from "@/context/command" -import { DirectoryDataProvider } from "@/pages/directory-layout" -import { useServerSync } from "@/context/server-sync" +import { SessionUIProvider } from "@/pages/directory-layout" +import { useData, useServer } from "@/context/server" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { useNotification } from "@/context/notification" import { PromptProvider, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" -import { SDKProvider, useSDK } from "@/context/sdk" +import { LocationProvider, useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" import { ServerConnection, serverName, useServers } from "@/context/servers" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider } from "@/context/terminal" import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useSettingsCommand } from "@/components/settings-dialog" import { setCursorPosition } from "@/components/prompt-input/editor-dom" import { promptLength } from "@/components/prompt-input/history" -import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" +import type { FollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, createSessionComposerController, @@ -95,11 +94,10 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" -import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" -import { createSessionLineage } from "./session/session-lineage" +import { createSessionResolution } from "./session/session-resolution" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -118,37 +116,14 @@ function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undef return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) } -async function runPromptRollbackMutation(input: { - capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } - optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void - request: () => Promise - complete: (result: R) => void - rollback: () => void - fail: (error: unknown) => void -}) { - const prompt = input.capturePrompt() - const previous = prompt.current().slice() - batch(() => input.optimistic(prompt)) - await input - .request() - .then(input.complete) - .catch((error) => { - batch(() => { - input.rollback() - prompt.set(previous) - }) - input.fail(error) - }) -} - // Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed // remount around the server-scoped providers. Nothing here may key on the // session ID: session tabs on the same server share this route instance, and // workspace-scoped state (terminal, directory providers) lives below. export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() - const serverSync = useServerSync() - const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory) + const data = useData() + const directory = createMemo(() => data.session.get(params.id)?.location.directory) return ( // Settings must keep the target-server SDK, sync, and models context and remain registered // when session content falls back to the route error boundary. @@ -232,37 +207,28 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve } function ResolvedTargetSessionRoute() { - const params = useParams<{ serverKey: string; id: string }>() - const tabs = useTabs() - const sync = useServerSync() - const serverKey = createMemo(() => requireServerKey(params.serverKey)) - const current = createSessionLineage( + const params = useParams<{ id: string }>() + const server = useServer() + const data = useData() + const current = createSessionResolution( () => params.id, - () => sync.session.lineage, + () => data.session, + { children: true }, ) - const directory = createMemo(() => current()?.session.location.directory) - - createEffect(() => { - const session = current() - if (!session) return - tabs.addSessionTab({ - server: serverKey(), - sessionId: session.root.id, - }) - }) + const directory = createMemo(() => current()?.location.directory) return ( // Non-keyed: closes only while the target's directory is unknown (uncached - // lineage mid-resolution), which tears down the workspace subtree including + // session mid-resolution), which tears down the workspace subtree including // the terminal. Same-workspace tab switches keep it open because warm // targets resolve synchronously from the sync cache. {(dir) => ( - - + + - - + + )} ) @@ -272,10 +238,10 @@ function ResolvedTargetSessionRoute() { // key: SessionPage handles session changes reactively, and remounting here // destroys workspace-scoped state (terminal PTYs, file/prompt providers). function TargetSessionPage() { - const sdk = useSDK() + const location = useWorkspaceLocation() const serverSDK = useServerSDK() return ( - + @@ -322,15 +288,14 @@ function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) { } export default function Page() { - const serverSync = useServerSync() + const data = useData() const layout = useLayout() const local = useLocal() const file = useFile() - const sync = useSync() const queryClient = useQueryClient() const dialog = useDialog() const language = useLanguage() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() const settings = useSettings() const platform = usePlatform() @@ -340,8 +305,17 @@ export default function Page() { const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const navigate = useNavigate() + const params = useParams() const isDesktop = createMediaQuery("(min-width: 768px)") - const canReview = createMemo(() => !!sync().project) + const project = createMemo(() => { + const info = params.id ? data.session.get(params.id) : undefined + const value = info?.projectID + ? data.project.get(info.projectID) + : data.project.list().find((item) => containsDirectory(item.canonical, sdk().directory)) + if (!value) return + return { ...value, worktree: value.canonical, worktrees: [] } + }) + const canReview = createMemo(() => !!project()) const controller = createSessionController({ review: isDesktop, hasReview: canReview, @@ -376,7 +350,6 @@ export default function Page() { const inputController = createPromptInputController({ sessionKey: controller.identity.sessionKey, sessionID: () => controller.identity.params.id, - queryOptions: serverSync.queryOptions, }) const sessionPanelKey = createMemo(() => @@ -446,7 +419,7 @@ export default function Page() { } const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory) - const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory())) + const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory())) const timeline = createTimelineModel({ session: controller }) const historyLoading = timeline.history.loading const historyMore = timeline.history.more @@ -533,10 +506,6 @@ export default function Page() { }) let reviewFrame: number | undefined - let todoFrame: number | undefined - let todoTimer: number | undefined - let diffFrame: number | undefined - let diffTimer: number | undefined createComputed((prev) => { const open = desktopReviewOpen() @@ -551,17 +520,23 @@ export default function Page() { return open }, desktopReviewOpen()) - const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) + // TODO: Restore turn diffs when current transcript projections expose message summaries. + const turnDiffs = createMemo(() => list(undefined)) const nogit = createMemo(() => { - const project = sync().project - return !!project && project.vcs !== "git" + const current = project() + return !!current && current.vcs !== "git" }) + const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory })) const changesOptions = createMemo(() => { const list: ChangeMode[] = [] - const project = sync().project - const vcs = sync().data.vcs - if (project?.vcs === "git") list.push("git") - if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { + const current = project() + if (current?.vcs === "git") list.push("git") + if ( + current?.vcs === "git" && + vcs()?.branch.current && + vcs()?.branch.default && + vcs()?.branch.current !== vcs()?.branch.default + ) { list.push("branch") } list.push("turn") @@ -584,13 +559,13 @@ export default function Page() { serverSDK.scope, "session-vcs", sdk().directory, - sync().data.vcs?.branch ?? "", - sync().data.vcs?.default_branch ?? "", + vcs()?.branch.current ?? "", + vcs()?.branch.default ?? "", ] as const, ) const vcsQuery = createQuery(() => { const mode = vcsMode() - const enabled = serverSDK.connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git" + const enabled = serverSDK.connection.status() === "connected" && wantsReview() && project()?.vcs === "git" return { queryKey: [...vcsKey(), mode] as const, @@ -599,18 +574,21 @@ export default function Page() { refetchOnWindowFocus: true, queryFn: mode ? () => - sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) + serverSDK.api.vcs + .diff({ + location: { directory: sdk().directory }, + mode: mode === "git" ? "working" : mode, + }) .then((result) => result.data) : skipToken, } }) const sessionDetailsQuery = createQuery(() => ({ queryKey: [serverSDK.scope, "session-details", sessionDirectory()] as const, - enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && sync().project?.vcs === "git", + enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && project()?.vcs === "git", queryFn: () => - sdk() - .api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" }) + serverSDK.api.vcs + .diff({ location: { directory: sessionDirectory() }, mode: "working" }) .then((result) => result.data) .catch((error) => { console.debug("[session-review] failed to load session details diff", { error }) @@ -658,7 +636,7 @@ export default function Page() { const loadReviewDiff = async (file: string, version?: number): Promise => { const mode = vcsMode() if (!mode) return - const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) + const root = reviewRootDirectory(project()?.worktree ?? sdk().directory) const directory = reviewDiffDirectory(root, file) const source = reviewDiffs().find((diff) => diff.file === file) const valid = (diff: FileDiffInfo | undefined) => { @@ -674,8 +652,8 @@ export default function Page() { staleTime: Number.POSITIVE_INFINITY, retry: 2, queryFn: () => - sdk() - .api.vcs.diff({ + serverSDK.api.vcs + .diff({ location: { directory: scope }, mode: mode === "git" ? "working" : mode, context, @@ -702,8 +680,8 @@ export default function Page() { const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" - const project = sync().project - if (project && sdk().directory !== project.worktree) return sdk().directory + const current = project() + if (current && sdk().directory !== current.worktree) return sdk().directory return "main" }) @@ -764,25 +742,6 @@ export default function Page() { scrollToMessage(msgs[targetIndex], "auto") } - function upsert(next: Project) { - const list = serverSync.data.project - sync().set("project", next.id) - const idx = list.findIndex((item) => item.id === next.id) - if (idx >= 0) { - serverSync.set( - "project", - list.map((item, i) => (i === idx ? { ...item, ...next } : item)), - ) - return - } - const at = list.findIndex((item) => item.id > next.id) - if (at >= 0) { - serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)]) - return - } - serverSync.set("project", [...list, next]) - } - const gitMutation = useMutation(() => ({ // TODO: Restore Git initialization when the V2 client exposes this operation. mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")), @@ -825,40 +784,7 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - createEffect( - on( - () => { - const id = controller.identity.params.id - return [ - sdk().directory, - id, - id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", - id ? composer.blocked() : false, - ] as const - }, - ([dir, id, status, blocked]) => { - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) - todoFrame = undefined - todoTimer = undefined - if (!id) return - if (status === "idle" && !blocked) return - const cached = untrack(() => sync().data.todo[id] !== undefined) - - todoFrame = requestAnimationFrame(() => { - todoFrame = undefined - todoTimer = window.setTimeout(() => { - todoTimer = undefined - if (sdk().directory !== dir || controller.identity.params.id !== id) return - untrack(() => { - void sync().session.todo(id, cached ? { force: true } : undefined) - }) - }, 0) - }) - }, - { defer: true }, - ), - ) + // TODO: Restore todo refresh when todos are available from the current session API. createEffect( on( @@ -1005,8 +931,8 @@ export default function Page() { createEffect(() => { if (!layout.ready()) return - if (sync().status !== "complete") return - if (!sync().project) return + if (serverSDK.connection.status() !== "connected") return + if (!project()) return const list = changesOptions() const mode = reviewMode() if (list.includes(mode)) return @@ -1017,7 +943,7 @@ export default function Page() { createEffect( on( - () => sync().data.session_status[controller.identity.params.id ?? ""]?.type, + () => data.session.status(controller.identity.params.id ?? ""), (next, prev) => { if (next !== "idle" || prev === undefined || prev === "idle") return refreshVcs() @@ -1380,7 +1306,7 @@ export default function Page() { const dir = sdk().directory if (!isDesktop()) return if (!layout.fileTree.opened()) return - if (sync().status === "loading") return + if (serverSDK.connection.status() !== "connected") return fileTreeTab() const refresh = treeDir !== dir @@ -1576,41 +1502,6 @@ export default function Page() { ), ) - const draft = (id: string) => - extractPromptFromParts(sync().data.part[id] ?? [], { - directory: sdk().directory, - attachmentName: language.t("common.attachment"), - }) - - const line = (id: string) => { - const text = draft(id) - .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) - .join("") - .replace(/\s+/g, " ") - .trim() - if (text) return text - return `[${language.t("common.attachment")}]` - } - - const fail = (err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - } - - const roll = ( - sessionID: string, - next: NonNullable>["revert"], - target = sync(), - ) => { - const session = target.session.get(sessionID) - if (!session) return - target.session.remember({ ...session, revert: next }) - } - - const busy = (sessionID: string) => sync().data.session_working(sessionID) const queuedFollowups = createMemo(() => { const id = controller.identity.params.id if (!id) return emptyFollowups @@ -1631,29 +1522,13 @@ export default function Page() { const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { - const owner = controller.ownership.capture() - const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) - if (!item) return + if (!(followup.items[input.sessionID] ?? []).some((entry) => entry.id === input.id)) return if (input.manual) setFollowup("paused", input.sessionID, undefined) setFollowup("failed", input.sessionID, undefined) - const ok = await sendFollowupDraft({ - api: sdk().api.session, - sync: sync(), - serverSync: serverSync, - session: () => sync().session.get(input.sessionID), - draft: item, - optimisticBusy: item.sessionDirectory === sdk().directory, - }).catch((err) => { - setFollowup("failed", input.sessionID, input.id) - fail(err) - return false - }) - if (!ok) return - - setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) owner.run(resumeScroll) + // TODO: Restore queued followups once submission no longer requires the legacy sync transcript. + setFollowup("failed", input.sessionID, input.id) }, })) @@ -1670,12 +1545,8 @@ export default function Page() { const queueEnabled = createMemo(() => { const id = controller.identity.params.id if (!id) return false - return ( - settings.general.followup() === "queue" && - controller.data.working() && - !composer.blocked() && - !controller.data.isChild() - ) + // TODO: Restore queued followups with current session admission APIs. + return false }) const followupText = (item: FollowupDraft) => { @@ -1707,7 +1578,7 @@ export default function Page() { const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { - if (sync().session.get(sessionID)?.parentID) return Promise.resolve() + if (data.session.get(sessionID)?.parentID) return Promise.resolve() const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() @@ -1738,88 +1609,12 @@ export default function Page() { setFollowup("edit", id, undefined) } - const halt = (sessionID: string) => - busy(sessionID) - ? sdk() - .api.session.interrupt({ sessionID }) - .catch(() => {}) - : Promise.resolve() - - const revertMutation = useMutation(() => ({ - mutationFn: async (input: { sessionID: string; messageID: string }) => { - const api = sdk().api.session - const target = sync() - const last = target.session.get(input.sessionID)?.revert - const value = draft(input.messageID) - await runPromptRollbackMutation({ - capturePrompt: prompt.capture, - optimistic: (prompt) => { - roll(input.sessionID, { messageID: input.messageID }, target) - prompt.set(value) - }, - request: () => halt(input.sessionID).then(() => api.revert.stage(input)), - complete: () => undefined, - rollback: () => roll(input.sessionID, last, target), - fail, - }) - }, - })) - - const restoreMutation = useMutation(() => ({ - mutationFn: async (id: string) => { - const sessionID = controller.identity.params.id - if (!sessionID) return - - const api = sdk().api.session - const target = sync() - const index = userMessages().findIndex((item) => item.id === id) - if (index < 0) return - const next = userMessages()[index + 1] - const last = target.session.get(sessionID)?.revert - - await runPromptRollbackMutation({ - capturePrompt: prompt.capture, - optimistic: (promptSession) => { - roll(sessionID, next ? { messageID: next.id } : undefined, target) - if (next) { - promptSession.set(draft(next.id)) - return - } - promptSession.reset() - }, - request: () => - !next - ? halt(sessionID).then(() => api.revert.clear({ sessionID })) - : halt(sessionID).then(() => api.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), - complete: () => undefined, - rollback: () => roll(sessionID, last, target), - fail, - }) - }, - })) - - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) - const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) - - const revert = (input: { sessionID: string; messageID: string }) => { - if (reverting()) return - return revertMutation.mutateAsync(input) - } - - const restore = (id: string) => { - if (!controller.identity.params.id || reverting()) return - return restoreMutation.mutateAsync(id) - } - - const rolled = createMemo(() => { - const id = controller.data.revertMessageID() - if (!id) return [] - const index = userMessages().findIndex((item) => item.id === id) - if (index < 0) return [] - return userMessages() - .slice(index) - .map((item) => ({ id: item.id, text: line(item.id) })) - }) + // TODO: Restore revert drafts when current transcript messages expose prompt parts. + const reverting = () => false + const restoring = () => undefined + const revert = (_input: { sessionID: string; messageID: string }) => undefined + const restore = (_id: string) => undefined + const rolled = () => [] // attachment bytes are embedded as a data URL, so downloading always works; // revealing requires the on-disk path captured by the client that attached the file @@ -1891,7 +1686,8 @@ export default function Page() { visibleUserMessages, historyMore, historyLoading, - loadMore: (sessionID) => sync().session.history.loadMore(sessionID), + // TODO: Restore hash paging when the current message API exposes history cursors. + loadMore: async () => undefined, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, setPendingMessage: (value) => setUi("pendingMessage", value), @@ -1925,10 +1721,6 @@ export default function Page() { onCleanup(() => { if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) }) @@ -2057,10 +1849,6 @@ export default function Page() { prompt, ready: () => !store.deferRender && messagesReady(), centered, - todo: { - collapsed: () => controller.layout.view().todoCollapsed.get(), - onToggle: () => controller.layout.view().todoCollapsed.set(!controller.layout.view().todoCollapsed.get()), - }, followup: () => controller.identity.params.id && !controller.data.isChild() ? { diff --git a/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app/src/pages/session/composer/prompt-model-selection.ts index 3f5bb0f477..1d43a764ab 100644 --- a/packages/app/src/pages/session/composer/prompt-model-selection.ts +++ b/packages/app/src/pages/session/composer/prompt-model-selection.ts @@ -3,13 +3,11 @@ import { useModels } from "@/context/models" import type { ModelKey, ModelSelection } from "@/context/local" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant" import { usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" import { useProviders } from "@/hooks/use-providers" export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) { - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() const models = useModels() const prompt = usePrompt() const providers = useProviders(() => sdk().directory) @@ -21,18 +19,14 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model } const configured = () => { - const value = sync().data.config.model - if (!value) return - const [providerID, modelID] = value.split("/") - const model = { providerID, modelID } - if (valid(model)) return model + // TODO: Restore the configured model fallback when current location data exposes config. + return undefined } const recent = () => models.recent.list().find(valid) const fallback = () => { - const defaults = providers.default() return providers.connected().flatMap((provider) => { - const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id + const modelID = Object.values(provider.models)[0]?.id return modelID ? [{ providerID: provider.id, modelID }] : [] })[0] } diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 4d2476c560..f2a4d15e9a 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -1,5 +1,4 @@ import { base64Encode } from "@opencode-ai/core/util/encode" -import { createQuery } from "@tanstack/solid-query" import { useNavigate, useSearchParams } from "@solidjs/router" import { type Accessor, createMemo } from "solid-js" import type { PromptInputControls } from "@/components/prompt-input/contracts" @@ -8,43 +7,40 @@ import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal, useServerCtx } from "@/context/global" import { useLayout } from "@/context/layout" import { useLocal, type ModelSelection } from "@/context/local" -import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServers } from "@/context/servers" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" import { useTabs } from "@/context/tabs" import { useProviders } from "@/hooks/use-providers" -import { pathKey } from "@/utils/path-key" +import { useData } from "@/context/server" +import { normalizeAgentList } from "@/context/global-sync/utils" export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor - queryOptions: Pick model?: ModelSelection }) { const layout = useLayout() const local = useLocal() - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() + const data = useData() const providers = useProviders(() => sdk().directory) const view = layout.view(input.sessionKey) - const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) return createMemo(() => { return { agents: { - available: sync().data.agent, + available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []), options: local.agent.list().map((agent) => agent.name), current: local.agent.current()?.name ?? "", - loading: agentsQuery.isLoading, + loading: data.location.agent.list({ directory: sdk().directory }) === undefined, visible: local.agent.visible(), select: local.agent.set, }, model: { selection: input.model ?? local.model, paid: providers.paid().length > 0, - loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(), + loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(), }, session: { id: input.sessionID(), @@ -58,7 +54,7 @@ export function createPromptInputController(input: { export function createPromptProjectControls(props: { draftId: string }) { const server = useServers() const serverSDK = useServerSDK() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const tabs = useTabs() const global = useGlobal() const pickDirectory = useDirectoryPicker() diff --git a/packages/app/src/pages/session/composer/session-composer-pullout.tsx b/packages/app/src/pages/session/composer/session-composer-pullout.tsx index 6d3bbaf441..962ed013d4 100644 --- a/packages/app/src/pages/session/composer/session-composer-pullout.tsx +++ b/packages/app/src/pages/session/composer/session-composer-pullout.tsx @@ -6,7 +6,7 @@ import { createEffect, createMemo, type JSX } from "solid-js" import { createStore } from "solid-js/store" export function SessionComposerPullout(props: { - name: "todo" | "background" + name: "background" label: JSX.Element ariaLabel: string preview?: string diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 4073f1c191..1107313eab 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -1,9 +1,6 @@ -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { type Accessor, createEffect, createMemo, createResource } from "solid-js" import type { PromptInputState } from "@/components/prompt-input" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" import type { SessionComposerController } from "./session-composer-state" @@ -28,10 +25,6 @@ export function createSessionComposerRegionController(input: { prompt: PromptInputState ready: Accessor centered: Accessor - todo: { - collapsed: Accessor - onToggle: () => void - } followup: Accessor revert: Accessor onResponseSubmit: () => void @@ -39,43 +32,7 @@ export function createSessionComposerRegionController(input: { setPromptRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void }) { - const sync = useSync() - const [store, setStore] = createStore({ - ready: input.ready() || input.state.dock(), - height: 320, - body: undefined as HTMLDivElement | undefined, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) window.clearTimeout(timer) - if (frame !== undefined) cancelAnimationFrame(frame) - timer = undefined - frame = undefined - } - - createEffect(() => { - input.sessionKey() - const ready = input.ready() - const dock = input.state.dock() - - clear() - if (store.ready || (!ready && !dock)) return - if (dock) { - setStore("ready", true) - return - } - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setStore("ready", true) - timer = undefined - }, 140) - }) - }) - + const data = useData() createEffect(() => { if (!input.prompt.ready()) return setSessionHandoff(input.sessionKey(), { @@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: { }) }) - createEffect(() => { - const el = store.body - if (!el) return - const update = () => setStore("height", el.getBoundingClientRect().height) - createResizeObserver(el, update) - update() - }) - - onCleanup(clear) - const parentID = createMemo(() => { const id = input.sessionID() - return id ? sync().session.get(id)?.parentID : undefined + return id ? data.session.get(id)?.parentID : undefined }) - const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing()) - const progress = useSpring( - () => (open() ? 1 : 0), - { visualDuration: 0.3, bounce: 0 }, - () => `${input.sessionKey()}\0${store.ready}`, - ) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) const ready = Promise.resolve() const [promptReady] = createResource( () => input.prompt.ready.promise ?? ready, @@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: { return { state: input.state, centered: input.centered, - todo: input.todo, followup: input.followup, revert: input.revert, onResponseSubmit: input.onResponseSubmit, @@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: { showComposer: () => !input.state.blocked() || !!parentID(), handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, promptReady: () => input.prompt.ready() || promptReady(), - dock: () => (store.ready && input.state.dock()) || value() > 0.001, - dockProgress: value, - dockHeight: () => Math.max(78, store.height), - lift: () => (input.revert()?.items.length ? 18 : 36 * value()), - setDockBodyRef: (el: HTMLDivElement) => setStore("body", el), + lift: () => (input.revert()?.items.length ? 18 : 0), } } diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a83bc5adf8..9d3369e490 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -4,7 +4,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" -import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" @@ -57,28 +56,6 @@ export function SessionComposerRegion(props: { - -
-
- -
-
-
{controller.handoffPrompt() || language.t("prompt.loading")}
@@ -106,11 +82,7 @@ export function SessionComposerRegion(props: { > {(revert) => ( -
+
@@ -115,35 +114,3 @@ describe("sessionQuestionForm", () => { expect(sessionQuestionForm(sessions, forms, "root")).toBeUndefined() }) }) - -describe("todoState", () => { - test("hides when there are no todos", () => { - expect(todoState({ count: 0, done: false, live: true })).toBe("hide") - }) - - test("opens while the session is still working", () => { - expect(todoState({ count: 2, done: false, live: true })).toBe("open") - }) - - test("closes completed todos after a running turn", () => { - expect(todoState({ count: 2, done: true, live: true })).toBe("close") - }) - - test("clears stale todos when the turn ends", () => { - expect(todoState({ count: 2, done: false, live: false })).toBe("clear") - }) - - test("clears completed todos when the session is no longer live", () => { - expect(todoState({ count: 2, done: true, live: false })).toBe("clear") - }) -}) - -describe("todoDockAtBoundary", () => { - test("shows active todos when entering a session", () => { - expect(todoDockAtBoundary("open")).toBe(true) - }) - - test("hides completed todos when entering a session", () => { - expect(todoDockAtBoundary("close")).toBe(false) - }) -}) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 9baa69eddb..03c8bf59c6 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -1,69 +1,40 @@ -import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" -import type { Todo } from "@/types" import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" -import { useServerSync } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree" -import { createQuery, useQueryClient } from "@tanstack/solid-query" - -export const todoState = (input: { - count: number - done: boolean - live: boolean -}): "hide" | "clear" | "open" | "close" => { - if (input.count === 0) return "hide" - if (!input.live) return "clear" - if (!input.done) return "open" - return "close" -} - -export const todoDockAtBoundary = (state: ReturnType) => state === "open" +import { useData } from "@/context/server" const idle = { type: "idle" as const } -export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) { +export function createSessionComposerController() { const params = useParams() - const sdk = useSDK() - const sync = useSync() - const serverSync = useServerSync() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() - const queryClient = useQueryClient() + const data = useData() const language = useLanguage() const permission = usePermission() - const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const - const shells = createQuery(() => ({ - queryKey: shellKey(), - enabled: !!params.id && serverSDK.connection.status() === "connected", - queryFn: () => - sdk() - .api.shell.list({ location: { directory: sdk().directory } }) - .then((result) => result.data ?? []), - })) - onCleanup( - sdk().event.listen((event) => { - if ( - event.details.type !== "shell.created" && - event.details.type !== "shell.exited" && - event.details.type !== "shell.deleted" - ) - return - void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true }) - }), - ) + createEffect(() => { + const id = params.id + if (!id || serverSDK.connection.status() !== "connected") return + void Promise.all([ + data.shell.sync({ directory: sdk().directory }), + data.session.permission.sync(id), + data.session.form.sync(id), + ]).catch(() => undefined) + }) const questionRequest = createMemo((): FormInfo | undefined => { - return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id) + return sessionQuestionForm(data.session.list(), data.session.form.list, params.id) }) const permissionRequest = createMemo((): PermissionRequest | undefined => { - return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => { + return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => { return !permission.autoResponds(item, sdk().directory) }) }) @@ -74,26 +45,15 @@ export function createSessionComposerController(options?: { closeMs?: number | ( return !!permissionRequest() || !!questionRequest() }) - const todos = createMemo((): Todo[] => { - const id = params.id - if (!id) return [] - return serverSync.session.data.todo[id] ?? [] - }) - - const done = createMemo( - () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), - ) - - const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) const primary = () => { const id = params.id - return !!id && !serverSync.session.get(id)?.parentID + return !!id && !data.session.get(id)?.parentID } const backgroundBlocking = createMemo(() => { if (!primary()) return [] const id = params.id if (!id) return [] - const assistant = (serverSync.session.data.session_message[id] ?? []).findLast( + const assistant = data.session.message.list(id).findLast( (message) => message.type === "assistant" && message.time.completed === undefined, ) if (assistant?.type !== "assistant") return [] @@ -116,7 +76,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const id = params.id if (!id) return [] const blocking = backgroundBlocking() - const messages = serverSync.session.data.session_message[id] ?? [] + const messages = data.session.message.list(id) const completed = new Set( messages.flatMap((message) => { if (message.type !== "synthetic") return [] @@ -144,9 +104,9 @@ export function createSessionComposerController(options?: { closeMs?: number | ( ] }) }) - const active = Object.values(serverSync.session.data.info).flatMap((info) => { + const active = data.session.list().flatMap((info) => { if (info?.parentID !== id) return [] - if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return [] + if (data.session.status(info.id) === "idle") return [] if ( blocking.some( (item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)), @@ -171,7 +131,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( ] }) }) - const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => { + const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => { if (shell.status !== "running" || shell.metadata.sessionID !== id) return [] if ( blocking.some( @@ -189,8 +149,8 @@ export function createSessionComposerController(options?: { closeMs?: number | ( if (!primary()) return const sessionID = params.id if (!sessionID) return - await sdk() - .api.session.background({ sessionID }) + await serverSDK.api.session + .background({ sessionID }) .catch((error) => { showToast({ title: language.t("common.requestFailed"), @@ -200,11 +160,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( } const [store, setStore] = createStore({ - sessionID: params.id, responding: undefined as string | undefined, - dock: todos().length > 0 && !done() && live(), - closing: false, - opening: false, }) const permissionResponding = createMemo(() => { @@ -219,8 +175,8 @@ export function createSessionComposerController(options?: { closeMs?: number | ( if (store.responding === perm.id) return setStore("responding", perm.id) - sdk() - .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) + serverSDK.api.permission + .reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) @@ -230,99 +186,6 @@ export function createSessionComposerController(options?: { closeMs?: number | ( }) } - let timer: number | undefined - let raf: number | undefined - - const closeMs = () => { - const value = options?.closeMs - if (typeof value === "function") return Math.max(0, value()) - if (typeof value === "number") return Math.max(0, value) - return 400 - } - - const scheduleClose = () => { - if (timer) window.clearTimeout(timer) - timer = window.setTimeout(() => { - setStore({ dock: false, closing: false }) - timer = undefined - }, closeMs()) - } - - // Keep stale turn todos from reopening if the model never clears them. - const clear = () => { - const id = params.id - if (!id) return - sync().set("todo", id, []) - } - - createEffect( - on( - () => [params.id, todos().length, done(), live()] as const, - ([id, count, complete, active], previous) => { - if (raf) cancelAnimationFrame(raf) - raf = undefined - - const next = todoState({ - count, - done: complete, - live: active, - }) - - if (!previous || previous[0] !== id) { - if (timer) window.clearTimeout(timer) - timer = undefined - setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false }) - if (next === "clear") clear() - return - } - - if (next === "hide") { - if (timer) window.clearTimeout(timer) - timer = undefined - setStore({ dock: false, closing: false, opening: false }) - return - } - - if (next === "clear") { - if (timer) window.clearTimeout(timer) - timer = undefined - clear() - return - } - - if (next === "open") { - if (timer) window.clearTimeout(timer) - timer = undefined - const hidden = !store.dock || store.closing - setStore({ dock: true, closing: false }) - if (hidden) { - setStore("opening", true) - raf = requestAnimationFrame(() => { - setStore("opening", false) - raf = undefined - }) - return - } - setStore("opening", false) - return - } - - setStore({ dock: true, opening: false, closing: true }) - if (!timer) scheduleClose() - }, - ), - ) - - onCleanup(() => { - if (!timer) return - window.clearTimeout(timer) - }) - - onCleanup(() => { - if (!raf) return - cancelAnimationFrame(raf) - }) - return { blocked, questionRequest, @@ -334,13 +197,6 @@ export function createSessionComposerController(options?: { closeMs?: number | ( move: moveToBackground, }, decide, - todos, - dock: () => - store.sessionID === params.id - ? store.dock - : todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })), - closing: () => store.sessionID === params.id && store.closing, - opening: () => store.sessionID === params.id && store.opening, } } diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 3b0dd8f500..90a09db832 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -8,7 +8,6 @@ import { useSpring } from "@opencode-ai/ui/motion-spring" import { showToast } from "@/utils/toast" import type { FormAnswer, FormInfo, FormMultiselectField, FormStringField } from "@opencode-ai/client/promise" import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" @@ -68,7 +67,6 @@ function Option(props: { } export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => void }> = (props) => { - const sdk = useSDK() const serverSDK = useServerSDK() const language = useLanguage() const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id) @@ -238,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => const replyMutation = useMutation(() => ({ mutationFn: (answer: FormAnswer) => - sdk().api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }), + serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }), onMutate: () => { props.onSubmit() }, @@ -250,7 +248,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }), + mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/composer/session-request-tree.ts b/packages/app/src/pages/session/composer/session-request-tree.ts index cf42ca2577..d37211bff8 100644 --- a/packages/app/src/pages/session/composer/session-request-tree.ts +++ b/packages/app/src/pages/session/composer/session-request-tree.ts @@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie function sessionTreeRequest( session: SessionInfo[], - request: Record, + request: Record | ((sessionID: string) => T[] | undefined), sessionID?: string, include: (item: T) => boolean = () => true, ) { @@ -28,14 +28,15 @@ function sessionTreeRequest( } } - const id = ids.find((id) => request[id]?.some(include)) + const list = (id: string) => (typeof request === "function" ? request(id) : request[id]) + const id = ids.find((id) => list(id)?.some(include)) if (!id) return - return request[id]?.find(include) + return list(id)?.find(include) } export function sessionPermissionRequest( session: SessionInfo[], - request: Record, + request: Record | ((sessionID: string) => PermissionRequest[] | undefined), sessionID?: string, include?: (item: PermissionRequest) => boolean, ) { @@ -44,7 +45,7 @@ export function sessionPermissionRequest( export function sessionQuestionForm( session: SessionInfo[], - request: Record, + request: Record | ((sessionID: string) => FormInfo[] | undefined), sessionID?: string, ) { return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question") diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx deleted file mode 100644 index dfa2e1db98..0000000000 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import type { Todo } from "@/types" -import { AnimatedNumber } from "@opencode-ai/ui/animated-number" -import { Checkbox } from "@opencode-ai/ui/checkbox" -import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" -import { Index, Match, Switch, createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/context/language" -import { SessionComposerPullout } from "./session-composer-pullout" - -const doneToken = "\u0000done\u0000" -const totalToken = "\u0000total\u0000" - -function dot(status: Todo["status"]) { - if (status !== "in_progress") return undefined - return ( - - - - ) -} - -export function SessionTodoDock(props: { - todos: Todo[] - collapsed: boolean - onToggle: () => void - collapseLabel: string - expandLabel: string - dockProgress: number -}) { - const language = useLanguage() - - const total = createMemo(() => props.todos.length) - const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) - const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) - const progress = createMemo(() => - language - .t("session.todo.progress", { done: doneToken, total: totalToken }) - .split(/(\u0000done\u0000|\u0000total\u0000)/), - ) - - const active = createMemo( - () => - props.todos.find((todo) => todo.status === "in_progress") ?? - props.todos.find((todo) => todo.status === "pending") ?? - props.todos.filter((todo) => todo.status === "completed").at(-1) ?? - props.todos[0], - ) - - const preview = createMemo(() => active()?.content ?? "") - return ( - - {(item) => ( - {item()}}> - - - - - - - - )} - - } - ariaLabel={label()} - preview={props.collapsed ? preview() : undefined} - collapsed={props.collapsed} - onToggle={props.onToggle} - collapseLabel={props.collapseLabel} - expandLabel={props.expandLabel} - dockProgress={props.dockProgress} - > - - - ) -} - -function TodoList(props: { todos: Todo[] }) { - const [store, setStore] = createStore({ - stuck: false, - }) - - return ( -
-
{ - setStore("stuck", e.currentTarget.scrollTop > 0) - }} - > - - {(todo) => ( - - - - )} - -
-
-
- ) -} diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx deleted file mode 100644 index 009c20a895..0000000000 --- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx +++ /dev/null @@ -1,624 +0,0 @@ -// @ts-nocheck -import { createEffect, createMemo, For, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import type { Todo } from "@/types" -import { useServerSync } from "@/context/global-sync" -import { PromptInput } from "@/components/prompt-input" -import { usePrompt } from "@/context/prompt" -import { - SessionComposerRegion, - createSessionComposerController, - createSessionComposerRegionController, -} from "@/pages/session/composer" - -export default { - title: "UI/Todo Panel Motion", - id: "components-todo-panel-motion", - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: `### Overview -This playground renders the real session composer region from app code. - -### Source path -- \`packages/app/src/pages/session/composer/session-composer-region.tsx\` - -### Includes -- \`SessionTodoDock\` (real) -- \`PromptInput\` (real) - -No visual reimplementation layer is used for the dock/input stack.`, - }, - }, - }, -} - -const pool = [ - "Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)", - "Remove inline measure nodes/CSS hooks and keep width morph behavior intact", - "Run typechecks/tests and report what changed", - "Verify reduced-motion behavior in timeline", - "Review diff for animation edge cases", - "Document rollout notes in PR description", - "Check keyboard and screen reader semantics", - "Add storybook controls for iteration speed", -] - -const btn = (accent?: boolean) => - ({ - padding: "6px 14px", - "border-radius": "6px", - border: "1px solid var(--color-divider, #333)", - background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", - color: "var(--color-text, #eee)", - cursor: "pointer", - "font-size": "13px", - }) as const - -const controls = { - agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} }, - model: { - selection: { - current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), - variant: { list: () => [], current: () => undefined, set: () => {} }, - }, - paid: true, - loading: false, - }, - session: { - id: "story-session", - tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} }, - reviewPanel: { opened: () => false, open: () => {} }, - }, -} - -const css = ` -[data-component="todo-stage"] { - display: grid; - gap: 20px; - padding: 20px; -} - -[data-component="todo-preview"] { - height: 560px; - min-height: 0; -} - -[data-component="todo-session-root"] { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - display: flex; - flex-direction: column; - background: var(--background-base); - border: 1px solid var(--border-weak-base); - border-radius: 12px; -} - -[data-component="todo-session-frame"] { - flex: 1 1 auto; - min-height: 0; - display: flex; - flex-direction: column; -} - -[data-component="todo-session-panel"] { - position: relative; - flex: 1 1 auto; - min-height: 0; - height: 100%; - display: flex; - flex-direction: column; - background: var(--background-stronger); -} - -[data-slot="todo-preview-content"] { - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -[data-slot="todo-preview-scroll"] { - height: 100%; - overflow: auto; - min-height: 0; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; -} - -[data-slot="todo-preview-spacer"] { - flex: 1 1 auto; - min-height: 0; -} - -[data-slot="todo-preview-msg"] { - border-radius: 8px; - border: 1px solid var(--border-weak-base); - background: var(--surface-base); - color: var(--text-weak); - padding: 8px 10px; - font-size: 13px; - line-height: 1.35; -} - -[data-slot="todo-preview-msg"][data-strong="true"] { - color: var(--text-strong); -} -` - -export const Playground = { - render: () => { - const global = useServerSync() - const prompt = usePrompt() - const [cfg, setCfg] = createStore({ - open: true, - collapsed: false, - step: 1, - dockOpenDuration: 0.3, - dockOpenBounce: 0, - dockCloseDuration: 0.3, - dockCloseBounce: 0, - drawerExpandDuration: 0.3, - drawerExpandBounce: 0, - drawerCollapseDuration: 0.3, - drawerCollapseBounce: 0, - subtitleDuration: 600, - subtitleAuto: true, - subtitleTravel: 25, - subtitleEdge: 17, - countDuration: 600, - countMask: 18, - countMaskHeight: 0, - countWidthDuration: 560, - }) - const open = () => cfg.open - const step = () => cfg.step - const dockOpenDuration = () => cfg.dockOpenDuration - const dockOpenBounce = () => cfg.dockOpenBounce - const dockCloseDuration = () => cfg.dockCloseDuration - const dockCloseBounce = () => cfg.dockCloseBounce - const drawerExpandDuration = () => cfg.drawerExpandDuration - const drawerExpandBounce = () => cfg.drawerExpandBounce - const drawerCollapseDuration = () => cfg.drawerCollapseDuration - const drawerCollapseBounce = () => cfg.drawerCollapseBounce - const subtitleDuration = () => cfg.subtitleDuration - const subtitleAuto = () => cfg.subtitleAuto - const subtitleTravel = () => cfg.subtitleTravel - const subtitleEdge = () => cfg.subtitleEdge - const countDuration = () => cfg.countDuration - const countMask = () => cfg.countMask - const countMaskHeight = () => cfg.countMaskHeight - const countWidthDuration = () => cfg.countWidthDuration - const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) }) - let frame - let scrollRef - - const todos = createMemo(() => { - const done = Math.max(0, Math.min(3, step())) - return pool.slice(0, 3).map((content, i) => ({ - id: `todo-${i + 1}`, - content, - status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending", - })) - }) - - createEffect(() => { - global.todo.set("story-session", todos()) - }) - - const clear = () => { - if (frame) cancelAnimationFrame(frame) - frame = undefined - } - - const pin = () => { - if (!scrollRef) return - scrollRef.scrollTop = scrollRef.scrollHeight - } - - const collapsed = () => cfg.collapsed - const setCollapsed = (value: boolean) => setCfg("collapsed", value) - const openDock = () => { - clear() - setCfg("open", true) - frame = requestAnimationFrame(() => { - pin() - frame = undefined - }) - } - - const closeDock = () => { - clear() - setCfg("open", false) - } - - const dockOpen = () => open() - - const toggleDock = () => { - if (dockOpen()) { - closeDock() - return - } - openDock() - } - - const toggleDrawer = () => { - if (!dockOpen()) { - openDock() - frame = requestAnimationFrame(() => { - pin() - setCollapsed(true) - frame = undefined - }) - return - } - setCollapsed(!collapsed()) - } - - const cycle = () => { - setCfg("step", (value) => (value + 1) % 4) - } - - onCleanup(clear) - - return ( -
- - -
-
-
-
-
-
-
-
- Thinking Checking type safety -
-
Shell Prints five topic blocks between timed commands
-
-
- -
- "story-session", - sessionID: () => "story-session", - prompt, - ready: () => true, - centered: () => false, - todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) }, - followup: () => undefined, - revert: () => undefined, - onResponseSubmit: pin, - openParent: () => {}, - setPromptRef: () => {}, - setDockRef: () => {}, - })} - promptInput={ - {}, handleSubmit: (event) => event.preventDefault() }} - ref={() => {}} - newSessionWorktree="" - onNewSessionWorktreeReset={() => {}} - /> - } - /> -
-
-
-
-
- -
- - - - - {(value) => ( - - )} - -
- -
-
Dock open
- - - -
- Dock close -
- - - -
- Drawer expand -
- - - -
- Drawer collapse -
- - - -
- Subtitle odometer -
- - - - - -
- Count odometer -
- - - - -
-
- ) - }, -} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index e4d6f9a0f1..fd573c040f 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -8,7 +8,8 @@ import type { SessionReviewCommentUpdate, } from "@opencode-ai/session-ui/session-review" import type { SelectedLineRange } from "@/context/file" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" +import { useServerSDK } from "@/context/server-sdk" import { useLayout } from "@/context/layout" import type { LineComment } from "@/context/comments" @@ -49,12 +50,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) { let userInteracted = false let restored: { x: number; y: number } | undefined - const sdk = useSDK() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const layout = useLayout() const readFile = async (path: string) => { - return sdk() - .api.file.read({ path, location: { directory: sdk().directory } }) + return serverSDK.api.file + .read({ path, location: { directory: sdk().directory } }) .then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) })) .catch((error) => { console.debug("[session-review] failed to read file", { path, error }) diff --git a/packages/app/src/pages/session/session-controller.ts b/packages/app/src/pages/session/session-controller.ts index 3c2ab4e785..949902d981 100644 --- a/packages/app/src/pages/session/session-controller.ts +++ b/packages/app/src/pages/session/session-controller.ts @@ -1,8 +1,9 @@ import type { Message, UserMessage } from "@/types" import { createMemo, type Accessor } from "solid-js" import { useFile } from "@/context/file" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" import { same } from "@/utils/same" +import { normalizeSessionMessages } from "@/utils/session-message" import { createSessionTabs } from "./helpers" import { normalizeSessionTab, @@ -23,26 +24,28 @@ export function createSessionController(input: { fileBrowser?: (sessionID: string | undefined) => boolean }) { const file = useFile() - const sync = useSync() + const data = useData() const layout = useSessionLayout() const sessionID = createMemo(() => layout.params.id) const info = createMemo(() => { const id = sessionID() - return id ? sync().session.get(id) : undefined + return id ? data.session.get(id) : undefined }) const parentID = createMemo(() => info()?.parentID) const parent = createMemo(() => { const id = parentID() - return id ? sync().session.get(id) : undefined + return id ? data.session.get(id) : undefined }) const status = createMemo(() => { const id = sessionID() - return id ? (sync().data.session_status[id] ?? idle) : idle + return id && data.session.status(id) === "running" ? { type: "busy" as const } : idle }) - const messages = createMemo(() => { + const transcript = createMemo(() => { const id = sessionID() - return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages + return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined }) + const messages = createMemo(() => transcript()?.messages ?? emptyMessages) + const parts = (messageID: string) => transcript()?.parts.get(messageID) ?? [] const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same }) const revertMessageID = createMemo(() => info()?.revert?.messageID) const visibleUserMessages = createMemo( @@ -75,12 +78,13 @@ export function createSessionController(input: { status, working: createMemo(() => { const id = sessionID() - return id ? sync().data.session_working(id) : false + return id ? data.session.status(id) === "running" : false }), revertMessageID, }, history: { messages, + parts, userMessages, visibleUserMessages, lastUserMessage: createMemo(() => visibleUserMessages().at(-1)), diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts index 199635735d..a8716eae45 100644 --- a/packages/app/src/pages/session/session-layout.ts +++ b/packages/app/src/pages/session/session-layout.ts @@ -2,13 +2,13 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" import { base64Encode } from "@opencode-ai/core/util/encode" export const useSessionKey = () => { const params = useParams() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const serverSDK = useServerSDK() const scope = createMemo(() => serverSDK.scope) const directory = createMemo(() => base64Encode(sdk().directory)) diff --git a/packages/app/src/pages/session/session-lineage.ts b/packages/app/src/pages/session/session-resolution.ts similarity index 70% rename from packages/app/src/pages/session/session-lineage.ts rename to packages/app/src/pages/session/session-resolution.ts index d86c7b657f..9a2e5098b0 100644 --- a/packages/app/src/pages/session/session-lineage.ts +++ b/packages/app/src/pages/session/session-resolution.ts @@ -1,15 +1,18 @@ import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { sessionNotFoundError } from "@/utils/server-errors" -type LineageStore = { peek: (id: string) => T | undefined; resolve: (id: string) => Promise } +type SessionStore = { + get: (id: string) => T | undefined + sync: (id: string, options?: { children?: boolean }) => Promise +} -type Resolution = { id: string; store: LineageStore } & ( +type Resolution = { id: string; store: SessionStore } & ( | { state: "pending" } | { state: "settled" } | { state: "failed"; failure: unknown } ) -// Reactive session lineage for the target session route, read from the sync store. +// Reactive target session for the session route, read from the data store. // All session tabs on a server share one route instance, so the target session ID // changes in place; the effect is only a trigger that starts resolution for the // current target, and each run cancels the previous one through onCleanup so a @@ -25,23 +28,27 @@ type Resolution = { id: string; store: LineageStore } & ( // so trusting a previous target's settlement would fabricate a not-found for a // session that simply has not resolved yet. Resolve failures rethrow on read so // the enclosing SessionRouteErrorBoundary renders the scoped session error. -export function createSessionLineage(sessionID: () => string, lineage: () => LineageStore) { - const cached = createMemo(() => lineage().peek(sessionID())) +export function createSessionResolution( + sessionID: () => string, + sessions: () => SessionStore, + options?: { children?: boolean }, +) { + const cached = createMemo(() => sessions().get(sessionID())) const [status, setStatus] = createSignal>() createEffect( - on([sessionID, lineage] as const, ([id, store]) => { + on([sessionID, sessions] as const, ([id, store]) => { let stale = false onCleanup(() => { stale = true }) - if (cached()) { + if (cached() && !options?.children) { setStatus({ id, store, state: "settled" }) return } setStatus({ id, store, state: "pending" }) store - .resolve(id) + .sync(id, options) .then(() => { if (!stale) setStatus({ id, store, state: "settled" }) }) @@ -56,12 +63,10 @@ export function createSessionLineage(sessionID: () => string, lineage: () => const value = cached() if (value) return value const state = status() - if (state?.id !== id || state.store !== lineage()) return undefined + if (state?.id !== id || state.store !== sessions()) return undefined if (state.state === "failed") throw state.failure - // The viewed session is pinned (DirectoryDataProvider, directory-layout.tsx) - // and pinned lineages are exempt from cache pruning, so a lineage missing - // after settlement means the session (or an ancestor) was deleted, possibly - // by another client. Match the resolve error so the boundary shows the + // A session missing after settlement was deleted, possibly by another client. + // Match the resolve error so the boundary shows the // session not found fallback. if (state.state === "settled") throw sessionNotFoundError(id) return undefined diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 6069649281..9b2f96ffcf 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -28,7 +28,7 @@ import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { @@ -71,7 +71,7 @@ export function SessionSidePanel(props: { const file = useFile() const language = useLanguage() const command = useCommand() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const { sessionKey, tabs, view, params } = useSessionLayout() const projectDirectory = createMemo(() => sdk().directory) diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx index a841179a7d..c6fad6eec9 100644 --- a/packages/app/src/pages/session/terminal-panel-v2.tsx +++ b/packages/app/src/pages/session/terminal-panel-v2.tsx @@ -19,7 +19,7 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useTerminal } from "@/context/terminal" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" @@ -28,7 +28,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" export function TerminalPanelV2(props: { stacked?: boolean } = {}) { const layout = useLayout() const terminal = useTerminal() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const language = useLanguage() const command = useCommand() const { workspaceKey, view } = useSessionLayout() diff --git a/packages/app/src/pages/session/timeline/controller.tsx b/packages/app/src/pages/session/timeline/controller.tsx index 242df2d313..6b60e9e296 100644 --- a/packages/app/src/pages/session/timeline/controller.tsx +++ b/packages/app/src/pages/session/timeline/controller.tsx @@ -3,16 +3,16 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, on, type Accessor } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useWorkspaceLocation } from "@/context/location" import { useTabs } from "@/context/tabs" import type { SessionController } from "@/pages/session/session-controller" +import { useServerSDK } from "@/context/server-sdk" import { sessionHref } from "@/utils/session-route" import { sessionTitle } from "@/utils/session-title" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" @@ -20,9 +20,9 @@ import { showToast } from "@/utils/toast" import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection" import { createTimelineProjection } from "./projection" import { useServer } from "@/context/server" +import { normalizeSessionMessages } from "@/utils/session-message" const emptyMessages: Message[] = [] -const emptyParts: Part[] = [] const taskDescription = (part: Part, sessionID: string): string | undefined => { if (part.type !== "tool" || part.tool !== "task") return undefined const metadata = "metadata" in part.state ? part.state.metadata : undefined @@ -35,7 +35,7 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => { export type TimelineSessionSource = { identity: Pick data: Pick - history: Pick + history: Pick } export function createTimelineController(input: { @@ -43,9 +43,10 @@ export function createTimelineController(input: { userMessages: Accessor }) { const navigate = useNavigate() - const sdk = useSDK() - const sync = useSync() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const server = useServer() + const data = server.ctx.data const settings = useSettings() const tabs = useTabs() const dialog = useDialog() @@ -58,29 +59,30 @@ export function createTimelineController(input: { const boundary = input.session.history .messages() .find((message) => message.role === "user" && !visible.has(message.id))?.id - const projected = sync().data.session_message[id] ?? [] + const projected = data.session.message.list(id) if (!boundary) return projected const index = projected.findIndex((message) => message.id === boundary) return index < 0 ? projected : projected.slice(0, index) }) const titleValue = createMemo(() => input.session.data.info()?.title) - const titleLabel = createMemo(() => sessionTitle(titleValue())) + const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new")) const shareUrl = (): string | undefined => undefined const shareEnabled = () => false - const parentMessages = createMemo(() => { + const parentTranscript = createMemo(() => { const id = input.session.data.parentID() - return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages + return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined }) + const parentMessages = createMemo(() => parentTranscript()?.messages ?? emptyMessages) const parentTitle = createMemo( () => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"), ) - const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts + const parts = input.session.history.parts const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID) const childTaskDescription = createMemo(() => { const id = input.session.identity.sessionID() if (!id) return undefined return parentMessages() - .flatMap((message) => parts(message.id)) + .flatMap((message) => parentTranscript()?.parts.get(message.id) ?? []) .map((item) => taskDescription(item, id)) .findLast((value): value is string => !!value) }) @@ -117,8 +119,8 @@ export function createTimelineController(input: { const next = title.trim() if (!next || next === (titleLabel() ?? "")) return true setPending("rename", true) - const success = await sdk() - .api.session.rename({ sessionID: id, title: next }) + const success = await serverSDK.api.session + .rename({ sessionID: id, title: next }) .then(() => true) .catch((error) => { showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }) @@ -126,12 +128,8 @@ export function createTimelineController(input: { }) setPending("rename", false) if (!success) return false - sync().set( - produce((draft) => { - const index = draft.session.findIndex((session) => session.id === id) - if (index !== -1) draft.session[index].title = next - }), - ) + const current = data.session.get(id) + if (current) data.session.remember({ ...current, title: next }) return true } const share = async () => { @@ -151,7 +149,7 @@ export function createTimelineController(input: { } const exportSession = async (id: string) => { try { - const data = await fetchSessionExport({ sessionID: id, api: sdk().api }) + const data = await fetchSessionExport({ sessionID: id, api: serverSDK.api }) const filename = sessionExportFilename(data.info) downloadSessionExport(filename, data) showToast({ @@ -169,30 +167,28 @@ export function createTimelineController(input: { } } const remove = async (id: string) => { - const session = sync().session.get(id) + const session = data.session.get(id) if (!session) return false - const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived) + const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived) const index = sessions.findIndex((item) => item.id === id) const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - const success = await sdk() - .api.session.remove({ sessionID: id }) + const success = await serverSDK.api.session + .remove({ sessionID: id }) .then(() => true) .catch((error) => { showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) }) return false }) if (!success) return false - const removed = timelineRemovedSessionIDs(sync().data.session, id) + const removed = timelineRemovedSessionIDs(data.session.list(), id) void navigateAfterRemoval(id, session.parentID, next?.id) - sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id))))) - removed.forEach((sessionID) => sync().session.evict(sessionID)) notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] }) return true } function DeleteDialog(props: { sessionID: string }) { const name = createMemo( - () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), + () => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"), ) const confirm = async () => { await remove(props.sessionID) @@ -222,8 +218,8 @@ export function createTimelineController(input: { on( () => [input.session.data.parentID(), childTaskDescription()] as const, ([id, description]) => { - if (!id || description || sync().data.message[id] !== undefined) return - void sync().session.sync(id) + if (!id || description || data.session.message.list(id).length > 0) return + void Promise.all([data.session.sync(id), data.session.message.sync(id)]) }, { defer: true }, ), diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 1a538ec12d..c8bb0ef84e 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -45,9 +45,8 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useLanguage } from "@/context/language" -import { useServerSync } from "@/context/server-sync" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" +import { useData } from "@/context/server" +import { useWorkspaceLocation } from "@/context/location" import { scheduleConnectedMeasure } from "./measure" import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" @@ -408,9 +407,8 @@ function MessageTimelineView( ) { let touchGesture: number | undefined const language = useLanguage() - const serverSync = useServerSync() - const sdk = useSDK() - const sync = useSync() + const data = useData() + const sdk = useWorkspaceLocation() const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom) const hasScrollGesture = createMemo(() => props.hasScrollGesture) const ownerSessionKey = props.data.sessionKey() @@ -431,7 +429,20 @@ function MessageTimelineView( const getMsgPart = props.data.part const projection = props.data.projection const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory) - const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory())) + const project = createMemo(() => { + const projectID = props.session.data.info()?.projectID + const value = projectID + ? data.project.get(projectID) + : data.project.list().find((item) => containsDirectory(item.canonical, sessionDirectory())) + if (!value) return + return { ...value, worktree: value.canonical, worktrees: [] } + }) + const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory())) + createEffect(() => { + const directory = project()?.worktree + if (!directory) return + void data.location.vcs.sync({ directory }).catch(() => undefined) + }) const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false) const [summaryOpen, setSummaryOpen] = createSignal(false) const setSummary = (open: boolean) => { @@ -1074,7 +1085,7 @@ function MessageTimelineView( diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id && !workspaceSession() && props.workspaceMoveEligible && - sync().project?.vcs === "git" && + project()?.vcs === "git" && sessionStatus().type === "idle" return ( @@ -1082,7 +1093,7 @@ function MessageTimelineView( + {(project) => ( (
- + {(project) => ( }) { - const serverSync = useServerSync() - const sync = useSync() - let refreshFrame: number | undefined - let refreshTimer: number | undefined + const data = useData() const [resource] = createResource( () => input.session.identity.sessionID(), - (id) => { - clearRefresh() - if (!id) return - - const cached = untrack(() => sync().data.message[id] !== undefined) - const stale = cached && !serverSync.session.fresh(id, sessionFreshness) - - refreshFrame = requestAnimationFrame(() => { - refreshFrame = undefined - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined - if (input.session.identity.sessionID() !== id) return - untrack(() => { - if (stale) void sync().session.sync(id, { force: true }) - }) - }, 0) - }) - - return sync().session.sync(id) - }, + (id) => (id ? data.session.message.sync(id) : undefined), ) - const ready = createMemo(() => { + const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading) + const more = () => { const id = input.session.identity.sessionID() - return !id || isTimelineReady(sync().data.message[id], serverSync.session.history.loading(id)) - }) - const more = createMemo(() => { + return id ? data.session.message.more(id) : false + } + const loading = () => { const id = input.session.identity.sessionID() - return id ? sync().session.history.more(id) : false - }) - const loading = createMemo(() => { - const id = input.session.identity.sessionID() - return id ? sync().session.history.loading(id) : false - }) + return id ? data.session.message.loading(id) : false + } const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => { return loadOlderTimeline({ sessionID: input.session.identity.sessionID, more, loading, - loadMore: (sessionID) => sync().session.history.loadMore(sessionID), + loadMore: data.session.message.loadMore, before: options?.before, after: options?.after, }) } - onCleanup(clearRefresh) - return { history: { loadOlder, loading, more }, lastUserMessage: input.session.history.lastUserMessage, @@ -75,12 +45,6 @@ export function createTimelineModel(input: { session: Pick { const language = useLanguage() const permission = usePermission() const prompt = usePrompt() - const sdk = useSDK() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const settings = useSettings() - const sync = useSync() const terminal = useTerminal() const layout = useLayout() const openDialog = async (load: () => Promise, show: (value: T) => void) => { @@ -60,18 +59,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const value = await load() owner.run(() => show(value)) } - const runCommand = async (input: { - owner: ReturnType - prompt: T - request: () => Promise - updatePrompt: (prompt: T) => void - updateViewport: () => void - }) => { - await input.request() - input.updatePrompt(input.prompt) - input.owner.run(input.updateViewport) - } - const shown = settings.visibility.fileTree const showAllFiles = () => { @@ -99,7 +86,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const navigateMessageByOffset = actions.navigateMessageByOffset - const setActiveMessage = actions.setActiveMessage const focusInput = actions.focusInput const sessionCommand = withCategory(language.t("command.category.session")) @@ -197,7 +183,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { try { const data = await fetchSessionExport({ sessionID, - api: sdk().api, + api: serverSDK.api, }) const filename = sessionExportFilename(data.info) downloadSessionExport(filename, data) @@ -287,75 +273,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) } - const undo = async () => { - const sessionID = actions.session.identity.params.id - if (!sessionID) return - const owner = actions.session.ownership.capture() - const session = sdk().api.session - const directory = sdk().directory - const promptSession = prompt.capture() - const revert = actions.session.data.revertMessageID() - const messages = actions.session.history.userMessages() - const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length - if (boundary < 0) return - const message = messages[boundary - 1] - if (!message) return - const parts = sync().data.part[message.id] - - if (sync().data.session_working(sessionID)) { - await session.interrupt({ sessionID }).catch(() => {}) - } - - await runCommand({ - owner, - prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: message.id }), - updatePrompt: (promptSession) => { - if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) - }, - updateViewport: () => setActiveMessage(messages[boundary - 2]), - }) - } - - const redo = async () => { - const sessionID = actions.session.identity.params.id - if (!sessionID) return - const owner = actions.session.ownership.capture() - const session = sdk().api.session - const messages = actions.session.history.userMessages() - const promptSession = prompt.capture() - - const revertMessageID = actions.session.data.revertMessageID() - if (!revertMessageID) return - - const boundary = messages.findIndex((message) => message.id === revertMessageID) - if (boundary < 0) return - const next = messages[boundary + 1] - if (!next) { - await runCommand({ - owner, - prompt: promptSession, - request: () => session.revert.clear({ sessionID }), - updatePrompt: (promptSession) => promptSession.reset(), - updateViewport: () => setActiveMessage(messages.at(-1)), - }) - return - } - - await runCommand({ - owner, - prompt: promptSession, - request: () => session.revert.stage({ sessionID, messageID: next.id }), - updatePrompt: () => undefined, - updateViewport: () => setActiveMessage(messages[boundary]), - }) - } - const compact = async () => { const sessionID = actions.session.identity.params.id if (!sessionID) return - await sdk().api.session.compact({ sessionID }) + await serverSDK.api.session.compact({ sessionID }) } const fork = () => { @@ -369,7 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const shareCmds = () => { // TODO: Restore these commands when the V2 client exposes session sharing. - // if (sync().data.config.share === "disabled") return [] + // Sharing remains disabled until the current API exposes it. return [] /* return [ @@ -410,16 +332,18 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.undo"), description: language.t("command.session.undo.description"), slash: "undo", - disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0, - onSelect: undo, + // TODO: Restore undo when current transcript parts can reconstruct the prompt draft. + disabled: true, + onSelect: () => undefined, }), sessionCommand({ id: "session.redo", title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", - disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID, - onSelect: redo, + // TODO: Restore redo with the current transcript projection. + disabled: true, + onSelect: () => undefined, }), sessionCommand({ id: "session.compact", diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 3b890c6fd8..209492dde9 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -19,7 +19,8 @@ import type { } from "@opencode-ai/session-ui/session-review" import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" +import { useServerSDK } from "@/context/server-sdk" import { filterRenderableDiff, filterReviewFiles, @@ -54,7 +55,8 @@ export type ReviewPanelV2Props = { } export function ReviewPanelV2(props: ReviewPanelV2Props) { - const sdk = useSDK() + const sdk = useWorkspaceLocation() + const serverSDK = useServerSDK() const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff)) const filteredFiles = createMemo(() => @@ -100,8 +102,8 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { }) const readFile = async (path: string) => - sdk() - .api.file.read({ path, location: { directory: sdk().directory } }) + serverSDK.api.file + .read({ path, location: { directory: sdk().directory } }) .then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) })) .catch((error) => { console.debug("[session-review-v2] failed to read file", { path, error }) diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 18ee78a13f..5e528d9c8c 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -7,7 +7,7 @@ import FileTreeV2, { type Kind } from "@/components/file-tree-v2" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useSDK } from "@/context/sdk" +import { useWorkspaceLocation } from "@/context/location" import { useServerSDK } from "@/context/server-sdk" import { displayName } from "@/pages/layout/helpers" import { useSessionLayout } from "@/pages/session/session-layout" @@ -39,7 +39,7 @@ export function SessionFileBrowserTab(props: { const file = useFile() const language = useLanguage() const layout = useLayout() - const sdk = useSDK() + const sdk = useWorkspaceLocation() const server = useServer() const serverSDK = useServerSDK() const { workspaceKey } = useSessionLayout() diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts index b34ae609ae..6186f00534 100644 --- a/packages/app/src/utils/server-errors.ts +++ b/packages/app/src/utils/server-errors.ts @@ -43,9 +43,8 @@ function unwrapNamedError(error: unknown): unknown { } // Client-synthesized session not-found errors share one constructor and -// predicate so the message contract cannot drift between the sync store -// (server-session.ts), the route lineage (session-lineage.ts), and the -// not-found fallback matching (session.tsx). +// predicate so the message contract cannot drift between route session +// resolution (session-resolution.ts) and not-found fallback matching (session.tsx). const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}` export function sessionNotFoundError(sessionID: string) { diff --git a/packages/app/test-browser/session-lineage.test.ts b/packages/app/test-browser/session-resolution.test.ts similarity index 71% rename from packages/app/test-browser/session-lineage.test.ts rename to packages/app/test-browser/session-resolution.test.ts index 2c2154d190..30d4601e91 100644 --- a/packages/app/test-browser/session-lineage.test.ts +++ b/packages/app/test-browser/session-resolution.test.ts @@ -1,25 +1,25 @@ import { expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { createSessionLineage } from "@/pages/session/session-lineage" +import { createSessionResolution } from "@/pages/session/session-resolution" -type Lineage = { session: { id: string; directory: string } } +type Session = { id: string; directory: string } -const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } }) +const sessionOf = (id: string): Session => ({ id, directory: `/dir/${id}` }) -// Fake sync lineage store: peek reads a reactive cache, resolve returns a -// deferred promise the test settles or fails explicitly. The lineage memo is +// Fake session store: get reads a reactive cache, sync returns a +// deferred promise the test settles or fails explicitly. The session memo is // live (read below), so it recomputes eagerly on cache/status writes — throws // surface at the write site, which is also where the enclosing ErrorBoundary // would see them in the app. Assertions wrap write + read to cover both. -function createFixture(initial: Record = {}) { +function createFixture(initial: Record = {}) { const [cache, setCache] = createSignal(initial) const deferred = new Map>() const resolves: string[] = [] return { resolves, - lineage: { - peek: (id: string) => cache()[id], - resolve: (id: string) => { + sessions: { + get: (id: string) => cache()[id], + sync: (id: string) => { resolves.push(id) const entry = deferred.get(id) ?? Promise.withResolvers() deferred.set(id, entry) @@ -27,13 +27,13 @@ function createFixture(initial: Record = {}) { }, }, settle(id: string) { - setCache({ ...cache(), [id]: lineageOf(id) }) + setCache({ ...cache(), [id]: sessionOf(id) }) deferred.get(id)?.resolve(undefined) }, fail(id: string, error: unknown) { deferred.get(id)?.reject(error) // The real store does not cache failures: the inflight request entry is - // dropped on rejection so the next resolve retries (server-session.ts). + // dropped on rejection so the next resolve retries. deferred.delete(id) }, remove(id: string) { @@ -45,18 +45,18 @@ function createFixture(initial: Record = {}) { } // Two microtask ticks: one for the resolve promise handed back by the fixture, -// one for the .then/.catch chain inside createSessionLineage. +// one for the .then/.catch chain inside createSessionResolution. const flush = async () => { await Promise.resolve() await Promise.resolve() } -test("resolves an uncached session and exposes its lineage", async () => { +test("resolves an uncached session", async () => { await createRoot(async (dispose) => { const fixture = createFixture() - const current = createSessionLineage( + const current = createSessionResolution( () => "ses_a", - () => fixture.lineage, + () => fixture.sessions, ) expect(current()).toBeUndefined() @@ -65,7 +65,7 @@ test("resolves an uncached session and exposes its lineage", async () => { fixture.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") dispose() }) @@ -76,12 +76,12 @@ test("resolves an uncached session and exposes its lineage", async () => { // of reporting the new session as missing. test("re-resolves when navigating to an uncached session without a remount", async () => { await createRoot(async (dispose) => { - const fixture = createFixture({ ses_a: lineageOf("ses_a") }) + const fixture = createFixture({ ses_a: sessionOf("ses_a") }) const [id, setId] = createSignal("ses_a") - const current = createSessionLineage(id, () => fixture.lineage) + const current = createSessionResolution(id, () => fixture.sessions) await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") expect(() => { setId("ses_b") @@ -91,7 +91,7 @@ test("re-resolves when navigating to an uncached session without a remount", asy fixture.settle("ses_b") await flush() - expect(current()?.session.id).toBe("ses_b") + expect(current()?.id).toBe("ses_b") dispose() }) @@ -103,7 +103,7 @@ test("ignores a stale resolution failure after the target changes", async () => await createRoot(async (dispose) => { const fixture = createFixture() const [id, setId] = createSignal("ses_a") - const current = createSessionLineage(id, () => fixture.lineage) + const current = createSessionResolution(id, () => fixture.sessions) await flush() setId("ses_b") @@ -113,7 +113,7 @@ test("ignores a stale resolution failure after the target changes", async () => expect(() => current()).not.toThrow() fixture.settle("ses_b") await flush() - expect(current()?.session.id).toBe("ses_b") + expect(current()?.id).toBe("ses_b") dispose() }) @@ -123,7 +123,7 @@ test("returning to a pruned session re-resolves instead of throwing not found", await createRoot(async (dispose) => { const fixture = createFixture() const [id, setId] = createSignal("ses_a") - const current = createSessionLineage(id, () => fixture.lineage) + const current = createSessionResolution(id, () => fixture.sessions) await flush() fixture.settle("ses_a") @@ -142,7 +142,7 @@ test("returning to a pruned session re-resolves instead of throwing not found", fixture.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") dispose() }) @@ -155,7 +155,7 @@ test("revisiting a session whose resolution failed while unfocused retries clean await createRoot(async (dispose) => { const fixture = createFixture() const [id, setId] = createSignal("ses_a") - const current = createSessionLineage(id, () => fixture.lineage) + const current = createSessionResolution(id, () => fixture.sessions) await flush() setId("ses_b") @@ -170,29 +170,29 @@ test("revisiting a session whose resolution failed while unfocused retries clean fixture.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") dispose() }) }) -// The lineage accessor is reactive: replacing the sync store (for example after +// The session accessor is reactive: replacing the data store (for example after // the server context is rebuilt) must gate out the old store's status and // re-resolve against the new one instead of fabricating a not-found. -test("re-resolves against a replaced lineage store", async () => { +test("re-resolves against a replaced session store", async () => { await createRoot(async (dispose) => { const first = createFixture() const second = createFixture() - const [store, setStore] = createSignal(first.lineage) - const current = createSessionLineage(() => "ses_a", store) + const [store, setStore] = createSignal(first.sessions) + const current = createSessionResolution(() => "ses_a", store) await flush() first.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") expect(() => { - setStore(second.lineage) + setStore(second.sessions) current() }).not.toThrow() await flush() @@ -200,7 +200,7 @@ test("re-resolves against a replaced lineage store", async () => { second.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") dispose() }) @@ -211,15 +211,15 @@ test("re-resolves against a replaced lineage store", async () => { test("throws not found when the settled session is deleted", async () => { await createRoot(async (dispose) => { const fixture = createFixture() - const current = createSessionLineage( + const current = createSessionResolution( () => "ses_a", - () => fixture.lineage, + () => fixture.sessions, ) await flush() fixture.settle("ses_a") await flush() - expect(current()?.session.id).toBe("ses_a") + expect(current()?.id).toBe("ses_a") expect(() => { fixture.remove("ses_a") diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 49a5acb6a4..7f93d563e3 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -93,6 +93,8 @@ type Store = { family: Record active: Record message: Record + messageCursor: Record + messageLoading: Record pending: Record input: Record permission: Record @@ -154,6 +156,8 @@ export function createData(config: CreateDataInput) { family: {}, active: {}, message: {}, + messageCursor: {}, + messageLoading: {}, pending: {}, input: {}, permission: {}, @@ -333,6 +337,8 @@ export function createData(config: CreateDataInput) { delete draft.info[sessionID] delete draft.active[sessionID] delete draft.message[sessionID] + delete draft.messageCursor[sessionID] + delete draft.messageLoading[sessionID] delete draft.pending[sessionID] delete draft.input[sessionID] delete draft.permission[sessionID] @@ -1017,38 +1023,6 @@ export function createData(config: CreateDataInput) { setStatus(sessionID: string, status: DataSessionStatus) { setSessionActive(sessionID, status) }, - lineage: { - peek(sessionID: string) { - const session = store.session.info[sessionID] - if (!session) return - const seen = new Set([session.id]) - let root = session - while (root.parentID) { - if (seen.has(root.parentID)) return { session, root } - seen.add(root.parentID) - const parent = store.session.info[root.parentID] - if (!parent) return - root = parent - } - return { session, root } - }, - async resolve(sessionID: string) { - await result.session.sync(sessionID) - const session = store.session.info[sessionID] - if (!session) throw new Error(`Session not found: ${sessionID}`) - const seen = new Set([session.id]) - let root = session - while (root.parentID) { - if (seen.has(root.parentID)) return { session, root } - seen.add(root.parentID) - await result.session.sync(root.parentID) - const parent = store.session.info[root.parentID] - if (!parent) throw new Error(`Session not found: ${root.parentID}`) - root = parent - } - return { session, root } - }, - }, root(sessionID: string) { return resolveRoot(sessionID) }, @@ -1133,11 +1107,34 @@ export function createData(config: CreateDataInput) { }, sync(sessionID: string) { return sync.run(`session.message:${sessionID}`, async () => { - const messages = (await api().message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed() + const response = await api().message.list({ sessionID, limit: 200, order: "desc" }) + const messages = response.data.toReversed() messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, reconcile(messages)) + setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined) }) }, + more(sessionID: string) { + return store.session.messageCursor[sessionID] !== undefined + }, + loading(sessionID: string) { + return store.session.messageLoading[sessionID] ?? false + }, + async loadMore(sessionID: string) { + const cursor = store.session.messageCursor[sessionID] + if (!cursor || store.session.messageLoading[sessionID]) return + setStore("session", "messageLoading", sessionID, true) + const response = await api() + .message.list({ sessionID, limit: 200, order: "desc", cursor }) + .finally(() => setStore("session", "messageLoading", sessionID, false)) + const older = response.data.toReversed() + const existing = store.session.message[sessionID] ?? [] + const ids = new Set(existing.map((item) => item.id)) + const messages = [...older.filter((item) => !ids.has(item.id)), ...existing] + messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position]))) + setStore("session", "message", sessionID, reconcile(messages)) + setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined) + }, invalidate(sessionID: string) { sync.invalidate(`session.message:${sessionID}`) }, @@ -1440,6 +1437,7 @@ export function createData(config: CreateDataInput) { const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(response.location) setStore("location", key, { ...store.location[key], model: response.data }) + if (key !== id) setStore("location", id, { ...store.location[id], model: response.data }) }) }, invalidate(ref?: LocationRef) { @@ -1456,6 +1454,7 @@ export function createData(config: CreateDataInput) { const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(response.location) setStore("location", key, { ...store.location[key], provider: response.data }) + if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data }) }) }, invalidate(ref?: LocationRef) { diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 082ffc443c..e1e2fea264 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -327,6 +327,7 @@ it.effect("generates from fresh settled Session context without durable mutation expect(requests[0]?.toolChoice).toBeUndefined() expect(yield* durableState(db, sessionID)).toEqual(before) }), + { timeout: 15_000 }, ) it.effect("blocks unavailable initial instructions before generation", () => @@ -343,4 +344,5 @@ it.effect("blocks unavailable initial instructions before generation", () => expect(requests).toEqual([]) expect(yield* durableState(db, sessionID)).toEqual(before) }), + { timeout: 15_000 }, ) diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts index 38b43f885b..ff6e5b2911 100644 --- a/packages/core/test/vcs-hg.test.ts +++ b/packages/core/test/vcs-hg.test.ts @@ -202,5 +202,6 @@ describeHg("Vcs mercurial", () => { expect(diff[0].patch).toContain("+two") }), ), + 15_000, ) }) diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts index bfb0e0eaa6..ca91b3c208 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) { ? join(app.getPath("userData"), "opencode", "service-local.json") : undefined, version: cli.version, - command: [...cli.command, "serve", "--service"], + command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])], onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }), }) if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication") diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 3fb9dae29a..baeff3767d 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -39,7 +39,7 @@ export default defineMain({ { find: /^@\/context\/file$/, replacement: path.resolve(mocks, "app/context/file.ts") }, { find: /^@\/context\/prompt$/, replacement: path.resolve(mocks, "app/context/prompt.ts") }, { find: /^@\/context\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") }, - { find: /^@\/context\/sdk$/, replacement: path.resolve(mocks, "app/context/sdk.ts") }, + { find: /^@\/context\/location$/, replacement: path.resolve(mocks, "app/context/location.ts") }, { find: /^@\/context\/sync$/, replacement: path.resolve(mocks, "app/context/sync.ts") }, { find: /^@\/context\/comments$/, replacement: path.resolve(mocks, "app/context/comments.ts") }, { find: /^@\/context\/command$/, replacement: path.resolve(mocks, "app/context/command.ts") }, diff --git a/packages/storybook/.storybook/mocks/app/context/global-sync.ts b/packages/storybook/.storybook/mocks/app/context/global-sync.ts index 2d7b990ec7..4f7d219d43 100644 --- a/packages/storybook/.storybook/mocks/app/context/global-sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/global-sync.ts @@ -1,5 +1,3 @@ -import { createStore } from "solid-js/store" - const provider = { all: [ { @@ -17,23 +15,6 @@ const provider = { default: { anthropic: "claude-3-7-sonnet" }, } -const [store, setStore] = createStore({ - provider, - session: [] as any[], - config: { permission: {} }, -}) - -export function useServerSync() { - return { - data: { - provider, - }, - child() { - return [store, setStore] as const - }, - } -} - export function useQueryOptions() { return { agents: (directory: string) => ({ diff --git a/packages/storybook/.storybook/mocks/app/context/location.ts b/packages/storybook/.storybook/mocks/app/context/location.ts new file mode 100644 index 0000000000..f01ecaac9f --- /dev/null +++ b/packages/storybook/.storybook/mocks/app/context/location.ts @@ -0,0 +1,18 @@ +const location = { + directory: "/tmp/story", + ref: { directory: "/tmp/story" }, + current: undefined, + error: undefined, + event: { + on: () => () => undefined, + listen: () => () => undefined, + }, +} + +export function useWorkspaceLocation() { + return () => location +} + +export function LocationProvider(props: { children?: unknown }) { + return props.children +} diff --git a/packages/storybook/.storybook/mocks/app/context/sdk.ts b/packages/storybook/.storybook/mocks/app/context/sdk.ts deleted file mode 100644 index 749772bb3e..0000000000 --- a/packages/storybook/.storybook/mocks/app/context/sdk.ts +++ /dev/null @@ -1,27 +0,0 @@ -const make = (directory: string) => ({ - session: { - create: async () => ({ data: { id: "story-session" } }), - prompt: async () => ({ data: undefined }), - shell: async () => ({ data: undefined }), - command: async () => ({ data: undefined }), - abort: async () => ({ data: undefined }), - }, - worktree: { - create: async () => ({ data: { directory: `${directory}/worktree-1` } }), - }, -}) - -const root = "/tmp/story" -const sdk = { - directory: root, - scope: "story-server", - url: "http://localhost:4096", - client: make(root), - createClient(input: { directory: string }) { - return make(input.directory) - }, -} - -export function useSDK() { - return () => sdk -} diff --git a/packages/storybook/.storybook/mocks/app/context/server-sync.ts b/packages/storybook/.storybook/mocks/app/context/server-sync.ts index 1e835afdb8..2270c50c87 100644 --- a/packages/storybook/.storybook/mocks/app/context/server-sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/server-sync.ts @@ -21,13 +21,3 @@ export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[] delete data.provider_auth[provider] } } - -export function useServerSync() { - return () => ({ - data, - set(key: "provider_auth", value: typeof data.provider_auth) { - data[key] = value - }, - updateConfig: async () => {}, - }) -} diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts index 1942927a3e..336ce12bb9 100644 --- a/packages/storybook/.storybook/mocks/app/context/sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -1,37 +1 @@ -import { createStore } from "solid-js/store" - -const [data, setData] = createStore({ - session: [] as Array<{ id: string; parentID?: string }>, - permission: {} as Record>, - question: {} as Record>, - session_diff: {} as Record>, - message: { - "story-session": [] as Array<{ id: string; role: string }>, - } as Record>, - session_status: {} as Record, - session_working: () => false, - agent: [{ name: "build", mode: "task", hidden: false }], - command: [{ name: "fix", description: "Run fix command", source: "project" }], - reference: [], - mcp_resource: {}, -}) - -const sync = { - data, - set(...input: unknown[]) { - ;(setData as (...args: unknown[]) => void)(...input) - }, - session: { - get(id: string) { - return { id } - }, - optimistic: { - add() {}, - remove() {}, - }, - }, -} - -export function useSync() { - return () => sync -} +export {} diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 1ccd3dc8ec..cc3ec7d6c2 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -244,6 +244,7 @@ test("keeps scroll anchors for open session tabs", async () => { try { await wait(() => setup.tabs.current() === "first") + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first")) setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 }) expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })