-
-
- {
+ 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