feat(ui): expose desktop session actions in header
Add a dedicated desktop header menu for the active session while keeping recent-session switching available when the sidebar is closed. Match inline rename behavior with the sidebar and expose rename, copy ID, share, export, archive, and delete actions with localized feedback. Automatically copy newly created share links, keep share/unshare state synchronized across live and global stores, and normalize stale upstream unshare responses so the UI immediately reflects successful unsharing. Require Markdown exports to load every available message page before formatting the conversation. Abort incomplete root exports, retain explicit child-session skip warnings, and guard complete-history pagination against failures and cursor cycles.
This commit is contained in:
@@ -467,11 +467,28 @@ describe("shareSession live state", () => {
|
||||
|
||||
const result = await unshareSession("session-a")
|
||||
|
||||
expect(result).toBe(unsharedSession)
|
||||
expect(result).toEqual({ ...unsharedSession, share: undefined })
|
||||
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])
|
||||
expect(globalUpsertedSessions).toEqual([{ ...unsharedSession, share: undefined }])
|
||||
})
|
||||
|
||||
test("clears a stale share URL echoed by a successful unshare response", async () => {
|
||||
const sharedSession = { id: "session-a", time: { created: 1 }, share: { url: "https://share.example/a" } } as Session
|
||||
const staleResponse = { id: "session-a", time: { created: 1, updated: 2 }, share: { url: "https://share.example/a" } } as Session
|
||||
const sessionStore = createStore({}, { session: [sharedSession] })
|
||||
const childStores = createChildStores([["/test/project", sessionStore]])
|
||||
sessionShareResult = { data: staleResponse }
|
||||
|
||||
const { setActionRefs, unshareSession } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project")
|
||||
|
||||
const result = await unshareSession("session-a")
|
||||
|
||||
expect(result?.share).toBe(undefined)
|
||||
expect(sessionStore.getState().session[0].share).toBe(undefined)
|
||||
expect((globalUpsertedSessions[0] as Session).share).toBe(undefined)
|
||||
})
|
||||
|
||||
test("updates the directory live store after sharing", async () => {
|
||||
@@ -492,7 +509,7 @@ describe("shareSession live state", () => {
|
||||
expect(globalUpsertedSessions).toEqual([sharedSession])
|
||||
})
|
||||
|
||||
test("preserves live directory metadata while clearing share from null response", async () => {
|
||||
test("preserves live directory metadata while normalizing a null share response", async () => {
|
||||
const sharedSession = {
|
||||
id: "session-a",
|
||||
time: { created: 1 },
|
||||
@@ -514,8 +531,8 @@ describe("shareSession live state", () => {
|
||||
|
||||
await unshareSession("session-a")
|
||||
|
||||
const liveSession = sessionStore.getState().session[0] as SessionWithDirectory & { share?: null }
|
||||
expect(liveSession.share).toBe(null)
|
||||
const liveSession = sessionStore.getState().session[0] as SessionWithDirectory
|
||||
expect(liveSession.share).toBe(undefined)
|
||||
expect(liveSession.directory).toBe("/test/project")
|
||||
expect(liveSession.project?.worktree).toBe("/test/project")
|
||||
})
|
||||
|
||||
@@ -843,7 +843,13 @@ export async function shareSession(sessionId: string): Promise<Session | null> {
|
||||
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 = stripSessionDiffSnapshots(assertSdkData(result, "session.unshare"))
|
||||
// A successful unshare is authoritative even when the upstream response
|
||||
// echoes the pre-mutation session with its old share URL. Normalize that
|
||||
// stale field at the action boundary before publishing to either store.
|
||||
const session = {
|
||||
...stripSessionDiffSnapshots(assertSdkData(result, "session.unshare")),
|
||||
share: undefined,
|
||||
}
|
||||
useGlobalSessionsStore.getState().upsertSession(session)
|
||||
updateLiveSession(session, sessionDirectory)
|
||||
return session
|
||||
|
||||
@@ -88,6 +88,91 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("loads every history page for an explicit complete-history request", async () => {
|
||||
const calls: Array<{ before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||
calls.push({ before })
|
||||
if (!before) return response([createRecord(sessionID, "msg_latest")], "cursor-2")
|
||||
if (before === "cursor-2") return response([createRecord(sessionID, "msg_middle")], "cursor-1")
|
||||
return response([createRecord(sessionID, "msg_oldest")])
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await loader.loadComplete(target)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ before: undefined },
|
||||
{ before: "cursor-2" },
|
||||
{ before: "cursor-1" },
|
||||
])
|
||||
expect(loader.getSnapshot(target).complete).toBe(true)
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]).toHaveLength(3)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects a complete-history request when its initial load fails", async () => {
|
||||
const { childStores, loader } = createLoader(async () => ({
|
||||
error: { message: "rejected" },
|
||||
response: { status: 400 },
|
||||
}))
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("session.messages failed (400): rejected")
|
||||
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects a complete-history request when an older page fails", async () => {
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => before
|
||||
? { error: { message: "older rejected" }, response: { status: 400 } }
|
||||
: response([createRecord(sessionID)], "older-cursor"))
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("session.messages failed (400): older rejected")
|
||||
|
||||
expect(loader.getSnapshot(target).cursor).toBe("older-cursor")
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("fetches authoritative coverage when renderable messages have no loader metadata", async () => {
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async ({ sessionID }) => {
|
||||
calls += 1
|
||||
return response([createRecord(sessionID)])
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
childStores.ensureChild(target.directory, { bootstrap: false }).setState({
|
||||
message: { [target.sessionID]: [createRecord(target.sessionID, "cached").info] },
|
||||
})
|
||||
|
||||
await loader.loadComplete(target)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(loader.getSnapshot(target).complete).toBe(true)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("rejects repeated pagination cursors instead of looping forever", async () => {
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||
calls += 1
|
||||
if (!before) return response([createRecord(sessionID, "latest")], "cursor-a")
|
||||
if (before === "cursor-a") return response([createRecord(sessionID, "middle")], "cursor-b")
|
||||
return response([createRecord(sessionID, "older")], "cursor-a")
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await expect(loader.loadComplete(target)).rejects.toThrow("Session history pagination made no progress")
|
||||
|
||||
expect(calls).toBe(3)
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("runs a requested tail refresh after an older in-flight load", async () => {
|
||||
const initial = deferred<ReturnType<typeof response>>()
|
||||
const refresh = deferred<ReturnType<typeof response>>()
|
||||
|
||||
@@ -242,6 +242,27 @@ export class SessionMessageLoader {
|
||||
})
|
||||
}
|
||||
|
||||
async loadComplete(target: SessionMessageTarget): Promise<void> {
|
||||
const normalized = this.normalizeTarget(target)
|
||||
if (!normalized || this.disposed) throw new Error("Session message loader is unavailable")
|
||||
const initial = this.getSnapshot(normalized)
|
||||
await this.ensure(normalized, { force: !initial.resolved })
|
||||
|
||||
const visitedCursors = new Set<string>()
|
||||
while (true) {
|
||||
const snapshot = this.getSnapshot(normalized)
|
||||
if (snapshot.status === "error") throw snapshot.error ?? new Error("Session history could not be loaded")
|
||||
if (snapshot.complete) return
|
||||
if (!snapshot.cursor) throw new Error("Session history coverage is unresolved")
|
||||
if (visitedCursors.has(snapshot.cursor)) {
|
||||
throw new Error("Session history pagination made no progress")
|
||||
}
|
||||
visitedCursors.add(snapshot.cursor)
|
||||
|
||||
await this.loadOlder(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
refreshTail(target: SessionMessageTarget, limit: number): Promise<void> {
|
||||
const normalized = this.normalizeTarget(target)
|
||||
if (!normalized || this.disposed) return Promise.resolve()
|
||||
|
||||
@@ -320,6 +320,14 @@ export function useSync() {
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const loadCompleteHistory = useCallback(
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
touch(sessionID, targetDirectory)
|
||||
await messageLoader.loadComplete({ directory: targetDirectory, sessionID })
|
||||
},
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const prefetchSession = useCallback(
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
if (getRuntimeKey() !== runtimeKey) return
|
||||
@@ -396,6 +404,7 @@ export function useSync() {
|
||||
syncSession,
|
||||
prefetchSession,
|
||||
loadMore,
|
||||
loadCompleteHistory,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isComplete,
|
||||
@@ -405,6 +414,6 @@ export function useSync() {
|
||||
confirm: optimisticConfirm,
|
||||
},
|
||||
}),
|
||||
[syncSession, prefetchSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm],
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user