Compare commits

...
7 changed files with 799 additions and 558 deletions
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { SESSION_OPEN_FILE_TAB } from "./helpers"
import { createSessionSidePanelController, sessionSidePanelHandoffFiles } from "./session-side-panel-controller"
function createController(options?: { active?: string; all?: string[]; mode?: "changes" | "all" }) {
const calls: string[] = []
const [state, setState] = createStore({
active: options?.active,
all: options?.all ?? ["file://src/a.ts"],
preview: undefined as string | undefined,
mode: options?.mode ?? ("changes" as "changes" | "all"),
})
return createRoot((dispose) => ({
dispose,
calls,
state,
controller: createSessionSidePanelController({
currentTab: () => state.active,
allTabs: () => state.all,
openTab: (tab) => calls.push(`open:${tab}`),
preview: (tab) => calls.push(`preview:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
normalizeFileTab: (tab) => `file://${tab.slice("file://".length).toLowerCase()}`,
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
loadFile: (path) => calls.push(`load:${path}`),
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => false,
openReviewPanel: () => calls.push("panel"),
treeMode: () => state.mode,
setTreeMode: (mode) => setState("mode", mode),
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: () => undefined,
}),
}))
}
describe("session side panel controller", () => {
test("normalizes and centralizes file tab selection mutations", async () => {
const owned = createController()
owned.controller.tabs.activate("file://SRC/A.ts")
expect(owned.calls).toEqual(["load:src/a.ts", "panel", "active:file://src/a.ts"])
owned.calls.length = 0
owned.controller.tabs.preview("file://SRC/B.ts")
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel"])
await Promise.resolve()
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel", "active:file://src/b.ts"])
owned.calls.length = 0
owned.controller.tabs.open("file://SRC/C.ts")
expect(owned.calls).toEqual(["open:file://src/c.ts", "load:src/c.ts", "panel", "active:file://src/c.ts"])
owned.dispose()
})
test("derives browser selection and controls the tree mode", () => {
const owned = createController({ active: "file://src/a.ts", all: ["file://src/a.ts"] })
expect(owned.controller.browser.tab()).toBe("file://src/a.ts")
expect(owned.controller.browser.mounted()).toBe(true)
expect(owned.controller.browser.visible()).toBe(true)
owned.controller.tree.setMode("invalid")
expect(owned.state.mode).toBe("changes")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.controller.tree.showAll()
expect(owned.state.mode).toBe("all")
owned.calls.length = 0
owned.controller.browser.open()
expect(owned.calls[0]).toBe(`preview:${SESSION_OPEN_FILE_TAB}`)
owned.dispose()
})
test("opens the file dialog with the tree handoff callback", async () => {
let render: (() => unknown) | undefined
let dialogProps: { mode?: "files"; onOpenFile?: (path: string) => void } | undefined
const owned = createController()
const controller = createSessionSidePanelController({
currentTab: () => undefined,
allTabs: () => [],
openTab: () => undefined,
preview: () => undefined,
setActive: () => undefined,
normalizeFileTab: (tab) => tab,
pathFromTab: () => undefined,
loadFile: () => undefined,
reviewEnabled: () => true,
canReview: () => true,
fileBrowserEnabled: () => true,
reviewPanelOpened: () => true,
openReviewPanel: () => undefined,
treeMode: owned.controller.tree.mode,
setTreeMode: owned.controller.tree.setMode,
fileReady: () => false,
sessionKey: () => "session",
selectedLines: () => null,
persistHandoff: () => undefined,
showDialog: (value) => (render = value),
loadSelectFileDialog: async () => ({
DialogSelectFile: (props) => {
dialogProps = props
return null
},
}),
})
await controller.dialog.openFile()
render?.()
expect(dialogProps?.mode).toBe("files")
dialogProps?.onOpenFile?.("src/a.ts")
expect(owned.state.mode).toBe("all")
owned.dispose()
})
})
test("projects only file tabs into handoff persistence", () => {
expect(
sessionSidePanelHandoffFiles(
["review", "file://src/a.ts", "file://src/b.ts"],
(tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
(path) => (path.endsWith("a.ts") ? { start: 2, end: 4 } : { startLine: 2, endLine: 4 }),
),
).toEqual({ "src/a.ts": { start: 2, end: 4 }, "src/b.ts": null })
})
@@ -0,0 +1,150 @@
import { createComponent, createEffect, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import type { SelectedLineRange } from "@/context/file"
import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs } from "@/pages/session/helpers"
type TreeMode = "changes" | "all"
type Input = {
currentTab: Accessor<string | undefined>
allTabs: Accessor<string[]>
openTab: (tab: string) => void
preview: (tab: string) => void
setActive: (tab: string) => void
normalizeFileTab: (tab: string) => string
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
reviewEnabled: Accessor<boolean>
canReview: Accessor<boolean>
fileBrowserEnabled: Accessor<boolean>
reviewPanelOpened: Accessor<boolean>
openReviewPanel: () => void
treeMode: Accessor<TreeMode>
setTreeMode: (mode: TreeMode) => void
fileReady: Accessor<boolean>
sessionKey: Accessor<string>
selectedLines: (path: string) => unknown
persistHandoff: (key: string, files: Record<string, SelectedLineRange | null>) => void
showDialog: (render: () => JSX.Element) => void
loadSelectFileDialog?: () => Promise<{
DialogSelectFile: Component<{ mode?: "files"; onOpenFile?: (path: string) => void }>
}>
}
export function createSessionSidePanelController(input: Input) {
const normalizeTab = (tab: string) => (tab.startsWith("file://") ? input.normalizeFileTab(tab) : tab)
const openReviewPanel = () => {
if (!input.reviewPanelOpened()) input.openReviewPanel()
}
const tabs = createSessionTabs({
tabs: () => ({ active: input.currentTab, all: input.allTabs }),
pathFromTab: input.pathFromTab,
normalizeTab,
review: input.reviewEnabled,
hasReview: input.canReview,
fileBrowser: input.fileBrowserEnabled,
})
const prepareTab = (tab: string) => {
const path = input.pathFromTab(tab)
if (path) input.loadFile(path)
openReviewPanel()
return tab
}
const open = createOpenSessionFileTab({
normalizeTab,
openTab: input.openTab,
pathFromTab: input.pathFromTab,
loadFile: input.loadFile,
openReviewPanel,
setActive: input.setActive,
})
const preview = (value: string) => {
const next = normalizeTab(value)
input.preview(next)
const selected = prepareTab(next)
queueMicrotask(() => input.setActive(selected))
}
const activate = (value: string) => input.setActive(prepareTab(normalizeTab(value)))
const openFileBrowser = () => preview(SESSION_OPEN_FILE_TAB)
const browserTab = createMemo(() => {
if (!input.fileBrowserEnabled()) return undefined
const active = tabs.activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && input.pathFromTab(active)) return active
return tabs.activeFileTab()
})
// Keep the shell mounted while any file tab exists. Kobalte briefly selects
// Review while replacing a preview trigger, which must not reset sidebar scroll.
const fileBrowserMounted = createMemo(
() =>
input.fileBrowserEnabled() && (tabs.openedTabs().length > 0 || tabs.openFileOpen() || browserTab() !== undefined),
)
const fileBrowserVisible = createMemo(() => {
const active = tabs.activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const setTreeMode = (value: string) => {
if (value !== "changes" && value !== "all") return
input.setTreeMode(value)
}
const showAllFiles = () => {
if (input.treeMode() !== "changes") return
input.setTreeMode("all")
}
const openFileDialog = async () => {
const load = input.loadSelectFileDialog ?? (() => import("@/components/dialog-select-file"))
const { DialogSelectFile } = await load()
input.showDialog(() => createComponent(DialogSelectFile, { mode: "files", onOpenFile: showAllFiles }))
}
createEffect(() => {
if (!input.fileReady()) return
input.persistHandoff(
input.sessionKey(),
sessionSidePanelHandoffFiles(input.allTabs(), input.pathFromTab, input.selectedLines),
)
})
return {
tabs: {
...tabs,
normalize: normalizeTab,
open,
preview,
activate,
},
browser: {
tab: browserTab,
mounted: fileBrowserMounted,
visible: fileBrowserVisible,
open: openFileBrowser,
},
tree: {
mode: input.treeMode,
setMode: setTreeMode,
showAll: showAllFiles,
},
dialog: {
openFile: openFileDialog,
},
}
}
export function sessionSidePanelHandoffFiles(
tabs: readonly string[],
pathFromTab: (tab: string) => string | undefined,
selectedLines: (path: string) => unknown,
) {
return tabs.reduce<Record<string, SelectedLineRange | null>>((files, tab) => {
const path = pathFromTab(tab)
if (!path) return files
const selected = selectedLines(path)
files[path] = isSelectedLineRange(selected) ? selected : null
return files
}, {})
}
function isSelectedLineRange(value: unknown): value is SelectedLineRange {
return !!value && typeof value === "object" && "start" in value && "end" in value
}
export type SessionSidePanelController = ReturnType<typeof createSessionSidePanelController>
@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
@@ -38,23 +38,17 @@ const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SESSION_OPEN_FILE_TAB,
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { SESSION_OPEN_FILE_TAB, getTabReorderIndex, shouldShowFileTree, type Sizing } from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionSidePanelController } from "@/pages/session/session-side-panel-controller"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
@@ -153,91 +147,49 @@ export function SessionSidePanel(props: {
return file.tree.children("").length === 0
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
const controller = createSessionSidePanelController({
currentTab: () => tabs().active(),
allTabs: () => tabs().all(),
openTab: (tab) => tabs().open(tab),
preview: (tab) => tabs().previewTab(tab),
setActive: (tab) => tabs().setActive(tab),
normalizeFileTab: file.tab,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
reviewEnabled: reviewTab,
canReview: props.canReview,
fileBrowserEnabled: () => !!props.fileBrowserState,
reviewPanelOpened: () => view().reviewPanel.opened(),
openReviewPanel: () => view().reviewPanel.open(),
treeMode: () => layout.fileTree.tab(),
setTreeMode: (mode) => layout.fileTree.setTab(mode),
fileReady: file.ready,
sessionKey,
selectedLines: file.selectedLines,
persistHandoff: (key, files) => setSessionHandoff(key, { files }),
showDialog: (render) => void dialog.show(render),
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab,
review: reviewTab,
hasReview: props.canReview,
fileBrowser: () => !!props.fileBrowserState,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
const panelTabs = tabState.panelTabs
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const contextOpen = controller.tabs.contextOpen
const panelTabs = controller.tabs.panelTabs
const openedTabs = controller.tabs.openedTabs
const activeTab = controller.tabs.activeTab
const activeFileTab = controller.tabs.activeFileTab
const openTab = controller.tabs.open
const previewTab = controller.tabs.preview
const activateTab = controller.tabs.activate
const browserTab = controller.browser.tab
const fileBrowserMounted = controller.browser.mounted
const fileBrowserVisible = controller.browser.visible
const fileTreeTab = controller.tree.mode
const setFileTreeTabValue = controller.tree.setMode
let fileFilter: HTMLInputElement | undefined
let tabList: HTMLDivElement | undefined
const temporaryTab = tabs().preview
const previewTab = (value: string) => {
const next = normalizeTab(value)
tabs().previewTab(next)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
queueMicrotask(() => tabs().setActive(next))
}
const openFileBrowser = () => {
previewTab(SESSION_OPEN_FILE_TAB)
controller.browser.open()
queueMicrotask(() => fileFilter?.focus())
}
const activateTab = (value: string) => {
const next = normalizeTab(value)
const path = file.pathFromTab(next)
if (path) void file.load(path)
openReviewPanel()
tabs().setActive(next)
}
const browserTab = createMemo(() => {
if (!props.fileBrowserState) return undefined
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
return activeFileTab()
})
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
if (!props.fileBrowserState) return false
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
const [store, setStore] = createStore({
@@ -264,27 +216,6 @@ export function SessionSidePanel(props: {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<aside
@@ -451,9 +382,7 @@ export function SessionSidePanel(props: {
iconSize="large"
class="!rounded-md"
onClick={() => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
})
void controller.dialog.openFile()
}}
aria-label={language.t("command.file.open")}
/>
@@ -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
}
@@ -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"])
})
})
@@ -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>
@@ -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>