diff --git a/packages/app/src/pages/session/timeline/controller-projection.ts b/packages/app/src/pages/session/timeline/controller-projection.ts new file mode 100644 index 0000000000..17cd19460a --- /dev/null +++ b/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 +} diff --git a/packages/app/src/pages/session/timeline/controller.test.ts b/packages/app/src/pages/session/timeline/controller.test.ts new file mode 100644 index 0000000000..22a37e6296 --- /dev/null +++ b/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"]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/controller.tsx b/packages/app/src/pages/session/timeline/controller.tsx new file mode 100644 index 0000000000..775be85243 --- /dev/null +++ b/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 }) { + 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 ( + + + + + + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("session.delete.button")} + + + + ) + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + 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(() => ), + 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 diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index dc9153536c..e8de78477c 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/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 type TimelineRowByTag = Extract @@ -89,14 +64,6 @@ type TimelineRowByTag = Extract }>() -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 ( + + ) +} + +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() - 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 = (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([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) - } - } - - 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 ( - - - - - - dialog.close()}> - {language.t("common.cancel")} - - - {language.t("session.delete.button")} - - - - ) - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) + 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: {
@@ -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()} />
@@ -1210,7 +856,7 @@ export function MessageTimeline(props: {
@@ -1326,16 +972,16 @@ export function MessageTimeline(props: {
@@ -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()} @@ -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: {
- void archiveSession(id)}> + void props.action.archive(id)}> {language.t("common.archive")} - dialog.show(() => )} - > + props.action.showDelete(id)}> {language.t("common.delete")} @@ -1638,11 +1281,11 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... - void archiveSession(id)}> + void props.action.archive(id)}> {language.t("common.archive")} - dialog.show(() => )}> + props.action.showDelete(id)}> {language.t("common.delete")}... @@ -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: { }} >
@@ -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")} @@ -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")} @@ -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")} @@ -1770,10 +1413,10 @@ export function MessageTimeline(props: { 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")} @@ -1799,7 +1442,7 @@ export function MessageTimeline(props: { variant="ghost-muted" icon={} aria-label={language.t("session.share.copy.copyLink")} - onClick={copyShareUrl} + onClick={() => void props.action.copyShareUrl()} /> } aria-label={language.t("session.share.action.view")} - onClick={viewShare} - disabled={unshareMutation.isPending} + onClick={props.action.viewShare} + disabled={props.pending.unshare()} />
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")}