diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index e1964473..de85748b 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -8,6 +8,8 @@ const scopedClientDirectories: string[] = [] const registeredSessionDirectories: Array<{ sessionID: string; directory: string }> = [] let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let questionReplyError: unknown | null = null +let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} +const globalUpsertedSessions: unknown[] = [] const mockScopedClient = { permission: { @@ -45,6 +47,14 @@ const mockSdk = { replyCalls.push({ method: "session.abort", params }) return Promise.resolve({ data: true }) }), + share: mock((params: Record) => { + replyCalls.push({ method: "session.share", params }) + return Promise.resolve(sessionShareResult) + }), + unshare: mock((params: Record) => { + replyCalls.push({ method: "session.unshare", params }) + return Promise.resolve(sessionShareResult) + }), }, permission: { reply: mock((params: Record) => { @@ -141,9 +151,21 @@ mock.module("./input-store", () => ({ })) mock.module("@/stores/useGlobalSessionsStore", () => ({ + mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => { + if (!existing) return incoming as SessionWithDirectory + const next = { ...(incoming as SessionWithDirectory) } + if (!next.directory && existing.directory) next.directory = existing.directory + if (!next.project && existing.project) next.project = existing.project + if (next.project && !next.project.worktree && existing.project?.worktree) { + next.project = { ...next.project, worktree: existing.project.worktree } + } + return next + }, useGlobalSessionsStore: { getState: () => ({ - upsertSession: () => {}, + upsertSession: (session: unknown) => { + globalUpsertedSessions.push(session) + }, }), }, })) @@ -161,6 +183,10 @@ import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2 type OptimisticAddCall = { sessionID: string; directory?: string | null; message: Message; parts: Part[] } type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string } +type SessionWithDirectory = Session & { + directory?: string | null + project?: { worktree?: string | null } +} function createStore( permissions: Record, @@ -183,9 +209,114 @@ function createChildStores(entries: Array<[string, StoreApi]>) { if (!store) throw new Error(`No store for ${dir}`) return store }, + getChild: (dir: string) => new Map(entries).get(dir), } as unknown as import("./child-store").ChildStoreManager } +describe("shareSession live state", () => { + beforeEach(() => { + replyCalls.length = 0 + globalUpsertedSessions.length = 0 + sessionShareResult = {} + }) + + test("updates the directory live store after unsharing", async () => { + const sharedSession = { id: "session-a", time: { created: 1 }, share: { url: "https://share.example/a" } } as Session + const unsharedSession = { id: "session-a", time: { created: 1, updated: 2 } } as Session + const sessionStore = createStore({}, { session: [sharedSession] }) + const otherStore = createStore({}, { session: [{ id: "other", time: { created: 1 } } as Session] }) + const childStores = createChildStores([ + ["/test/project", sessionStore], + ["/other/project", otherStore], + ]) + sessionShareResult = { data: unsharedSession } + + const { setActionRefs, unshareSession } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + const result = await unshareSession("session-a") + + expect(result).toBe(unsharedSession) + expect(replyCalls.find((call) => call.method === "session.unshare")?.params.directory).toBe("/test/project") + expect(sessionStore.getState().session[0].share).toBe(undefined) + expect(otherStore.getState().session[0].id).toBe("other") + expect(globalUpsertedSessions).toEqual([unsharedSession]) + }) + + test("updates the directory live store after sharing", async () => { + const unsharedSession = { id: "session-a", time: { created: 1 } } as Session + const sharedSession = { id: "session-a", time: { created: 1, updated: 2 }, share: { url: "https://share.example/a" } } as Session + const sessionStore = createStore({}, { session: [unsharedSession] }) + const childStores = createChildStores([["/test/project", sessionStore]]) + sessionShareResult = { data: sharedSession } + + const { setActionRefs, shareSession } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + const result = await shareSession("session-a") + + expect(result).toBe(sharedSession) + expect(replyCalls.find((call) => call.method === "session.share")?.params.directory).toBe("/test/project") + expect(sessionStore.getState().session[0].share?.url).toBe("https://share.example/a") + expect(globalUpsertedSessions).toEqual([sharedSession]) + }) + + test("preserves live directory metadata while clearing share from null response", async () => { + const sharedSession = { + id: "session-a", + time: { created: 1 }, + directory: "/test/project", + project: { worktree: "/test/project" }, + share: { url: "https://share.example/a" }, + } as SessionWithDirectory + const unsharedSession = { + id: "session-a", + time: { created: 1, updated: 2 }, + share: null, + } as unknown as Session + const sessionStore = createStore({}, { session: [sharedSession] }) + const childStores = createChildStores([["/test/project", sessionStore]]) + sessionShareResult = { data: unsharedSession } + + const { setActionRefs, unshareSession } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + await unshareSession("session-a") + + const liveSession = sessionStore.getState().session[0] as SessionWithDirectory & { share?: null } + expect(liveSession.share).toBe(null) + expect(liveSession.directory).toBe("/test/project") + expect(liveSession.project?.worktree).toBe("/test/project") + }) + + test("strips oversized diff snapshots before updating session stores", async () => { + const sessionWithDiff = { + id: "session-a", + time: { created: 1, updated: 2 }, + share: { url: "https://share.example/a" }, + summary: { + diffs: [{ file: "a.txt", before: "old", after: "new", additions: 1, deletions: 1 }], + }, + } as unknown as Session + const sessionStore = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] }) + const childStores = createChildStores([["/test/project", sessionStore]]) + sessionShareResult = { data: sessionWithDiff } + + const { setActionRefs, shareSession } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + const result = await shareSession("session-a") + + const storedDiff = ((sessionStore.getState().session[0] as { summary?: { diffs?: Array> } }).summary?.diffs ?? [])[0] + const globalDiff = (((globalUpsertedSessions[0] as { summary?: { diffs?: Array> } }).summary?.diffs ?? [])[0]) + const resultDiff = ((result as { summary?: { diffs?: Array> } }).summary?.diffs ?? [])[0] + expect(storedDiff.before).toBe(undefined) + expect(storedDiff.after).toBe(undefined) + expect(globalDiff.before).toBe(undefined) + expect(resultDiff.after).toBe(undefined) + }) +}) + describe("optimisticSend target directory", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 15948659..5e5af61c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -9,12 +9,12 @@ import { useSessionUIStore } from "./session-ui-store" import { useInputStore } from "./input-store" import type { ChildStoreManager } from "./child-store" import { opencodeClient } from "@/lib/opencode/client" -import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" +import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" import { isSyntheticPart } from "@/lib/messages/synthetic" import { materializeSessionSnapshots } from "./materialization" -import { stripMessageDiffSnapshots } from "./sanitize" +import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { sessionEvents } from "@/lib/sessionEvents" import { getOriginalSessionID, @@ -127,6 +127,27 @@ function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; dire return { store: dirStore(), directory: dir() } } +function updateLiveSession(session: Session, directory?: string): void { + const stores = _childStores + if (!stores) return + + const candidates = directory + ? [[directory, stores.getChild(directory)] as const] + : stores.children + + for (const [, store] of candidates) { + if (!store) continue + const current = store.getState().session + const index = current.findIndex((item) => item.id === session.id) + if (index === -1) continue + + const next = [...current] + next[index] = mergeSessionDirectoryMetadata(session, current[index]) + store.setState({ session: next }) + return + } +} + function dir() { return _getDirectory() || undefined } @@ -527,16 +548,18 @@ export async function updateSessionTitle(sessionId: string, title: string): Prom export async function shareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory }) - const session = assertSdkData(result, "session.share") + const session = stripSessionDiffSnapshots(assertSdkData(result, "session.share")) useGlobalSessionsStore.getState().upsertSession(session) + updateLiveSession(session, sessionDirectory) return session } export async function unshareSession(sessionId: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory }) - const session = assertSdkData(result, "session.unshare") + const session = stripSessionDiffSnapshots(assertSdkData(result, "session.unshare")) useGlobalSessionsStore.getState().upsertSession(session) + updateLiveSession(session, sessionDirectory) return session }