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
This commit is contained in:
@@ -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<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.share", params })
|
||||
return Promise.resolve(sessionShareResult)
|
||||
}),
|
||||
unshare: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.unshare", params })
|
||||
return Promise.resolve(sessionShareResult)
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
reply: mock((params: Record<string, unknown>) => {
|
||||
@@ -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<string, PermissionRequest[]>,
|
||||
@@ -183,9 +209,114 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
||||
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<Record<string, unknown>> } }).summary?.diffs ?? [])[0]
|
||||
const globalDiff = (((globalUpsertedSessions[0] as { summary?: { diffs?: Array<Record<string, unknown>> } }).summary?.diffs ?? [])[0])
|
||||
const resultDiff = ((result as { summary?: { diffs?: Array<Record<string, unknown>> } }).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
|
||||
|
||||
@@ -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<Session | null> {
|
||||
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<Session | null> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user