diff --git a/packages/app/src/components/session-action-menu.tsx b/packages/app/src/components/session-action-menu.tsx new file mode 100644 index 000000000000..320538983188 --- /dev/null +++ b/packages/app/src/components/session-action-menu.tsx @@ -0,0 +1,46 @@ +import type { JSX } from "solid-js" +import { Show } from "solid-js" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { useLanguage } from "@/context/language" + +export function SessionActionMenu(props: { + trigger: JSX.Element + open: boolean + onOpenChange: (open: boolean) => void + onCloseAutoFocus?: () => boolean + shareEnabled: boolean + onRename: () => void + onShare: () => void + onExport: () => void + onArchive: () => void + onDelete: () => void +}) { + const language = useLanguage() + return ( + + {props.trigger} + + { + if (props.onCloseAutoFocus?.()) event.preventDefault() + }} + > + {language.t("common.rename")} + + {language.t("session.share.action.share")}... + + {language.t("common.export")}... + {language.t("common.archive")} + + {language.t("common.delete")}... + + + + ) +} diff --git a/packages/app/src/components/session-delete-dialog.tsx b/packages/app/src/components/session-delete-dialog.tsx new file mode 100644 index 000000000000..a279ff32f91b --- /dev/null +++ b/packages/app/src/components/session-delete-dialog.tsx @@ -0,0 +1,32 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" + +export function SessionDeleteDialog(props: { name: string; onDelete: () => Promise }) { + const dialog = useDialog() + const language = useLanguage() + return ( + + + + + + dialog.close()}> + {language.t("common.cancel")} + + { + void props.onDelete().then((ok) => { if (ok) dialog.close() }) + }} + > + {language.t("session.delete.button")} + + + + ) +} diff --git a/packages/app/src/components/session-share-popover.tsx b/packages/app/src/components/session-share-popover.tsx new file mode 100644 index 000000000000..b164eef78d42 --- /dev/null +++ b/packages/app/src/components/session-share-popover.tsx @@ -0,0 +1,65 @@ +import { Popover } from "@kobalte/core/popover" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Show } from "solid-js" +import { useLanguage } from "@/context/language" + +export function SessionSharePopover(props: { + open: boolean + anchor: () => HTMLElement | undefined + url?: string + publishing?: boolean + unpublishing?: boolean + onOpenChange: (open: boolean) => void + onPublish: () => void + onUnpublish: () => void + onCopy: () => void + onView: () => void +}) { + const language = useLanguage() + return ( + + + +
+
+ {language.t("session.share.popover.title")} +
+
+ {props.url + ? language.t("session.share.popover.description.shared") + : language.t("session.share.popover.description.unshared")} +
+
+
+ + {props.publishing ? language.t("session.share.action.publishing") : language.t("session.share.action.publish")} + + } + > +
+
+
+ {props.url} +
+ } aria-label={language.t("session.share.copy.copyLink")} onClick={props.onCopy} /> + } aria-label={language.t("session.share.action.view")} onClick={props.onView} disabled={props.unpublishing} /> +
+ + {props.unpublishing ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")} + +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 0a4b4da12dfe..48b9768bc7c4 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -20,6 +20,7 @@ import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" import type { Session } from "@opencode-ai/sdk/v2" +import { createSessionMutation } from "@/utils/session-mutation" function SessionTabSlot(props: { tab: SessionTab @@ -103,13 +104,9 @@ function SessionTabEntry(props: { const ctx = props.serverCtx() if (!value || !ctx) return - ctx.sync.session.remember({ ...value, title }) try { - await ctx.sdk.api.session.rename({ sessionID: value.id, title }) + await createSessionMutation({ client: ctx.sdk.client, serverSync: ctx.sync }).rename(value, title) } catch (err) { - const current = session() - const currentCtx = props.serverCtx() - if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) showToast({ title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : undefined, 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 9b94f1de2128..541b004aceaa 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 @@ -180,6 +180,8 @@ describe("Home V2 session index", () => { cache.remove("a") expect(queryClient.getQueryData(cache.indexKey)).toBeUndefined() - expect(cache.sessions({ sessions, eventSequence: 0 }, undefined).map((item) => item.id)).toEqual(["b"]) + expect(cache.sessions({ sessions, eventSequence: 0 }, queryClient.getQueryData(cache.eventsKey)).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 781c39c45012..55881413d10a 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -1,5 +1,6 @@ import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" import type { QueryClient } from "@tanstack/solid-query" +import { createStore, reconcile } from "solid-js/store" import { trimSessions } from "./session-trim" import { pathKey } from "@/utils/path-key" @@ -7,7 +8,7 @@ export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 export type HomeSessionEvent = { type: "session.created" | "session.updated" | "session.deleted" - properties: { sessionID: string; info: Session } + properties: { sessionID: string; info?: Session } } export type HomeSessionEvents = { sequence: number @@ -85,11 +86,18 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st const indexKey = homeSessionIndexKey(server) const eventsKey = homeSessionEventsKey(server) let connected = false - const removed = new Set() + + const [state, setState] = createStore({ + sessions: [] as Session[], + ready: false, + }) return { indexKey, eventsKey, + get state() { + return state + }, eventSequence() { return queryClient.getQueryData(eventsKey)?.sequence ?? 0 }, @@ -98,17 +106,21 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) }, sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - const sessions = homeSessionIndexSessions(index, events) - return removed.size === 0 ? sessions : sessions.filter((session) => !removed.has(session.id)) + return state.ready ? state.sessions : homeSessionIndexSessions(index, events) + }, + setInitial(sessions: Session[]) { + const currentEvents = queryClient.getQueryData(eventsKey) + const merged = homeSessionIndexSessions({ sessions, eventSequence: 0 }, currentEvents) + setState({ sessions: merged, ready: true }) + queryClient.setQueryData(indexKey, { sessions: merged, eventSequence: currentEvents?.sequence ?? 0 }) }, 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 + queryClient.setQueryData(eventsKey, next) + if (state.ready) { + const updated = applyHomeSessionEvent(state.sessions, event) + setState("sessions", reconcile(updated, { key: "id" })) } - const index = queryClient.getQueryData(indexKey) if (index) { queryClient.setQueryData(indexKey, { @@ -116,17 +128,9 @@ export function createHomeSessionIndexCache(queryClient: QueryClient, server: st eventSequence: next.sequence, }) } - queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) }, remove(sessionID: string) { - removed.add(sessionID) - if (!queryClient.getQueryState(indexKey)) return - queryClient.setQueryData(indexKey, (index) => { - if (!index) return index - const at = index.sessions.findIndex((session) => session.id === sessionID) - if (at === -1) return index - return { ...index, sessions: index.sessions.toSpliced(at, 1) } - }) + this.apply({ type: "session.deleted", properties: { sessionID } }) }, refresh(event: Event["type"]) { const result = homeSessionIndexRefresh(event, connected) @@ -156,8 +160,13 @@ export function retainHomeSessions(sessions: Session[], limit: number, now: numb export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { const info = event.properties.info - const index = sessions.findIndex((session) => session.id === info.id) - if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { + const sessionID = info?.id ?? event.properties.sessionID + const index = sessions.findIndex((session) => session.id === sessionID) + if (event.type === "session.deleted") { + if (index === -1) return sessions + return sessions.toSpliced(index, 1) + } + if (!info || info.parentID || typeof info.time.archived === "number") { if (index === -1) return sessions return sessions.toSpliced(index, 1) } diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index b6608e868112..9254704fb2aa 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -8,7 +8,7 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" -import { sessionHref } from "@/utils/session-route" +import { requireServerKey, sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { createDraftPromptSession, type PromptModel } from "./prompt-state" @@ -179,12 +179,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const actions = { addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } - const existing = store.find((item) => tabKey(item) === tabKey(next)) + const key = tabKey(next) + const existing = store.find((item) => tabKey(item) === key) if (existing) return existing + if (closing.has(key)) return next void startTransition(() => { setStore( produce((tabs) => { - if (tabs.some((item) => tabKey(item) === tabKey(next))) return + if (closing.has(key)) return + if (tabs.some((item) => tabKey(item) === key)) return tabs.push(next) }), ) @@ -298,17 +301,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeSessions: (input: SessionTabsRemovedDetail) => { const targetServer = input.server ?? server.key updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs)) - const removed = store - .filter( - (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), - ) - .map(tabKey) + const removed = input.sessionIDs.map((sessionId) => + tabKey({ type: "session", server: targetServer, sessionId }), + ) + removed.forEach((key) => closing.add(key)) void startTransition(() => { setStore( produce((tabs) => { const sessionIDs = new Set(input.sessionIDs) + const routeServer = params.serverKey ? requireServerKey(params.serverKey) : server.key const currentHref = - targetServer === server.key && params.dir && params.id + params.id && routeServer === targetServer ? tabHref({ type: "session", server: targetServer, @@ -343,7 +346,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) - }) + }).finally(() => removed.forEach((key) => closing.delete(key))) for (const key of removed) memory.remove(key) for (const key of removed) removeInfo(key) }, diff --git a/packages/app/src/pages/home/home-session-menu.test.ts b/packages/app/src/pages/home/home-session-menu.test.ts new file mode 100644 index 000000000000..83b36ca86f1e --- /dev/null +++ b/packages/app/src/pages/home/home-session-menu.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test" +import { archiveHomeSession } from "../home-session-archive" +import { sessionRemovalIDs } from "@/utils/session-delete" +import { publishSession, unpublishSession } from "@/utils/session-share" + +describe("session actions", () => { + test("publish returns share URL", async () => { + expect( + await publishSession( + { + session: { + share: async () => ({ data: { share: { url: "https://share.example/session" } } }), + unshare: async () => undefined, + }, + }, + "ses_1", + ), + ).toBe("https://share.example/session") + }) + + test("publish rejects missing URL", async () => { + expect( + publishSession( + { session: { share: async () => ({ data: {} }), unshare: async () => undefined } }, + "ses_1", + ), + ).rejects.toThrow("Session share URL missing") + }) + + test("unpublish calls client", async () => { + const calls: string[] = [] + await unpublishSession( + { + session: { + share: async () => ({ data: {} }), + unshare: async ({ sessionID }) => calls.push(sessionID), + }, + }, + "ses_1", + ) + expect(calls).toEqual(["ses_1"]) + }) + + test("deletion includes descendants only", () => { + expect( + [...sessionRemovalIDs([{ id: "root" }, { id: "child", parentID: "root" }, { id: "grand", parentID: "child" }, { id: "other" }], "root")], + ).toEqual(["root", "child", "grand"]) + }) +}) + +describe("archiveHomeSession", () => { + test("removes session and tabs after archive", async () => { + let removed = false + await archiveHomeSession({ + server: "remote" as never, + session: { id: "ses_1", directory: "/workspace" }, + archive: async () => undefined, + remove: () => { + removed = true + }, + }) + expect(removed).toBe(true) + }) + + test("does not remove session after archive failure", async () => { + let removed = false + let cause: unknown + const failure = new Error("offline") + await archiveHomeSession({ + server: "remote" as never, + session: { id: "ses_1", directory: "/workspace" }, + archive: async () => Promise.reject(failure), + remove: () => { + removed = true + }, + onError: (error) => { + cause = error + }, + }) + expect(removed).toBe(false) + expect(cause).toBe(failure) + }) +}) diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index f306f208cc80..48066e511b4b 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -1,10 +1,10 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" +import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useQuery } from "@tanstack/solid-query" import { DateTime } from "luxon" import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" -import { produce } from "solid-js/store" import { useCommand } from "@/context/command" import { loadHomeSessionIndex, @@ -12,15 +12,21 @@ import { type HomeSessionEvents, } from "@/context/global-sync/home-session-index" import type { LocalProject } from "@/context/layout" +import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { sessionHasOpenTab, useTabs } from "@/context/tabs" import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" +import { createSessionMutation } from "@/utils/session-mutation" import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" -import { archiveHomeSession } from "../home-session-archive" +import { + fetchSessionExport, + downloadSessionExport, + sessionExportFilename, +} from "@/utils/session-export" +import { base64Encode } from "@opencode-ai/core/util/encode" import type { HomeController } from "./home-controller" const HOME_SESSION_LIMIT = 64 @@ -39,6 +45,7 @@ export type HomeSessionGroup = { export type OpenSessionOptions = { background?: boolean } export function createHomeSessionsController(home: HomeController) { + const navigate = useNavigate() const tabs = useTabs() const command = useCommand() const dialog = useDialog() @@ -72,6 +79,7 @@ export function createHomeSessionsController(home: HomeController) { signal, ) cache.complete(eventSequence) + cache.setInitial(index.sessions) return index }, retry: false, @@ -79,13 +87,11 @@ export function createHomeSessionsController(home: HomeController) { refetchOnMount: true, refetchOnReconnect: true, })) - const indexedSessions = createMemo(() => - retainHomeSessions( - homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), - HOME_SESSION_LIMIT, - Date.now(), - ), - ) + const indexedSessions = createMemo(() => { + const cache = homeSessions() + const raw = cache.state.ready ? cache.state.sessions : cache.sessions(sessionLoad.data, sessionEventLoad.data) + return retainHomeSessions(raw, HOME_SESSION_LIMIT, Date.now()) + }) const allRecords = createMemo(() => buildHomeSessionRecords({ sessions: indexedSessions, @@ -177,6 +183,7 @@ export function createHomeSessionsController(home: HomeController) { showProjectName: () => !home.project.selected(), server: () => home.selection.value().server, canCreate: () => !!home.project.newSession(), + shareEnabled: () => home.server.focusedSync().data.config.share !== "disabled", create: home.project.openNewSession, open: (session: Session, options?: OpenSessionOptions) => { const directoryKey = pathKey(session.directory) @@ -208,32 +215,113 @@ 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.directory) - if ((await ctx.sdk.protocol) !== "v1") return - await archiveHomeSession({ - server: ServerConnection.key(conn), - session, - archive: (sessionID) => - ctx.sdk.client.session.update({ - sessionID, - directory: session.directory, - time: { archived: Date.now() }, - }), - remove: () => { - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (item) => item.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ) - homeSessions().remove(session.id) - }, - onError: (cause) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(cause, language.t("common.requestFailed")), - }), - }) + try { + await createSessionMutation({ + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + serverSync: ctx.sync, + }).archive(session) + notifySessionTabsRemoved({ + server: ServerConnection.key(conn), + directory: session.directory, + sessionIDs: [session.id], + }) + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) + } + }, + rename: async (session: Session, title: string) => { + if (!title || title === session.title) return + const ctx = home.server.focusedContext() + if (!ctx) return + try { + await createSessionMutation({ + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + serverSync: ctx.sync, + }).rename(session, title) + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) + } + }, + share: async (session: Session) => { + const ctx = home.server.focusedContext() + if (!ctx) return + try { + return await createSessionMutation({ + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + serverSync: ctx.sync, + }).publish(session) + } catch (cause) { + showToast({ + title: language.t("toast.session.share.failed.title"), + description: errorMessage(cause, language.t("toast.session.share.failed.description")), + }) + } + }, + unshare: async (session: Session): Promise => { + const ctx = home.server.focusedContext() + if (!ctx) return false + try { + await createSessionMutation({ + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + serverSync: ctx.sync, + }).unpublish(session) + return true + } catch (cause) { + showToast({ + title: language.t("toast.session.unshare.failed.title"), + description: errorMessage(cause, language.t("toast.session.unshare.failed.description")), + }) + return false + } + }, + exportSession: async (session: Session) => { + const ctx = home.server.focusedContext() + if (!ctx) return + try { + const data = await fetchSessionExport({ + sessionID: session.id, + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (cause) { + showToast({ + title: language.t("toast.session.export.failed.title"), + description: errorMessage(cause, language.t("toast.session.export.failed.description")), + }) + } + }, + delete: async (session: Session): Promise => { + const ctx = home.server.focusedContext() + if (!ctx) return false + try { + const removed = await createSessionMutation({ + client: ctx.sdk.ensureDirSdkContext(session.directory).client, + serverSync: ctx.sync, + }).delete(session) + notifySessionTabsRemoved({ + server: home.selection.value().server, + directory: session.directory, + sessionIDs: [...removed], + }) + return true + } catch (cause) { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(cause, language.t("session.delete.failed.title")), + }) + return false + } }, }, tab: { @@ -292,6 +380,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType void onOpenSession: (session: Session, options?: OpenSessionOptions) => void onArchiveSession: (session: Session) => Promise + onRenameSession: (session: Session, title: string) => Promise + onShareSession: (session: Session) => Promise + onUnshareSession: (session: Session) => Promise + onExportSession: (session: Session) => Promise + onDeleteSession: (session: Session) => Promise + shareEnabled: Accessor onSetHoverTarget: (element: HTMLElement) => void onSetThumbTrack: (element: HTMLDivElement) => void onSetContent: (element: HTMLDivElement) => void @@ -417,65 +427,198 @@ function HomeSessionGroupHeader(props: { function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const showProjectName = () => props.showProjectName() && props.record.projectName + const shareUrl = createMemo(() => props.record.session.share?.url) + const [state, setState] = createStore({ + menuOpen: false, + editing: false, + renaming: false, + pendingRename: false, + pendingShare: false, + draft: "", + shareOpen: false, + sharing: false, + unsharing: false, + }) + const dialog = useDialog() + const language = useLanguage() + let menuTrigger: HTMLButtonElement | undefined + let titleInput: HTMLInputElement | undefined + + function rename() { + setState("editing", true) + setState("draft", props.record.session.title ?? "") + requestAnimationFrame(() => { + titleInput?.focus() + titleInput?.select() + }) + } + + function saveRename() { + if (state.renaming || !state.editing) return + const next = state.draft.trim() + setState("editing", false) + if (!next || next === props.record.session.title) return + setState("renaming", true) + void props.onRenameSession(props.record.session, next).finally(() => setState("renaming", false)) + } return (
- - -
{ + if (event.button === 1) event.preventDefault() + }} + onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })} + onAuxClick={(event) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.onOpenSession(props.record.session, { background: true }) + }} + > + + + + + + + } > - - setState("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + saveRename() + } + if (event.key === "Escape") { + event.preventDefault() + setState("editing", false) + } + }} + onBlur={saveRename} + /> + +
+
+ } - aria-label={props.language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.onArchiveSession(props.record.session) + icon={} + aria-label={language.t("common.moreOptions")} + aria-expanded={state.menuOpen || state.shareOpen} + ref={(element: HTMLButtonElement) => { + menuTrigger = element }} + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} /> - -
-
+ } + open={state.menuOpen} + onOpenChange={(open) => setState("menuOpen", open)} + onCloseAutoFocus={() => { + if (state.pendingRename) { + setState("pendingRename", false) + rename() + return true + } + if (!state.pendingShare) return false + requestAnimationFrame(() => { + setState("shareOpen", true) + setState("pendingShare", false) + }) + return true + }} + shareEnabled={props.shareEnabled()} + onRename={() => { + setState("pendingRename", true) + setState("menuOpen", false) + }} + onShare={() => { + setState("pendingShare", true) + setState("menuOpen", false) + }} + onExport={() => props.onExportSession(props.record.session)} + onArchive={() => props.onArchiveSession(props.record.session)} + onDelete={() => + dialog.show(() => ( + props.onDeleteSession(props.record.session)} + /> + )) + } + /> + menuTrigger} + url={shareUrl()} + publishing={state.sharing} + unpublishing={state.unsharing} + onOpenChange={(open) => setState("shareOpen", open)} + onPublish={() => { + setState("sharing", true) + void props + .onShareSession(props.record.session) + .catch(() => {}) + .finally(() => { + setState("sharing", false) + }) + }} + onUnpublish={() => { + setState("unsharing", true) + void props + .onUnshareSession(props.record.session) + .catch(() => {}) + .finally(() => { + setState("unsharing", false) + }) + }} + onCopy={() => { + const url = shareUrl() + if (url) void navigator.clipboard.writeText(url) + }} + onView={() => { + const url = shareUrl() + if (url) window.open(url, "_blank", "noopener,noreferrer") + }} + /> +
) } diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx index 7bda4dd3630f..35fedcd2682b 100644 --- a/packages/app/src/pages/home/home-sessions.tsx +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -27,6 +27,12 @@ export function HomeSessions(props: { onCreateSession={props.sessions.session.create} onOpenSession={props.sessions.session.open} onArchiveSession={props.sessions.session.archive} + onRenameSession={props.sessions.session.rename} + onShareSession={props.sessions.session.share} + onUnshareSession={props.sessions.session.unshare} + onExportSession={props.sessions.session.exportSession} + onDeleteSession={props.sessions.session.delete} + shareEnabled={props.sessions.session.shareEnabled} onSetHoverTarget={props.scroll.viewport.setHoverTarget} onSetThumbTrack={props.scroll.viewport.setThumbTrack} onSetContent={props.scroll.header.setContent} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 98548ab2333b..c2c00ab17dd1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -15,6 +15,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useServerSync } from "@/context/server-sync" +import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" @@ -30,6 +31,7 @@ import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" +import { createSessionMutation } from "@/utils/session-mutation" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" @@ -40,7 +42,6 @@ import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" @@ -869,29 +870,18 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { - if ((await serverSDK().protocol) !== "v1") return - const [store, setStore] = serverSync().child(session.directory) - const sessions = store.session ?? [] - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] - - await serverSDK().client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: Date.now() }, - }) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (s) => s.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ) - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } + try { + await createSessionMutation({ client: serverSDK().ensureDirSdkContext(session.directory).client, serverSync: serverSync() }).archive(session) + notifySessionTabsRemoved({ + server: server.key, + directory: session.directory, + sessionIDs: [session.id], + }) + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) } } @@ -1481,12 +1471,8 @@ export default function LegacyLayout(props: ParentProps) { sessions .filter((session) => session.time.archived === undefined) .map((session) => - serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: Date.now() }, - }) + createSessionMutation({ client: serverSDK().client, serverSync: serverSync() }) + .archive(session) .catch(() => undefined), ), ) diff --git a/packages/app/src/pages/session/session-archive.ts b/packages/app/src/pages/session/session-archive.ts index 396886953954..01e3f831150c 100644 --- a/packages/app/src/pages/session/session-archive.ts +++ b/packages/app/src/pages/session/session-archive.ts @@ -1,66 +1,35 @@ -import { useNavigate } from "@solidjs/router" -import { produce } from "solid-js/store" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" import { useSync } from "@/context/sync" -import { useTabs } from "@/context/tabs" import { errorMessage } from "@/pages/layout/helpers" import { useSessionKey } from "@/pages/session/session-layout" -import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { requireServerKey } from "@/utils/session-route" import { showToast } from "@/utils/toast" +import { createSessionMutation } from "@/utils/session-mutation" export function useSessionArchive() { const language = useLanguage() - const navigate = useNavigate() const sdk = useSDK() const sync = useSync() + const server = useServer() const serverSync = useServerSync() - const tabs = useTabs() const { params } = useSessionKey() - const navigateAfterRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - const href = (id: string) => - params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id) - if (parentID) { - navigate(href(parentID)) - return - } - if (nextSessionID) { - navigate(href(nextSessionID)) - return - } - if (params.serverKey) { - tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) - return - } - navigate(`/${params.dir}/session`) - } - const archive = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return - if ((await sdk().protocol) !== "v1") return - const sessions = sync().data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk() - .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) + await createSessionMutation({ client: sdk().client, serverSync: serverSync() }) + .archive(session) .then(() => { - sync().set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - sync().session.evict(sessionID) - serverSync().homeSessions.remove(sessionID) - navigateAfterRemoval(sessionID, session.parentID, nextSession?.id) - notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] }) + notifySessionTabsRemoved({ + server: params.serverKey ? requireServerKey(params.serverKey) : server.key, + directory: sdk().directory, + sessionIDs: [sessionID], + }) }) .catch((err) => { showToast({ @@ -70,5 +39,5 @@ export function useSessionArchive() { }) } - return { archive, navigateAfterRemoval } + return { archive } } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index e0838825570e..1846f182ccaf 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -17,7 +17,6 @@ import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" -import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" import { ContextToolGroup, @@ -35,14 +34,11 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { Dialog } from "@opencode-ai/ui/dialog" -import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { InlineInput } from "@opencode-ai/ui/inline-input" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SessionRetry } from "@opencode-ai/session-ui/session-retry" import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" -import { TextField } from "@opencode-ai/ui/text-field" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import type { @@ -55,22 +51,28 @@ import type { import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/session-ui/session-diff" import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionActionMenu } from "@/components/session-action-menu" +import { SessionDeleteDialog } from "@/components/session-delete-dialog" +import { SessionSharePopover } from "@/components/session-share-popover" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useSessionArchive } from "@/pages/session/session-archive" import { useServerSDK } from "@/context/server-sdk" +import { ServerConnection } from "@/context/server" +import { useServerSync } from "@/context/server-sync" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" +import { sessionRemovalIDs } from "@/utils/session-delete" +import { createSessionMutation } from "@/utils/session-mutation" import { sessionTitle } from "@/utils/session-title" import { scheduleConnectedMeasure } from "./measure" import { observeElementOffsetReconnectAware } from "./observe-element-offset" @@ -260,6 +262,7 @@ export function MessageTimeline(props: { const navigate = useNavigate() const serverSDK = useServerSDK() + const serverSync = useServerSync() const sdk = useSDK() const sync = useSync() const settings = useSettings() @@ -659,29 +662,25 @@ export function MessageTimeline(props: { } const shareMutation = useMutation(() => ({ - mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }), + mutationFn: (session: NonNullable>) => + createSessionMutation({ client: sdk().client, serverSync: serverSync() }).publish(session), onError: (err) => { console.error("Failed to share session", err) }, })) const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }), + mutationFn: (session: NonNullable>) => + createSessionMutation({ client: sdk().client, serverSync: serverSync() }).unpublish(session), onError: (err) => { console.error("Failed to unshare session", err) }, })) const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => - sdk().api.session.rename({ sessionID: input.id, title: input.title }), - onSuccess: (_, input) => { - sync().set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === input.id) - if (index !== -1) draft.session[index].title = input.title - }), - ) + mutationFn: (input: { session: NonNullable>; title: string }) => + createSessionMutation({ client: sdk().client, serverSync: serverSync() }).rename(input.session, input.title), + onSuccess: () => { setTitle("editing", false) }, onError: (err) => { @@ -693,17 +692,17 @@ export function MessageTimeline(props: { })) const shareSession = () => { - const id = sessionID() - if (!id || shareMutation.isPending) return + const session = info() + if (!session || shareMutation.isPending) return if (!shareEnabled()) return - shareMutation.mutate(id) + shareMutation.mutate(session) } const unshareSession = () => { - const id = sessionID() - if (!id || unshareMutation.isPending) return + const session = info() + if (!session || unshareMutation.isPending) return if (!shareEnabled()) return - unshareMutation.mutate(id) + unshareMutation.mutate(session) } const copyShareUrl = () => { const url = shareUrl() @@ -725,15 +724,6 @@ export function MessageTimeline(props: { }), ) } - const selectShareUrlText: JSX.EventHandler = (event) => { - const selection = window.getSelection() - if (!selection) return - const range = document.createRange() - range.selectNodeContents(event.currentTarget) - selection.removeAllRanges() - selection.addRange(range) - } - createEffect( on( sessionKey, @@ -788,7 +778,9 @@ export function MessageTimeline(props: { return } - titleMutation.mutate({ id, title: next }) + const session = info() + if (!session) return + titleMutation.mutate({ session, title: next }) } const exportSession = async (sessionID: string) => { @@ -818,63 +810,23 @@ export function MessageTimeline(props: { const session = sync().session.get(sessionID) if (!session) return false - const sessions = (sync().data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk() - .api.session.remove({ sessionID }) - .then(() => true) + const removed = await createSessionMutation({ client: sdk().client, serverSync: serverSync() }) + .delete(session) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(err), }) - return false + return undefined }) - if (!result) return false - - const removed = new Set([sessionID]) - const byParent = new Map() - for (const item of sync().data.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - sessionArchive.navigateAfterRemoval(sessionID, session.parentID, nextSession?.id) - - sync().set( - produce((draft) => { - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) + if (!removed) return false - for (const id of removed) { - sync().session.evict(id) - } - notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] }) + notifySessionTabsRemoved({ + server: params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server), + directory: sdk().directory, + sessionIDs: [...removed], + }) return true } @@ -886,56 +838,6 @@ export function MessageTimeline(props: { ) } - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo( - () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), - ) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - if (settings.general.newLayoutDesigns()) - return ( - - - - - - dialog.close()}> - {language.t("common.cancel")} - - - {language.t("session.delete.button")} - - - - ) - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } - const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID const turnDurationMs = (userMessageID: string) => { @@ -1551,7 +1453,12 @@ export function MessageTimeline(props: { dialog.show(() => )} + onSelect={() => dialog.show(() => ( + deleteSession(id)} + /> + ))} > {language.t("common.delete")} @@ -1560,258 +1467,68 @@ export function MessageTimeline(props: { } > - } + variant="ghost-muted" + size="large" + state={share.open || title.pendingShare ? "pressed" : undefined} + aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen || share.open || title.pendingShare} + ref={(el: HTMLButtonElement) => { + more = el + }} + /> + } open={title.menuOpen} - onOpenChange={(open) => { - setTitle("menuOpen", open) - if (open) return + onOpenChange={(open) => setTitle("menuOpen", open)} + onCloseAutoFocus={() => { + if (title.pendingRename) { + setTitle("pendingRename", false) + openTitleEditor() + return true + } + if (!title.pendingShare) return false + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + return true }} - > - } - variant="ghost-muted" - size="large" - state={share.open || title.pendingShare ? "pressed" : undefined} - aria-label={language.t("common.moreOptions")} - aria-expanded={title.menuOpen || share.open || title.pendingShare} - ref={(el: HTMLButtonElement) => { - more = el - }} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) - } - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - - { - setTitle({ pendingShare: true, menuOpen: false }) - }} - > - {language.t("session.share.action.share")}... - - - exportSession(id)}> - {language.t("common.export")}... - - void sessionArchive.archive(id)}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")}... - - - - + shareEnabled={shareEnabled()} + onRename={() => { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + onShare={() => setTitle({ pendingShare: true, menuOpen: false })} + onExport={() => exportSession(id)} + onArchive={() => void sessionArchive.archive(id)} + onDelete={() => dialog.show(() => ( + deleteSession(id)} + /> + ))} + /> - more} - placement="bottom-end" - gutter={settings.general.newLayoutDesigns() ? 6 : 4} - modal={false} + anchor={() => more} + url={shareUrl()} + publishing={shareMutation.isPending} + unpublishing={unshareMutation.isPending} onOpenChange={(open) => { if (open) setShare("dismiss", null) setShare("open", open) }} - > - - { - setShare({ dismiss: "escape", open: false }) - event.preventDefault() - event.stopPropagation() - }} - onPointerDownOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onFocusOutside={() => { - setShare({ dismiss: "outside", open: false }) - }} - onCloseAutoFocus={(event) => { - if (share.dismiss === "outside") event.preventDefault() - setShare("dismiss", null) - }} - > - -
-
- {language.t("session.share.popover.title")} -
-
- {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} -
-
-
- - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - - } - > -
- -
- - -
-
-
-
- - } - > -
-
- {language.t("session.share.popover.title")} -
-
- {shareUrl() - ? language.t("session.share.popover.description.shared") - : language.t("session.share.popover.description.unshared")} -
-
-
- - {shareMutation.isPending - ? language.t("session.share.action.publishing") - : language.t("session.share.action.publish")} - - } - > -
-
-
- {shareUrl()} -
- } - aria-label={language.t("session.share.copy.copyLink")} - onClick={copyShareUrl} - /> - } - aria-label={language.t("session.share.action.view")} - onClick={viewShare} - disabled={unshareMutation.isPending} - /> -
-
- - {unshareMutation.isPending - ? language.t("session.share.action.unpublishing") - : language.t("session.share.action.unpublish")} - -
-
-
-
-
-
-
-
+ onPublish={shareSession} + onUnpublish={unshareSession} + onCopy={copyShareUrl} + onView={viewShare} + /> )} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 18d5b172ff17..2bfc9ded1a0e 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -10,6 +10,7 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" +import { useServerSync } from "@/context/server-sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" @@ -21,6 +22,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionArchive } from "@/pages/session/session-archive" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" +import { createSessionMutation } from "@/utils/session-mutation" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -47,6 +49,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sdk = useSDK() const settings = useSettings() const sync = useSync() + const serverSync = useServerSync() const terminal = useTerminal() const layout = useLayout() const local = useLocal() @@ -197,9 +200,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - const url = await sdk() - .client.session.share({ sessionID }) - .then((res) => res.data?.share?.url) + const session = info() + if (!session) return + const url = await createSessionMutation({ client: sdk().client, serverSync: serverSync() }) + .publish(session) .catch(() => undefined) if (!url) { showToast({ @@ -217,8 +221,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return - await sdk() - .client.session.unshare({ sessionID }) + const session = info() + if (!session) return + await createSessionMutation({ client: sdk().client, serverSync: serverSync() }) + .unpublish(session) .then(() => showToast({ title: language.t("toast.session.unshare.success.title"), diff --git a/packages/app/src/utils/session-delete.ts b/packages/app/src/utils/session-delete.ts new file mode 100644 index 000000000000..f0754afbf70e --- /dev/null +++ b/packages/app/src/utils/session-delete.ts @@ -0,0 +1,21 @@ +export function sessionRemovalIDs(sessions: ReadonlyArray<{ id: string; parentID?: string }>, sessionID: string) { + const removed = new Set([sessionID]) + const children = new Map() + sessions.forEach((session) => { + if (!session.parentID) return + const list = children.get(session.parentID) + if (list) list.push(session.id) + else children.set(session.parentID, [session.id]) + }) + const pending = [sessionID] + while (pending.length) { + const parentID = pending.pop() + if (!parentID) continue + children.get(parentID)?.forEach((id) => { + if (removed.has(id)) return + removed.add(id) + pending.push(id) + }) + } + return removed +} diff --git a/packages/app/src/utils/session-mutation.test.ts b/packages/app/src/utils/session-mutation.test.ts new file mode 100644 index 000000000000..4b46d2a51af6 --- /dev/null +++ b/packages/app/src/utils/session-mutation.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2/client" +import type { ServerSync } from "@/context/server-sync" +import { applySession, createSessionMutation, removeSession, type SessionMutationSync } from "./session-mutation" + +const session = { id: "ses_1", directory: "/project", title: "Old", time: { created: 1, updated: 1 } } as Session + +function fixture(input: { fail?: boolean; shareURL?: string } = {}) { + const state = { session: [session] } + const remembered: Session[] = [] + const evicted: string[] = [] + const events: Array<{ type: string; sessionID: string; title?: string }> = [] + const client = { + session: { + update: async () => { + if (input.fail) throw new Error("failed") + }, + delete: async () => { + if (input.fail) throw new Error("failed") + }, + share: async () => ({ data: input.shareURL ? { ...session, share: { url: input.shareURL } } : undefined }), + unshare: async () => { + if (input.fail) throw new Error("failed") + return { data: { ...session, share: undefined } } + }, + }, + } + const serverSync: SessionMutationSync = { + session: { + remember(value: Session) { + remembered.push(value) + return value + }, + evict(id: string) { + evicted.push(id) + }, + }, + peek() { + return [ + state, + (update: unknown) => { + if (typeof update === "function") { + (update as (draft: { session: Session[] }) => void)(state) + } + }, + ] as ReturnType + }, + homeSessions: { + apply(event) { + events.push({ type: event.type, sessionID: event.properties.sessionID, title: event.properties.info?.title }) + }, + }, + } + return { mutation: createSessionMutation({ client, serverSync }), serverSync, remembered, evicted, events, state } +} + +describe("session mutation", () => { + test("applySession and removeSession commit cache directly", () => { + const result = fixture() + applySession(result.serverSync, { ...session, title: "Applied" }) + expect(result.remembered[0]?.title).toBe("Applied") + expect(result.state.session[0]?.title).toBe("Applied") + expect(result.events).toEqual([{ type: "session.updated", sessionID: session.id, title: "Applied" }]) + + removeSession(result.serverSync, session.id, session.directory, ["ses_child"]) + expect(result.evicted).toEqual(["ses_1", "ses_child"]) + expect(result.state.session).toEqual([]) + expect(result.events.slice(1)).toEqual([ + { type: "session.deleted", sessionID: "ses_1", title: undefined }, + { type: "session.deleted", sessionID: "ses_child", title: undefined }, + ]) + }) + + test("writes rename only after API success", async () => { + const result = fixture() + await result.mutation.rename(session, "New") + expect(result.remembered[0]?.title).toBe("New") + expect(result.state.session[0]?.title).toBe("New") + expect(result.events).toEqual([{ type: "session.updated", sessionID: session.id, title: "New" }]) + }) + + test("does not write failed mutation", async () => { + const result = fixture({ fail: true }) + await expect(result.mutation.rename(session, "New")).rejects.toThrow("failed") + expect(result.remembered).toEqual([]) + expect(result.events).toEqual([]) + }) + + test("removes descendants after delete", async () => { + const result = fixture() + result.state.session.push({ ...session, id: "ses_2", parentID: session.id }) + await result.mutation.delete(session) + expect(result.evicted).toEqual(["ses_1", "ses_2"]) + expect(result.state.session).toEqual([]) + expect(result.events).toEqual([ + { type: "session.deleted", sessionID: "ses_1", title: undefined }, + { type: "session.deleted", sessionID: "ses_2", title: undefined }, + ]) + }) + + test("archives, publishes, and unpublishes through shared commits", async () => { + const result = fixture({ shareURL: "https://share" }) + await result.mutation.publish(session) + await result.mutation.unpublish({ ...session, share: { url: "https://share" } }) + await result.mutation.archive(session) + expect(result.events.map((event) => event.type)).toEqual([ + "session.updated", + "session.updated", + "session.created", + "session.deleted", + ]) + expect(result.events[0]?.title).toBe("Old") + expect(result.state.session.map((item) => item.id)).toEqual(["ses_2"]) + expect(result.evicted).toEqual(["ses_1"]) + }) +}) diff --git a/packages/app/src/utils/session-mutation.ts b/packages/app/src/utils/session-mutation.ts new file mode 100644 index 000000000000..1adcfcac24dd --- /dev/null +++ b/packages/app/src/utils/session-mutation.ts @@ -0,0 +1,89 @@ +import type { ServerSync } from "@/context/server-sync" +import type { Session } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { produce } from "solid-js/store" +import { normalizeSessionInfo } from "./session" +import { sessionRemovalIDs } from "./session-delete" + +export type SessionMutationClient = { + session: { + update(input: { sessionID: string; title?: string; directory?: string; time?: { archived: number } }): Promise + delete(input: { sessionID: string; directory: string }): Promise + share(input: { sessionID: string }): Promise<{ data?: Session | null }> + unshare(input: { sessionID: string }): Promise<{ data?: Session | null }> + } +} + +export type SessionMutationSync = { + session: Pick + homeSessions: Pick + peek: ServerSync["peek"] +} + +export function applySession( + serverSync: SessionMutationSync, + session: Session, + type: "session.created" | "session.updated" = "session.updated", +) { + serverSync.session.remember(session) + const [, setStore] = serverSync.peek(session.directory, { bootstrap: false }) + setStore( + produce((draft) => { + const index = draft.session.findIndex((item) => item.id === session.id) + if (index === -1) draft.session.push(session) + if (index !== -1) draft.session[index] = session + }), + ) + serverSync.homeSessions.apply({ type, properties: { sessionID: session.id, info: session } }) + return session +} + +export function removeSession( + serverSync: SessionMutationSync, + sessionID: string, + directory: string, + descendantIDs?: Iterable, +) { + const ids = new Set(descendantIDs ? [sessionID, ...descendantIDs] : [sessionID]) + const [, setStore] = serverSync.peek(directory, { bootstrap: false }) + setStore(produce((draft) => (draft.session = draft.session.filter((item) => !ids.has(item.id))))) + ids.forEach((id) => { + serverSync.session.evict(id) + serverSync.homeSessions.apply({ type: "session.deleted", properties: { sessionID: id } }) + }) + return ids +} + +export function createSessionMutation(input: { client: SessionMutationClient; serverSync: SessionMutationSync }) { + return { + async rename(session: Session, title: string) { + await input.client.session.update({ sessionID: session.id, title }) + return applySession(input.serverSync, { ...session, title }) + }, + async archive(session: Session) { + await input.client.session.update({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) + removeSession(input.serverSync, session.id, session.directory) + }, + async delete(session: Session) { + const [store] = input.serverSync.peek(session.directory, { bootstrap: false }) + const ids = sessionRemovalIDs([...store.session], session.id) + await input.client.session.delete({ sessionID: session.id, directory: session.directory }) + removeSession(input.serverSync, session.id, session.directory, ids) + return ids + }, + async publish(session: Session) { + const data = (await input.client.session.share({ sessionID: session.id })).data + const url = data?.share?.url + if (!data || !url) throw new Error("Session share URL missing") + return applySession(input.serverSync, data).share!.url + }, + async unpublish(session: Session) { + await input.client.session.unshare({ sessionID: session.id }) + return applySession(input.serverSync, { ...session, share: undefined }) + }, + } +} diff --git a/packages/app/src/utils/session-share.ts b/packages/app/src/utils/session-share.ts new file mode 100644 index 000000000000..134307c90acc --- /dev/null +++ b/packages/app/src/utils/session-share.ts @@ -0,0 +1,16 @@ +export type SessionShareClient = { + session: { + share: (input: { sessionID: string }) => Promise<{ data?: { share?: { url?: string } } | null }> + unshare: (input: { sessionID: string }) => Promise + } +} + +export async function publishSession(client: SessionShareClient, sessionID: string) { + const url = (await client.session.share({ sessionID })).data?.share?.url + if (!url) throw new Error("Session share URL missing") + return url +} + +export async function unpublishSession(client: SessionShareClient, sessionID: string) { + await client.session.unshare({ sessionID }) +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..45c221566eb8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -808,7 +808,7 @@ const layer: Layer.Layer< }) const setShare = Effect.fn("Session.setShare")(function* (input: { sessionID: SessionID; share: Info["share"] }) { - yield* patch(input.sessionID, { share: input.share ?? null, time: { updated: Date.now() } }).pipe(Effect.orDie) + yield* patch(input.sessionID, { share: input.share ?? null }).pipe(Effect.orDie) }) const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: {