Sfoglia il codice sorgente

refactor(app): extract session timeline controller

Brendan Allan 2 settimane fa
parent
commit
99cae9766b

+ 26 - 0
packages/app/src/pages/session/timeline/controller-projection.ts

@@ -0,0 +1,26 @@
+export function timelineChildTitle(input: {
+  parentID?: string
+  taskDescription?: string
+  title?: string
+  fallback: string
+}) {
+  if (!input.parentID) return input.title ?? ""
+  if (input.taskDescription) return input.taskDescription
+  return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
+}
+
+export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
+  const removed = new Set([sessionID])
+  const byParent = Map.groupBy(
+    sessions.filter((session) => session.parentID),
+    (session) => session.parentID!,
+  )
+  const visit = (id: string) =>
+    byParent.get(id)?.forEach((child) => {
+      if (removed.has(child.id)) return
+      removed.add(child.id)
+      visit(child.id)
+    })
+  visit(sessionID)
+  return removed
+}

+ 30 - 0
packages/app/src/pages/session/timeline/controller.test.ts

@@ -0,0 +1,30 @@
+import { describe, expect, test } from "bun:test"
+import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
+
+describe("timeline controller", () => {
+  test("projects child titles from task descriptions and session fallbacks", () => {
+    expect(timelineChildTitle({ title: "Root", fallback: "New session" })).toBe("Root")
+    expect(
+      timelineChildTitle({ parentID: "parent", taskDescription: "Investigate timeline", fallback: "New session" }),
+    ).toBe("Investigate timeline")
+    expect(
+      timelineChildTitle({ parentID: "parent", title: "Fallback (@build subagent)", fallback: "New session" }),
+    ).toBe("Fallback")
+    expect(timelineChildTitle({ parentID: "parent", fallback: "New session" })).toBe("New session")
+  })
+
+  test("collects the removed session and all descendants", () => {
+    const removed = timelineRemovedSessionIDs(
+      [
+        { id: "root" },
+        { id: "child", parentID: "root" },
+        { id: "grandchild", parentID: "child" },
+        { id: "sibling", parentID: "root" },
+        { id: "unrelated" },
+      ],
+      "root",
+    )
+
+    expect([...removed]).toEqual(["root", "child", "grandchild", "sibling"])
+  })
+})

+ 330 - 0
packages/app/src/pages/session/timeline/controller.tsx

@@ -0,0 +1,330 @@
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
+import { Button } from "@opencode-ai/ui/button"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { useNavigate } from "@solidjs/router"
+import { createEffect, createMemo, on, type Accessor } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useServerSDK } from "@/context/server-sdk"
+import { useSettings } from "@/context/settings"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useTabs } from "@/context/tabs"
+import { useSessionKey } from "@/pages/session/session-layout"
+import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
+import { sessionTitle } from "@/utils/session-title"
+import { showToast } from "@/utils/toast"
+import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
+import { createTimelineProjection } from "./projection"
+
+const emptyMessages: Message[] = []
+const emptyParts: Part[] = []
+const idle = { type: "idle" as const }
+
+const taskDescription = (part: Part, sessionID: string): string | undefined => {
+  if (part.type !== "tool" || part.tool !== "task") return undefined
+  const metadata = "metadata" in part.state ? part.state.metadata : undefined
+  if (metadata?.sessionId !== sessionID) return undefined
+  const value = part.state.input?.description
+  if (typeof value === "string" && value) return value
+  return undefined
+}
+
+export function createTimelineController(input: { userMessages: Accessor<UserMessage[]> }) {
+  const navigate = useNavigate()
+  const serverSDK = useServerSDK()
+  const sdk = useSDK()
+  const sync = useSync()
+  const settings = useSettings()
+  const tabs = useTabs()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const platform = usePlatform()
+  const { params, sessionKey } = useSessionKey()
+  const sessionID = createMemo(() => params.id)
+  const status = createMemo(() => {
+    const id = sessionID()
+    if (!id) return idle
+    return sync().data.session_status[id] ?? idle
+  })
+  const messages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
+  const projectedMessages = createMemo(() => {
+    const id = sessionID()
+    if (!id) return []
+    const visible = new Set(input.userMessages().map((message) => message.id))
+    const boundary = messages().find((message) => message.role === "user" && !visible.has(message.id))?.id
+    const projected = sync().data.session_message[id] ?? []
+    return boundary ? projected.filter((message) => message.id < boundary) : projected
+  })
+  const info = createMemo(() => {
+    const id = sessionID()
+    return id ? sync().session.get(id) : undefined
+  })
+  const titleValue = createMemo(() => info()?.title)
+  const titleLabel = createMemo(() => sessionTitle(titleValue()))
+  const shareUrl = createMemo(() => info()?.share?.url)
+  const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
+  const parentID = createMemo(() => info()?.parentID)
+  const parent = createMemo(() => {
+    const id = parentID()
+    return id ? sync().session.get(id) : undefined
+  })
+  const parentMessages = createMemo(() => {
+    const id = parentID()
+    return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
+  })
+  const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
+  const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
+  const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
+  const childTaskDescription = createMemo(() => {
+    const id = sessionID()
+    if (!id) return undefined
+    return parentMessages()
+      .flatMap((message) => parts(message.id))
+      .map((item) => taskDescription(item, id))
+      .findLast((value): value is string => !!value)
+  })
+  const childTitle = createMemo(() => {
+    return timelineChildTitle({
+      parentID: parentID(),
+      taskDescription: childTaskDescription(),
+      title: titleLabel(),
+      fallback: language.t("command.session.new"),
+    })
+  })
+  const showHeader = createMemo(() => !!(titleValue() || parentID()))
+  const projection = createTimelineProjection({
+    messages,
+    userMessages: input.userMessages,
+    sessionMessages: projectedMessages,
+    parts,
+    status,
+    showReasoningSummaries: settings.general.showReasoningSummaries,
+    inlineComments: settings.general.newLayoutDesigns,
+  })
+  const [pending, setPending] = createStore({ rename: false, share: false, unshare: false })
+
+  const errorMessage = (error: unknown) => {
+    if (error && typeof error === "object" && "data" in error) {
+      const data = error.data
+      if (data && typeof data === "object" && "message" in data && typeof data.message === "string") return data.message
+    }
+    if (error instanceof Error) return error.message
+    return language.t("common.requestFailed")
+  }
+  const rename = async (title: string) => {
+    const id = sessionID()
+    if (!id || pending.rename) return false
+    const next = title.trim()
+    if (!next || next === (titleLabel() ?? "")) return true
+    setPending("rename", true)
+    const success = await sdk()
+      .api.session.rename({ sessionID: id, title: next })
+      .then(() => true)
+      .catch((error) => {
+        showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
+        return false
+      })
+    setPending("rename", false)
+    if (!success) return false
+    sync().set(
+      produce((draft) => {
+        const index = draft.session.findIndex((session) => session.id === id)
+        if (index !== -1) draft.session[index].title = next
+      }),
+    )
+    return true
+  }
+  const share = async () => {
+    const id = sessionID()
+    if (!id || pending.share || !shareEnabled()) return
+    setPending("share", true)
+    await serverSDK()
+      .client.session.share({ sessionID: id })
+      .catch((error) => console.error("Failed to share session", error))
+    setPending("share", false)
+  }
+  const unshare = async () => {
+    const id = sessionID()
+    if (!id || pending.unshare || !shareEnabled()) return
+    setPending("unshare", true)
+    await serverSDK()
+      .client.session.unshare({ sessionID: id })
+      .catch((error) => console.error("Failed to unshare session", error))
+    setPending("unshare", false)
+  }
+  const href = (id: string) =>
+    params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
+  const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
+    if (params.id !== id) return
+    if (parent) return navigate(href(parent))
+    if (next) return navigate(href(next))
+    if (params.serverKey)
+      return tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
+    navigate(`/${params.dir}/session`)
+  }
+  const archive = async (id: string) => {
+    const session = sync().session.get(id)
+    if (!session || (await sdk().protocol) !== "v1") return
+    const index = sync().data.session.findIndex((item) => item.id === id)
+    const next = index === -1 ? undefined : (sync().data.session[index + 1] ?? sync().data.session[index - 1])
+    await sdk()
+      .client.session.update({ sessionID: id, directory: sdk().directory, time: { archived: Date.now() } })
+      .then(() => {
+        sync().set(
+          produce((draft) => {
+            const index = draft.session.findIndex((item) => item.id === id)
+            if (index !== -1) draft.session.splice(index, 1)
+          }),
+        )
+        sync().session.evict(id)
+        void navigateAfterRemoval(id, session.parentID, next?.id)
+        notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [id] })
+      })
+      .catch((error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }))
+  }
+  const remove = async (id: string) => {
+    const session = sync().session.get(id)
+    if (!session) return false
+    const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
+    const index = sessions.findIndex((item) => item.id === id)
+    const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+    const success = await sdk()
+      .api.session.remove({ sessionID: id })
+      .then(() => true)
+      .catch((error) => {
+        showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
+        return false
+      })
+    if (!success) return false
+    const removed = timelineRemovedSessionIDs(sync().data.session, id)
+    void navigateAfterRemoval(id, session.parentID, next?.id)
+    sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
+    removed.forEach((sessionID) => sync().session.evict(sessionID))
+    notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
+    return true
+  }
+
+  function DeleteDialog(props: { sessionID: string }) {
+    const name = createMemo(
+      () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
+    )
+    const confirm = async () => {
+      await remove(props.sessionID)
+      dialog.close()
+    }
+    if (settings.general.newLayoutDesigns())
+      return (
+        <DialogV2 fit>
+          <DialogHeader hideClose>
+            <DialogTitleGroup
+              title={language.t("session.delete.title")}
+              description={language.t("session.delete.confirm", { name: name() })}
+            />
+          </DialogHeader>
+          <DialogFooter>
+            <ButtonV2 variant="ghost" onClick={() => dialog.close()}>
+              {language.t("common.cancel")}
+            </ButtonV2>
+            <ButtonV2 variant="danger" onClick={confirm}>
+              {language.t("session.delete.button")}
+            </ButtonV2>
+          </DialogFooter>
+        </DialogV2>
+      )
+    return (
+      <Dialog title={language.t("session.delete.title")} fit>
+        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+          <div class="flex flex-col gap-1">
+            <span class="text-14-regular text-text-strong">
+              {language.t("session.delete.confirm", { name: name() })}
+            </span>
+          </div>
+          <div class="flex justify-end gap-2">
+            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+              {language.t("common.cancel")}
+            </Button>
+            <Button variant="primary" size="large" onClick={confirm}>
+              {language.t("session.delete.button")}
+            </Button>
+          </div>
+        </div>
+      </Dialog>
+    )
+  }
+
+  createEffect(
+    on(
+      () => [parentID(), childTaskDescription()] as const,
+      ([id, description]) => {
+        if (!id || description || sync().data.message[id] !== undefined) return
+        void sync().session.sync(id)
+      },
+      { defer: true },
+    ),
+  )
+
+  return {
+    data: {
+      sessionKey,
+      sessionID,
+      status,
+      titleValue,
+      titleLabel,
+      shareUrl,
+      shareEnabled,
+      parentID,
+      parentTitle,
+      childTitle,
+      showHeader,
+      parts,
+      part,
+      projection,
+      newLayoutDesigns: settings.general.newLayoutDesigns,
+      showReasoningSummaries: settings.general.showReasoningSummaries,
+      shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
+      editToolPartsExpanded: settings.general.editToolPartsExpanded,
+    },
+    pending: {
+      rename: () => pending.rename,
+      share: () => pending.share,
+      unshare: () => pending.unshare,
+    },
+    action: {
+      rename,
+      share,
+      unshare,
+      archive,
+      showDelete: (id: string) => dialog.show(() => <DeleteDialog sessionID={id} />),
+      navigateParent: () => {
+        const id = parentID()
+        if (id) navigate(href(id))
+      },
+      viewShare: () => {
+        const url = shareUrl()
+        if (url) platform.openLink(url)
+      },
+      copyShareUrl: async () => {
+        const url = shareUrl()
+        if (!url) return
+        await navigator.clipboard.writeText(url).then(
+          () =>
+            showToast({
+              variant: "success",
+              icon: "circle-check",
+              title: language.t("session.share.copy.copied"),
+              description: url,
+            }),
+          (error) => showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) }),
+        )
+      },
+    },
+  }
+}
+
+export type TimelineController = ReturnType<typeof createTimelineController>

+ 92 - 449
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -11,10 +11,8 @@ import {
   type Accessor,
   type JSX,
 } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
-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"
@@ -35,8 +33,6 @@ 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"
@@ -45,43 +41,22 @@ 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 {
-  AssistantMessage,
-  Message as MessageType,
-  Part as PartType,
-  ToolPart,
-  UserMessage,
-} from "@opencode-ai/sdk/v2"
-import { showToast } from "@/utils/toast"
+import type { AssistantMessage, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
 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 { 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 { useServerSDK } from "@/context/server-sdk"
-import { usePlatform } from "@/context/platform"
-import { useSettings } from "@/context/settings"
-import { useTabs } from "@/context/tabs"
-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 { sessionTitle } from "@/utils/session-title"
 import { scheduleConnectedMeasure } from "./measure"
 import { observeElementOffsetReconnectAware } from "./observe-element-offset"
-import { createTimelineProjection } from "./projection"
 import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
 import { filterVirtualIndexes } from "./virtual-items"
+import { createTimelineController, type TimelineController } from "./controller"
 
-const emptyMessages: MessageType[] = []
-const emptyParts: PartType[] = []
 const emptyTools: ToolPart[] = []
 const emptyAssistantMessages: AssistantMessage[] = []
-const idle = { type: "idle" as const }
 
 type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
 type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -89,14 +64,6 @@ type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<Timel
 const timelineFallbackItemSize = 60
 const timelineCache = new Map<string, { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }>()
 
-const taskDescription = (part: PartType, sessionID: string) => {
-  if (part.type !== "tool" || part.tool !== "task") return
-  const metadata = "metadata" in part.state ? part.state.metadata : undefined
-  if (metadata?.sessionId !== sessionID) return
-  const value = part.state.input?.description
-  if (typeof value === "string" && value) return value
-}
-
 const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
   const current = target instanceof Element ? target : undefined
   const nested = current?.closest("[data-scrollable]")
@@ -237,7 +204,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
   )
 }
 
-export function MessageTimeline(props: {
+type MessageTimelineProps = {
   actions?: UserActions
   scroll: { overflow: boolean; bottom: boolean; jump: boolean }
   onResumeScroll: () => void
@@ -257,88 +224,42 @@ export function MessageTimeline(props: {
   setRevealMessage?: (fn: (id: string) => void) => void
   setScrollToEnd?: (fn: () => void) => void
   setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
-}) {
-  let touchGesture: number | undefined
+}
 
-  const navigate = useNavigate()
-  const serverSDK = useServerSDK()
-  const sdk = useSDK()
-  const sync = useSync()
-  const settings = useSettings()
-  const tabs = useTabs()
-  const dialog = useDialog()
+export function MessageTimeline(props: MessageTimelineProps) {
+  const controller = createTimelineController({ userMessages: () => props.userMessages })
+  return (
+    <MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
+  )
+}
+
+function MessageTimelineView(
+  props: MessageTimelineProps & {
+    data: TimelineController["data"]
+    action: TimelineController["action"]
+    pending: TimelineController["pending"]
+  },
+) {
+  let touchGesture: number | undefined
   const language = useLanguage()
-  const { params, sessionKey } = useSessionKey()
-  const ownerSessionKey = sessionKey()
+  const ownerSessionKey = props.data.sessionKey()
   const cached = timelineCache.get(ownerSessionKey)
   const initialMeasurements = cached?.measurements
   const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
-  const platform = usePlatform()
 
   const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
-  const sessionID = createMemo(() => params.id)
-  const sessionStatus = createMemo(() => {
-    const id = sessionID()
-    if (!id) return idle
-    return sync().data.session_status[id] ?? idle
-  })
-  const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
-  const projectedMessages = createMemo(() => {
-    const id = sessionID()
-    if (!id) return []
-    const visible = new Set(props.userMessages.map((message) => message.id))
-    const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id
-    const messages = sync().data.session_message[id] ?? []
-    return boundary ? messages.filter((message) => message.id < boundary) : messages
-  })
-  const info = createMemo(() => {
-    const id = sessionID()
-    if (!id) return
-    return sync().session.get(id)
-  })
-  const titleValue = createMemo(() => info()?.title)
-  const titleLabel = createMemo(() => sessionTitle(titleValue()))
-  const shareUrl = createMemo(() => info()?.share?.url)
-  const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
-  const parentID = createMemo(() => info()?.parentID)
-  const parent = createMemo(() => {
-    const id = parentID()
-    if (!id) return
-    return sync().session.get(id)
-  })
-  const parentMessages = createMemo(() => {
-    const id = parentID()
-    if (!id) return emptyMessages
-    return sync().data.message[id] ?? emptyMessages
-  })
-  const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
-  const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
-  const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
-  const childTaskDescription = createMemo(() => {
-    const id = sessionID()
-    if (!id) return
-    return parentMessages()
-      .flatMap((message) => getMsgParts(message.id))
-      .map((part) => taskDescription(part, id))
-      .findLast((value): value is string => !!value)
-  })
-  const childTitle = createMemo(() => {
-    if (!parentID()) return titleLabel() ?? ""
-    if (childTaskDescription()) return childTaskDescription()
-    const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
-    if (value) return value
-    return language.t("command.session.new")
-  })
-  const showHeader = createMemo(() => !!(titleValue() || parentID()))
-  const projection = createTimelineProjection({
-    messages: sessionMessages,
-    userMessages: () => props.userMessages,
-    sessionMessages: projectedMessages,
-    parts: getMsgParts,
-    status: sessionStatus,
-    showReasoningSummaries: settings.general.showReasoningSummaries,
-    inlineComments: settings.general.newLayoutDesigns,
-  })
+  const sessionID = props.data.sessionID
+  const sessionStatus = props.data.status
+  const titleLabel = props.data.titleLabel
+  const shareUrl = props.data.shareUrl
+  const shareEnabled = props.data.shareEnabled
+  const parentID = props.data.parentID
+  const parentTitle = props.data.parentTitle
+  const childTitle = props.data.childTitle
+  const showHeader = props.data.showHeader
+  const getMsgParts = props.data.parts
+  const getMsgPart = props.data.part
+  const projection = props.data.projection
   const activeMessageID = projection.activeMessageID
   const assistantMessagesByParent = projection.assistantMessagesByParent
   const lastAssistantGroupKey = projection.lastAssistantGroupKey
@@ -528,9 +449,9 @@ export function MessageTimeline(props: {
     virtualizer.scrollToEnd()
   }
 
-  let measuredSessionKey = sessionKey()
+  let measuredSessionKey = props.data.sessionKey()
   createEffect(() => {
-    const key = sessionKey()
+    const key = props.data.sessionKey()
     timelineRows().length
     if (measuredSessionKey !== key) {
       measuredSessionKey = key
@@ -643,88 +564,6 @@ export function MessageTimeline(props: {
     props.setScrollRef(undefined)
   })
 
-  const viewShare = () => {
-    const url = shareUrl()
-    if (!url) return
-    platform.openLink(url)
-  }
-
-  const errorMessage = (err: unknown) => {
-    if (err && typeof err === "object" && "data" in err) {
-      const data = (err as { data?: { message?: string } }).data
-      if (data?.message) return data.message
-    }
-    if (err instanceof Error) return err.message
-    return language.t("common.requestFailed")
-  }
-
-  const shareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
-    onError: (err) => {
-      console.error("Failed to share session", err)
-    },
-  }))
-
-  const unshareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
-    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
-        }),
-      )
-      setTitle("editing", false)
-    },
-    onError: (err) => {
-      showToast({
-        title: language.t("common.requestFailed"),
-        description: errorMessage(err),
-      })
-    },
-  }))
-
-  const shareSession = () => {
-    const id = sessionID()
-    if (!id || shareMutation.isPending) return
-    if (!shareEnabled()) return
-    shareMutation.mutate(id)
-  }
-
-  const unshareSession = () => {
-    const id = sessionID()
-    if (!id || unshareMutation.isPending) return
-    if (!shareEnabled()) return
-    unshareMutation.mutate(id)
-  }
-  const copyShareUrl = () => {
-    const url = shareUrl()
-    if (!url) return
-    void navigator.clipboard
-      .writeText(url)
-      .then(() =>
-        showToast({
-          variant: "success",
-          icon: "circle-check",
-          title: language.t("session.share.copy.copied"),
-          description: url,
-        }),
-      )
-      .catch((err: unknown) =>
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        }),
-      )
-  }
   const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
     const selection = window.getSelection()
     if (!selection) return
@@ -736,7 +575,7 @@ export function MessageTimeline(props: {
 
   createEffect(
     on(
-      sessionKey,
+      props.data.sessionKey,
       () =>
         setTitle({
           draft: "",
@@ -749,18 +588,6 @@ export function MessageTimeline(props: {
     ),
   )
 
-  createEffect(
-    on(
-      () => [parentID(), childTaskDescription()] as const,
-      ([id, description]) => {
-        if (!id || description) return
-        if (sync().data.message[id] !== undefined) return
-        void sync().session.sync(id)
-      },
-      { defer: true },
-    ),
-  )
-
   const openTitleEditor = () => {
     if (!sessionID() || parentID()) return
     setTitle({ editing: true, draft: titleLabel() ?? "" })
@@ -772,193 +599,12 @@ export function MessageTimeline(props: {
   }
 
   const closeTitleEditor = () => {
-    if (titleMutation.isPending) return
+    if (props.pending.rename()) return
     setTitle("editing", false)
   }
 
-  const saveTitleEditor = () => {
-    const id = sessionID()
-    if (!id) return
-    if (titleMutation.isPending) return
-
-    const next = title.draft.trim()
-    if (!next || next === (titleLabel() ?? "")) {
-      setTitle("editing", false)
-      return
-    }
-
-    titleMutation.mutate({ id, title: next })
-  }
-
-  const navigateAfterSessionRemoval = (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 archiveSession = 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() } })
-      .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)
-        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-        notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [sessionID] })
-      })
-      .catch((err) => {
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        })
-      })
-  }
-
-  const deleteSession = async (sessionID: string) => {
-    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)
-      .catch((err) => {
-        showToast({
-          title: language.t("session.delete.failed.title"),
-          description: errorMessage(err),
-        })
-        return false
-      })
-
-    if (!result) return false
-
-    const removed = new Set<string>([sessionID])
-    const byParent = new Map<string, string[]>()
-    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)
-      }
-    }
-
-    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
-
-    sync().set(
-      produce((draft) => {
-        draft.session = draft.session.filter((s) => !removed.has(s.id))
-      }),
-    )
-
-    for (const id of removed) {
-      sync().session.evict(id)
-    }
-    notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
-    return true
-  }
-
-  const navigateParent = () => {
-    const id = parentID()
-    if (!id) return
-    navigate(
-      params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
-    )
-  }
-
-  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 (
-        <DialogV2 fit>
-          <DialogHeader hideClose>
-            <DialogTitleGroup
-              title={language.t("session.delete.title")}
-              description={language.t("session.delete.confirm", { name: name() })}
-            />
-          </DialogHeader>
-          <DialogFooter>
-            <ButtonV2 variant="ghost" onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </ButtonV2>
-            <ButtonV2 variant="danger" onClick={handleDelete}>
-              {language.t("session.delete.button")}
-            </ButtonV2>
-          </DialogFooter>
-        </DialogV2>
-      )
-
-    return (
-      <Dialog title={language.t("session.delete.title")} fit>
-        <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
-          <div class="flex flex-col gap-1">
-            <span class="text-14-regular text-text-strong">
-              {language.t("session.delete.confirm", { name: name() })}
-            </span>
-          </div>
-          <div class="flex justify-end gap-2">
-            <Button variant="ghost" size="large" onClick={() => dialog.close()}>
-              {language.t("common.cancel")}
-            </Button>
-            <Button variant="primary" size="large" onClick={handleDelete}>
-              {language.t("session.delete.button")}
-            </Button>
-          </div>
-        </div>
-      </Dialog>
-    )
+  const saveTitleEditor = async () => {
+    if (await props.action.rename(title.draft)) setTitle("editing", false)
   }
 
   const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
@@ -1037,7 +683,7 @@ export function MessageTimeline(props: {
     const defaultOpen = createMemo(() => {
       const item = part()
       if (!item) return
-      return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
+      return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
     })
 
     return (
@@ -1050,7 +696,7 @@ export function MessageTimeline(props: {
                 message={message()}
                 showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
                 turnDurationMs={turnDurationMs(row().userMessageID)}
-                useV2Actions={settings.general.newLayoutDesigns()}
+                useV2Actions={props.data.newLayoutDesigns()}
                 defaultOpen={defaultOpen()}
                 toolOpen={toolOpen[part().id] ?? defaultOpen()}
                 onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -1113,8 +759,8 @@ export function MessageTimeline(props: {
                       <div
                         classList={{
                           "shrink-0 max-w-[260px] rounded-[6px] border-border-weak-base bg-background-stronger px-2.5 py-2": true,
-                          "border-[0.5px]": settings.general.newLayoutDesigns(),
-                          border: !settings.general.newLayoutDesigns(),
+                          "border-[0.5px]": props.data.newLayoutDesigns(),
+                          border: !props.data.newLayoutDesigns(),
                         }}
                       >
                         <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
@@ -1149,7 +795,7 @@ export function MessageTimeline(props: {
           if (m?.role === "user") return m
         })
         const messageComments = createMemo(() => {
-          if (!settings.general.newLayoutDesigns()) return []
+          if (!props.data.newLayoutDesigns()) return []
           return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
         })
         return (
@@ -1162,7 +808,7 @@ export function MessageTimeline(props: {
                       message={message()}
                       parts={getMsgParts(userMessageRow().userMessageID)}
                       actions={props.actions}
-                      useV2Actions={settings.general.newLayoutDesigns()}
+                      useV2Actions={props.data.newLayoutDesigns()}
                       comments={messageComments()}
                     />
                   </div>
@@ -1210,7 +856,7 @@ export function MessageTimeline(props: {
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
               <TimelineThinkingRow
                 reasoningHeading={thinkingRow().reasoningHeading}
-                showReasoningSummaries={settings.general.showReasoningSummaries()}
+                showReasoningSummaries={props.data.showReasoningSummaries()}
               />
             </div>
           </TimelineRowFrame>
@@ -1326,16 +972,16 @@ export function MessageTimeline(props: {
       <div
         class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
         classList={{
-          "bottom-8": settings.general.newLayoutDesigns(),
-          "bottom-6": !settings.general.newLayoutDesigns(),
+          "bottom-8": props.data.newLayoutDesigns(),
+          "bottom-6": !props.data.newLayoutDesigns(),
           "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
           "opacity-0 translate-y-2 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
-          "scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && settings.general.newLayoutDesigns(),
-          "scale-95": (!props.scroll.overflow || !props.scroll.jump) && !settings.general.newLayoutDesigns(),
+          "scale-[0.8]": (!props.scroll.overflow || !props.scroll.jump) && props.data.newLayoutDesigns(),
+          "scale-95": (!props.scroll.overflow || !props.scroll.jump) && !props.data.newLayoutDesigns(),
         }}
       >
         <Show
-          when={settings.general.newLayoutDesigns()}
+          when={props.data.newLayoutDesigns()}
           fallback={
             <button
               type="button"
@@ -1398,22 +1044,22 @@ export function MessageTimeline(props: {
             classList={{
               "sticky top-0 z-30": true,
               "bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)]":
-                settings.general.newLayoutDesigns(),
+                props.data.newLayoutDesigns(),
               "bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]":
-                !settings.general.newLayoutDesigns(),
+                !props.data.newLayoutDesigns(),
               "w-full": true,
               "pb-4": true,
               "pr-3": true,
-              "pl-2.5": settings.general.newLayoutDesigns(),
-              "pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
-              "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
+              "pl-2.5": props.data.newLayoutDesigns(),
+              "pl-2 md:pl-4": !props.data.newLayoutDesigns(),
+              "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !props.data.newLayoutDesigns(),
             }}
           >
             <div class="h-12 w-full flex items-center justify-between gap-2">
               <div
                 classList={{
                   "flex items-center gap-1 min-w-0 flex-1": true,
-                  "pr-3": !settings.general.newLayoutDesigns(),
+                  "pr-3": !props.data.newLayoutDesigns(),
                 }}
               >
                 <div class="flex items-center min-w-0 flex-1 w-full">
@@ -1422,7 +1068,7 @@ export function MessageTimeline(props: {
                       type="button"
                       data-slot="session-title-parent"
                       class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
-                      onClick={navigateParent}
+                      onClick={props.action.navigateParent}
                     >
                       {parentTitle()}
                     </button>
@@ -1443,8 +1089,8 @@ export function MessageTimeline(props: {
                           classList={{
                             "truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
                             "w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
-                              settings.general.newLayoutDesigns(),
-                            "grow-1 min-w-0": !settings.general.newLayoutDesigns(),
+                              props.data.newLayoutDesigns(),
+                            "grow-1 min-w-0": !props.data.newLayoutDesigns(),
                           }}
                           onClick={openTitleEditor}
                         >
@@ -1458,15 +1104,14 @@ export function MessageTimeline(props: {
                         }}
                         data-slot="session-title-child"
                         value={title.draft}
-                        disabled={titleMutation.isPending}
+                        disabled={props.pending.rename()}
                         classList={{
                           "block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
-                          "w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
-                          "field-sizing-content self-start rounded-[6px] px-2 py-1 ":
-                            settings.general.newLayoutDesigns(),
+                          "w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !props.data.newLayoutDesigns(),
+                          "field-sizing-content self-start rounded-[6px] px-2 py-1 ": props.data.newLayoutDesigns(),
                         }}
                         style={{
-                          "--inline-input-shadow": settings.general.newLayoutDesigns()
+                          "--inline-input-shadow": props.data.newLayoutDesigns()
                             ? "none"
                             : "var(--shadow-xs-border-select)",
                         }}
@@ -1494,17 +1139,17 @@ export function MessageTimeline(props: {
                   <div
                     classList={{
                       "shrink-0 flex items-center": true,
-                      "gap-2": settings.general.newLayoutDesigns(),
-                      "gap-3": !settings.general.newLayoutDesigns(),
+                      "gap-2": props.data.newLayoutDesigns(),
+                      "gap-3": !props.data.newLayoutDesigns(),
                     }}
                   >
                     <SessionContextUsage
                       placement="bottom"
-                      buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
+                      buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
                     />
                     <Show when={!parentID()}>
                       <Show
-                        when={settings.general.newLayoutDesigns()}
+                        when={props.data.newLayoutDesigns()}
                         fallback={
                           <DropdownMenu
                             gutter={4}
@@ -1567,13 +1212,11 @@ export function MessageTimeline(props: {
                                     </DropdownMenu.ItemLabel>
                                   </DropdownMenu.Item>
                                 </Show>
-                                <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
+                                <DropdownMenu.Item onSelect={() => void props.action.archive(id)}>
                                   <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
                                 </DropdownMenu.Item>
                                 <DropdownMenu.Separator />
-                                <DropdownMenu.Item
-                                  onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
-                                >
+                                <DropdownMenu.Item onSelect={() => props.action.showDelete(id)}>
                                   <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
                                 </DropdownMenu.Item>
                               </DropdownMenu.Content>
@@ -1638,11 +1281,11 @@ export function MessageTimeline(props: {
                                   {language.t("session.share.action.share")}...
                                 </MenuV2.Item>
                               </Show>
-                              <MenuV2.Item onSelect={() => void archiveSession(id)}>
+                              <MenuV2.Item onSelect={() => void props.action.archive(id)}>
                                 {language.t("common.archive")}
                               </MenuV2.Item>
                               <MenuV2.Separator />
-                              <MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
+                              <MenuV2.Item onSelect={() => props.action.showDelete(id)}>
                                 {language.t("common.delete")}...
                               </MenuV2.Item>
                             </MenuV2.Content>
@@ -1654,7 +1297,7 @@ export function MessageTimeline(props: {
                         open={share.open}
                         anchorRef={() => more}
                         placement="bottom-end"
-                        gutter={settings.general.newLayoutDesigns() ? 6 : 4}
+                        gutter={props.data.newLayoutDesigns() ? 6 : 4}
                         modal={false}
                         onOpenChange={(open) => {
                           if (open) setShare("dismiss", null)
@@ -1666,7 +1309,7 @@ export function MessageTimeline(props: {
                             data-component="popover-content"
                             classList={{
                               "flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
-                                settings.general.newLayoutDesigns(),
+                                props.data.newLayoutDesigns(),
                             }}
                             style={{ "min-width": "320px" }}
                             onEscapeKeyDown={(event) => {
@@ -1686,7 +1329,7 @@ export function MessageTimeline(props: {
                             }}
                           >
                             <Show
-                              when={settings.general.newLayoutDesigns()}
+                              when={props.data.newLayoutDesigns()}
                               fallback={
                                 <div class="flex flex-col p-3">
                                   <div class="flex flex-col gap-1">
@@ -1707,10 +1350,10 @@ export function MessageTimeline(props: {
                                           size="large"
                                           variant="primary"
                                           class="w-full"
-                                          onClick={shareSession}
-                                          disabled={shareMutation.isPending}
+                                          onClick={() => void props.action.share()}
+                                          disabled={props.pending.share()}
                                         >
-                                          {shareMutation.isPending
+                                          {props.pending.share()
                                             ? language.t("session.share.action.publishing")
                                             : language.t("session.share.action.publish")}
                                         </Button>
@@ -1730,10 +1373,10 @@ export function MessageTimeline(props: {
                                             size="large"
                                             variant="secondary"
                                             class="w-full shadow-none border border-border-weak-base"
-                                            onClick={unshareSession}
-                                            disabled={unshareMutation.isPending}
+                                            onClick={() => void props.action.unshare()}
+                                            disabled={props.pending.unshare()}
                                           >
-                                            {unshareMutation.isPending
+                                            {props.pending.unshare()
                                               ? language.t("session.share.action.unpublishing")
                                               : language.t("session.share.action.unpublish")}
                                           </Button>
@@ -1741,8 +1384,8 @@ export function MessageTimeline(props: {
                                             size="large"
                                             variant="primary"
                                             class="w-full"
-                                            onClick={viewShare}
-                                            disabled={unshareMutation.isPending}
+                                            onClick={props.action.viewShare}
+                                            disabled={props.pending.unshare()}
                                           >
                                             {language.t("session.share.action.view")}
                                           </Button>
@@ -1770,10 +1413,10 @@ export function MessageTimeline(props: {
                                     <ButtonV2
                                       variant="contrast"
                                       class="w-full"
-                                      onClick={shareSession}
-                                      disabled={shareMutation.isPending}
+                                      onClick={() => void props.action.share()}
+                                      disabled={props.pending.share()}
                                     >
-                                      {shareMutation.isPending
+                                      {props.pending.share()
                                         ? language.t("session.share.action.publishing")
                                         : language.t("session.share.action.publish")}
                                     </ButtonV2>
@@ -1799,7 +1442,7 @@ export function MessageTimeline(props: {
                                         variant="ghost-muted"
                                         icon={<IconV2 name="outline-copy" />}
                                         aria-label={language.t("session.share.copy.copyLink")}
-                                        onClick={copyShareUrl}
+                                        onClick={() => void props.action.copyShareUrl()}
                                       />
                                       <IconButtonV2
                                         type="button"
@@ -1807,18 +1450,18 @@ export function MessageTimeline(props: {
                                         variant="ghost-muted"
                                         icon={<IconV2 name="outline-square-arrow" />}
                                         aria-label={language.t("session.share.action.view")}
-                                        onClick={viewShare}
-                                        disabled={unshareMutation.isPending}
+                                        onClick={props.action.viewShare}
+                                        disabled={props.pending.unshare()}
                                       />
                                     </div>
                                     <div class="flex w-full">
                                       <ButtonV2
                                         variant="outline"
                                         class="w-full"
-                                        onClick={unshareSession}
-                                        disabled={unshareMutation.isPending}
+                                        onClick={() => void props.action.unshare()}
+                                        disabled={props.pending.unshare()}
                                       >
-                                        {unshareMutation.isPending
+                                        {props.pending.unshare()
                                           ? language.t("session.share.action.unpublishing")
                                           : language.t("session.share.action.unpublish")}
                                       </ButtonV2>