From 26eb89e0ceda56aef579d4751ea261ca96b4f2f9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 7 Jun 2026 09:09:42 +0300 Subject: [PATCH] fix: refresh session share state after unsharing Updates live session state after share and unshare actions Preserves session directory metadata while applying share changes Adds regression coverage for unshare and sanitized share responses --- packages/ui/src/sync/session-actions.test.ts | 133 ++++++++++++++++++- packages/ui/src/sync/session-actions.ts | 31 ++++- 2 files changed, 159 insertions(+), 5 deletions(-) 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 }