diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 81220b3ad..162e016c6 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -5,6 +5,7 @@ import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" +import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" @@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" -interface SessionContextTabProps { - messages: () => Message[] - visibleUserMessages: () => UserMessage[] - view: () => ReturnType["view"]> - info: () => ReturnType["session"]["get"]> -} - const BREAKDOWN_COLOR: Record = { system: "var(--syntax-info)", user: "var(--syntax-success)", @@ -91,11 +85,45 @@ function RawMessage(props: { ) } -export function SessionContextTab(props: SessionContextTabProps) { +const emptyMessages: Message[] = [] +const emptyUserMessages: UserMessage[] = [] + +export function SessionContextTab() { const params = useParams() const sync = useSync() + const layout = useLayout() const language = useLanguage() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const view = createMemo(() => layout.view(sessionKey)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + + const messages = createMemo( + () => { + const id = params.id + if (!id) return emptyMessages + return (sync.data.message[id] ?? []) as Message[] + }, + emptyMessages, + { equals: same }, + ) + + const userMessages = createMemo( + () => messages().filter((m) => m.role === "user") as UserMessage[], + emptyUserMessages, + { equals: same }, + ) + + const visibleUserMessages = createMemo( + () => { + const revert = info()?.revert?.messageID + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, + emptyUserMessages, + { equals: same }, + ) + const usd = createMemo( () => new Intl.NumberFormat(language.locale(), { @@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) { }), ) - const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) const ctx = createMemo(() => metrics().context) const formatter = createMemo(() => createSessionContextFormatter(language.locale())) @@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) { }) const counts = createMemo(() => { - const all = props.messages() + const all = messages() const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) return { @@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) { }) const systemPrompt = createMemo(() => { - const msg = findLast(props.visibleUserMessages(), (m) => !!m.system) + const msg = findLast(visibleUserMessages(), (m) => !!m.system) const system = msg?.system if (!system) return const trimmed = system.trim() @@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) { const breakdown = createMemo( on( - () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], + () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], () => { const c = ctx() if (!c?.input) return [] return estimateSessionContextBreakdown({ - messages: props.messages(), + messages: messages(), parts: sync.data.part as Record, input: c.input, systemPrompt: systemPrompt(), @@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) { } const stats = [ - { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" }, + { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, @@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) { { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, { label: "context.stats.totalCost", value: cost }, - { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) }, + { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, ] satisfies { label: string; value: () => JSX.Element }[] @@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) { const el = scroll if (!el) return - const s = props.view()?.scroll("context") + const s = view().scroll("context") if (!s) return if (el.scrollTop !== s.y) el.scrollTop = s.y @@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) { pending = undefined if (!next) return - props.view().setScroll("context", next) + view().setScroll("context", next) }) } createEffect( on( - () => props.messages().length, + () => messages().length, () => { requestAnimationFrame(restoreScroll) }, @@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{language.t("context.rawMessages.title")}
- + {(message) => ( )} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 23dc0304e..7d950b346 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,26 +1,20 @@ import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore, produce } from "solid-js/store" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Dialog } from "@opencode-ai/ui/dialog" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { Select } from "@opencode-ai/ui/select" -import { useCodeComponent } from "@opencode-ai/ui/context/code" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { Mark } from "@opencode-ai/ui/logo" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" -import { useGlobalSync } from "@/context/global-sync" -import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useTerminal } from "@/context/terminal" import { useLayout } from "@/context/layout" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" @@ -34,16 +28,14 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { useComments } from "@/context/comments" -import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" import { showToast } from "@opencode-ai/ui/toast" -import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" +import { SessionHeader, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" -import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers" +import { createOpenReviewFile } from "@/pages/session/helpers" import { createScrollSpy } from "@/pages/session/scroll-spy" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" -import { FileTabContent } from "@/pages/session/file-tabs" import { SessionReviewTab, StickyAddButton, @@ -51,7 +43,6 @@ import { type SessionReviewTabProps, } from "@/pages/session/review-tab" import { TerminalPanel } from "@/pages/session/terminal-panel" -import { terminalTabLabel } from "@/pages/session/terminal-label" import { MessageTimeline } from "@/pages/session/message-timeline" import { useSessionCommands } from "@/pages/session/use-session-commands" import { SessionPromptDock } from "@/pages/session/session-prompt-dock" @@ -59,42 +50,13 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" -type HandoffSession = { - prompt: string - files: Record -} - -const HANDOFF_MAX = 40 - -const handoff = { - session: new Map(), - terminal: new Map(), -} - -const touch = (map: Map, key: K, value: V) => { - map.delete(key) - map.set(key, value) - while (map.size > HANDOFF_MAX) { - const first = map.keys().next().value - if (first === undefined) return - map.delete(first) - } -} - -const setSessionHandoff = (key: string, patch: Partial) => { - const prev = handoff.session.get(key) ?? { prompt: "", files: {} } - touch(handoff.session, key, { ...prev, ...patch }) -} - export default function Page() { const layout = useLayout() const local = useLocal() const file = useFile() const sync = useSync() - const globalSync = useGlobalSync() const terminal = useTerminal() const dialog = useDialog() - const codeComponent = useCodeComponent() const command = useCommand() const language = useLanguage() const params = useParams() @@ -104,53 +66,21 @@ export default function Page() { const comments = useComments() const permission = usePermission() - const permRequest = createMemo(() => { - const sessionID = params.id - if (!sessionID) return - return sync.data.permission[sessionID]?.[0] - }) - - const questionRequest = createMemo(() => { - const sessionID = params.id - if (!sessionID) return - return sync.data.question[sessionID]?.[0] - }) - - const blocked = createMemo(() => !!permRequest() || !!questionRequest()) - const [ui, setUi] = createStore({ - responding: false, pendingMessage: undefined as string | undefined, scrollGesture: 0, - autoCreated: false, scroll: { overflow: false, bottom: true, }, }) - createEffect( - on( - () => permRequest()?.id, - () => setUi("responding", false), - { defer: true }, - ), - ) + const blocked = createMemo(() => { + const sessionID = params.id + if (!sessionID) return false + return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0] + }) - const decide = (response: "once" | "always" | "reject") => { - const perm = permRequest() - if (!perm) return - if (ui.responding) return - - setUi("responding", true) - sdk.client.permission - .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setUi("responding", false)) - } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const workspaceKey = createMemo(() => params.dir ?? "") const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) @@ -323,206 +253,6 @@ export default function Page() { return sync.session.history.loading(id) }) - const [title, setTitle] = createStore({ - draft: "", - editing: false, - saving: false, - menuOpen: false, - pendingRename: false, - }) - let titleRef: HTMLInputElement | undefined - - 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") - } - - createEffect( - on( - sessionKey, - () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!params.id) return - setTitle({ editing: true, draft: info()?.title ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (title.saving) return - setTitle({ editing: false, saving: false }) - } - - const saveTitleEditor = async () => { - const sessionID = params.id - if (!sessionID) return - if (title.saving) return - - const next = title.draft.trim() - if (!next || next === (info()?.title ?? "")) { - setTitle({ editing: false, saving: false }) - return - } - - setTitle("saving", true) - await sdk.client.session - .update({ sessionID, title: next }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session[index].title = next - }), - ) - setTitle({ editing: false, saving: false }) - }) - .catch((err) => { - setTitle("saving", false) - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - async function archiveSession(sessionID: string) { - const session = sync.session.get(sessionID) - if (!session) 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, 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) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - async function deleteSession(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.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.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) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - function DialogDeleteSession(props: { sessionID: string }) { - const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: title() })} - -
-
- - -
-
-
- ) - } - const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -555,8 +285,6 @@ export default function Page() { ) const [store, setStore] = createStore({ - activeDraggable: undefined as string | undefined, - activeTerminalDraggable: undefined as string | undefined, messageId: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "changes", @@ -679,43 +407,6 @@ export default function Page() { void sync.session.todo(id) }) - createEffect(() => { - if (!view().terminal.opened()) { - setUi("autoCreated", false) - return - } - if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return - terminal.new() - setUi("autoCreated", true) - }) - - createEffect( - on( - () => terminal.all().length, - (count, prevCount) => { - if (prevCount !== undefined && prevCount > 0 && count === 0) { - if (view().terminal.opened()) { - view().terminal.toggle() - } - } - }, - ), - ) - - createEffect( - on( - () => terminal.active(), - (activeId) => { - if (!activeId || !view().terminal.opened()) return - // Immediately remove focus - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur() - } - focusTerminalById(activeId) - }, - ), - ) - createEffect( on( () => visibleUserMessages().at(-1)?.id, @@ -729,11 +420,6 @@ export default function Page() { ) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) - const todos = createMemo(() => { - const id = params.id - if (!id) return [] - return globalSync.data.session_todo[id] ?? [] - }) createEffect( on( @@ -741,7 +427,6 @@ export default function Page() { () => { setStore("messageId", undefined) setStore("changes", "session") - setUi("autoCreated", false) }, { defer: true }, ), @@ -827,53 +512,6 @@ export default function Page() { } } - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setStore("activeDraggable", id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentTabs = tabs().all() - const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) - if (toIndex === undefined) return - tabs().move(draggable.id.toString(), toIndex) - } - } - - const handleDragEnd = () => { - setStore("activeDraggable", undefined) - } - - const handleTerminalDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setStore("activeTerminalDraggable", id) - } - - const handleTerminalDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const terminals = terminal.all() - const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) - if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - terminal.move(draggable.id.toString(), toIndex) - } - } - } - - const handleTerminalDragEnd = () => { - setStore("activeTerminalDraggable", undefined) - const activeId = terminal.active() - if (!activeId) return - setTimeout(() => { - focusTerminalById(activeId) - }, 0) - } - const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() @@ -1485,58 +1123,6 @@ export default function Page() { document.addEventListener("keydown", handleKeyDown) }) - const previewPrompt = () => - prompt - .current() - .map((part) => { - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - if (part.type === "image") return `[image:${part.filename}]` - return part.content - }) - .join("") - .trim() - - createEffect(() => { - if (!prompt.ready()) return - setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) - }) - - createEffect(() => { - if (!terminal.ready()) return - language.locale() - - touch( - handoff.terminal, - params.dir!, - terminal.all().map((pty) => - terminalTabLabel({ - title: pty.title, - titleNumber: pty.titleNumber, - t: language.t as (key: string, vars?: Record) => string, - }), - ), - ) - }) - - createEffect(() => { - if (!file.ready()) return - setSessionHandoff(sessionKey(), { - files: tabs() - .all() - .reduce>((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 - }, {}), - }) - }) - onCleanup(() => { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) @@ -1555,7 +1141,6 @@ export default function Page() { reviewCount={reviewCount()} onSession={() => setStore("mobileTab", "session")} onChanges={() => setStore("mobileTab", "changes")} - t={language.t as (key: string, vars?: Record) => string} /> {/* Session panel */} @@ -1595,27 +1180,7 @@ export default function Page() { isDesktop={isDesktop()} onScrollSpyScroll={scrollSpy.onScroll} onAutoScrollInteraction={autoScroll.handleInteraction} - showHeader={!!(info()?.title || info()?.parentID)} centered={centered()} - title={info()?.title} - parentID={info()?.parentID} - openTitleEditor={openTitleEditor} - closeTitleEditor={closeTitleEditor} - saveTitleEditor={saveTitleEditor} - titleRef={(el) => { - titleRef = el - }} - titleState={title} - onTitleDraft={(value) => setTitle("draft", value)} - onTitleMenuOpen={(open) => setTitle("menuOpen", open)} - onTitlePendingRename={(value) => setTitle("pendingRename", value)} - onNavigateParent={() => { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - sessionID={params.id!} - onArchiveSession={(sessionID) => void archiveSession(sessionID)} - onDeleteSession={(sessionID) => dialog.show(() => )} - t={language.t as (key: string, vars?: Record) => string} setContentRef={(el) => { content = el autoScroll.contentRef(el) @@ -1670,15 +1235,6 @@ export default function Page() { ) => string} - responding={ui.responding} - onDecide={decide} inputRef={(el) => { inputRef = el }} @@ -1688,7 +1244,9 @@ export default function Page() { comments.clear() resumeScroll() }} - setPromptDockRef={(el) => (promptDock = el)} + setPromptDockRef={(el) => { + promptDock = el + }} /> @@ -1702,64 +1260,10 @@ export default function Page() {
- handoff.session.get(sessionKey())?.files} - codeComponent={codeComponent} - addCommentToContext={addCommentToContext} - activeDraggable={() => store.activeDraggable} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - fileTreeTab={fileTreeTab} - setFileTreeTabValue={setFileTreeTabValue} - diffsReady={diffsReady()} - diffFiles={diffFiles()} - kinds={kinds()} - activeDiff={tree.activeDiff} - focusReviewDiff={focusReviewDiff} - /> + - handoff.terminal.get(params.dir!) ?? []} - activeTerminalDraggable={() => store.activeTerminalDraggable} - handleTerminalDragStart={handleTerminalDragStart} - handleTerminalDragOver={handleTerminalDragOver} - handleTerminalDragEnd={handleTerminalDragEnd} - onCloseTab={() => setUi("autoCreated", false)} - /> + ) } diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index d22fa358b..9e3a54311 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,6 +1,8 @@ -import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" +import { useParams } from "@solidjs/router" +import { useCodeComponent } from "@opencode-ai/ui/context/code" import { sampledChecksum } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" @@ -8,9 +10,11 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ import { Mark } from "@opencode-ai/ui/logo" import { Tabs } from "@opencode-ai/ui/tabs" import { useLayout } from "@/context/layout" -import { useFile, type SelectedLineRange } from "@/context/file" +import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { getSessionHandoff } from "@/pages/session/handoff" const formatCommentLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) @@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => { return `lines ${start}-${end}` } -export function FileTabContent(props: { - tab: string - activeTab: () => string - tabs: () => ReturnType["tabs"]> - view: () => ReturnType["view"]> - handoffFiles: () => Record | undefined - file: ReturnType - comments: ReturnType - language: ReturnType - codeComponent: NonNullable - addCommentToContext: (input: { - file: string - selection: SelectedLineRange - comment: string - preview?: string - origin?: "review" | "file" - }) => void -}) { +export function FileTabContent(props: { tab: string }) { + const params = useParams() + const layout = useLayout() + const file = useFile() + const comments = useComments() + const language = useLanguage() + const prompt = usePrompt() + const codeComponent = useCodeComponent() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined let codeScroll: HTMLElement[] = [] - const path = createMemo(() => props.file.pathFromTab(props.tab)) + const path = createMemo(() => file.pathFromTab(props.tab)) const state = createMemo(() => { const p = path() if (!p) return - return props.file.get(p) + return file.get(p) }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => sampledChecksum(contents())) @@ -82,7 +81,7 @@ export function FileTabContent(props: { svgToast.shown = true showToast({ variant: "error", - title: props.language.t("toast.file.loadFailed.title"), + title: language.t("toast.file.loadFailed.title"), }) }) const svgPreviewUrl = createMemo(() => { @@ -100,16 +99,57 @@ export function FileTabContent(props: { const selectedLines = createMemo(() => { const p = path() if (!p) return null - if (props.file.ready()) return props.file.selectedLines(p) ?? null - return props.handoffFiles()?.[p] ?? null + if (file.ready()) return file.selectedLines(p) ?? null + return getSessionHandoff(sessionKey())?.files[p] ?? null }) + const selectionPreview = (source: string, selection: FileSelection) => { + const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) + const end = Math.max(selection.startLine, selection.endLine) + const lines = source.split("\n").slice(start - 1, end) + if (lines.length === 0) return undefined + return lines.slice(0, 2).join("\n") + } + + const addCommentToContext = (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => { + const selection = selectionFromLines(input.selection) + const preview = + input.preview ?? + (() => { + if (input.file === path()) return selectionPreview(contents(), selection) + const source = file.get(input.file)?.content?.content + if (!source) return undefined + return selectionPreview(source, selection) + })() + + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) + prompt.context.add({ + type: "file", + path: input.file, + selection, + comment: input.comment, + commentID: saved.id, + commentOrigin: input.origin, + preview, + }) + } + let wrap: HTMLDivElement | undefined const fileComments = createMemo(() => { const p = path() if (!p) return [] - return props.comments.list(p) + return comments.list(p) }) const commentLayout = createMemo(() => { @@ -228,19 +268,19 @@ export function FileTabContent(props: { }) createEffect(() => { - const focus = props.comments.focus() + const focus = comments.focus() const p = path() if (!focus || !p) return if (focus.file !== p) return - if (props.activeTab() !== props.tab) return + if (tabs().active() !== props.tab) return const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return setNote("openedComment", target.id) setNote("commenting", null) - props.file.setSelectedLines(p, target.selection) - requestAnimationFrame(() => props.comments.clearFocus()) + file.setSelectedLines(p, target.selection) + requestAnimationFrame(() => comments.clearFocus()) }) const getCodeScroll = () => { @@ -269,7 +309,7 @@ export function FileTabContent(props: { pending = undefined if (!out) return - props.view().setScroll(props.tab, out) + view().setScroll(props.tab, out) }) } @@ -305,7 +345,7 @@ export function FileTabContent(props: { const el = scroll if (!el) return - const s = props.view()?.scroll(props.tab) + const s = view().scroll(props.tab) if (!s) return syncCodeScroll() @@ -343,7 +383,7 @@ export function FileTabContent(props: { createEffect( on( - () => props.file.ready(), + () => file.ready(), (ready) => { if (!ready) return requestAnimationFrame(restoreScroll) @@ -354,7 +394,7 @@ export function FileTabContent(props: { createEffect( on( - () => props.tabs().active() === props.tab, + () => tabs().active() === props.tab, (active) => { if (!active) return if (!state()?.loaded) return @@ -381,7 +421,7 @@ export function FileTabContent(props: { class={`relative overflow-hidden ${wrapperClass}`} > { const p = path() if (!p) return - props.file.setSelectedLines(p, range) + file.setSelectedLines(p, range) if (!range) setNote("commenting", null) }} onLineSelectionEnd={(range: SelectedLineRange | null) => { @@ -423,14 +463,14 @@ export function FileTabContent(props: { onMouseEnter={() => { const p = path() if (!p) return - props.file.setSelectedLines(p, comment.selection) + file.setSelectedLines(p, comment.selection) }} onClick={() => { const p = path() if (!p) return setNote("commenting", null) setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) - props.file.setSelectedLines(p, comment.selection) + file.setSelectedLines(p, comment.selection) }} /> )} @@ -447,12 +487,7 @@ export function FileTabContent(props: { onSubmit={(value) => { const p = path() if (!p) return - props.addCommentToContext({ - file: p, - selection: range(), - comment: value, - origin: "file", - }) + addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" }) setNote("commenting", null) }} onPopoverFocusOut={(e: FocusEvent) => { @@ -509,13 +544,13 @@ export function FileTabContent(props: {
{path()?.split("/").pop()}
-
{props.language.t("session.files.binaryContent")}
+
{language.t("session.files.binaryContent")}
{renderCode(contents(), "pb-40")} -
{props.language.t("common.loading")}...
+
{language.t("common.loading")}...
{(err) =>
{err()}
}
diff --git a/packages/app/src/pages/session/handoff.ts b/packages/app/src/pages/session/handoff.ts new file mode 100644 index 000000000..61bdca934 --- /dev/null +++ b/packages/app/src/pages/session/handoff.ts @@ -0,0 +1,36 @@ +import type { SelectedLineRange } from "@/context/file" + +type HandoffSession = { + prompt: string + files: Record +} + +const MAX = 40 + +const store = { + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +export const setSessionHandoff = (key: string, patch: Partial) => { + const prev = store.session.get(key) ?? { prompt: "", files: {} } + touch(store.session, key, { ...prev, ...patch }) +} + +export const getSessionHandoff = (key: string) => store.session.get(key) + +export const setTerminalHandoff = (key: string, value: string[]) => { + touch(store.terminal, key, value) +} + +export const getTerminalHandoff = (key: string) => store.terminal.get(key) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index a8d22ccc8..b94942408 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,13 +1,21 @@ -import { For, onCleanup, onMount, Show, type JSX } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, onMount, Show, type JSX } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import type { UserMessage } from "@opencode-ai/sdk/v2" +import { showToast } from "@opencode-ai/ui/toast" 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 { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined @@ -53,29 +61,7 @@ export function MessageTimeline(props: { isDesktop: boolean onScrollSpyScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - showHeader: boolean centered: boolean - title?: string - parentID?: string - openTitleEditor: () => void - closeTitleEditor: () => void - saveTitleEditor: () => void | Promise - titleRef: (el: HTMLInputElement) => void - titleState: { - draft: string - editing: boolean - saving: boolean - menuOpen: boolean - pendingRename: boolean - } - onTitleDraft: (value: string) => void - onTitleMenuOpen: (open: boolean) => void - onTitlePendingRename: (value: boolean) => void - onNavigateParent: () => void - sessionID: string - onArchiveSession: (sessionID: string) => void - onDeleteSession: (sessionID: string) => void - t: (key: string, vars?: Record) => string setContentRef: (el: HTMLDivElement) => void turnStart: number onRenderEarlier: () => void @@ -91,6 +77,230 @@ export function MessageTimeline(props: { }) { let touchGesture: number | undefined + const params = useParams() + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const sessionID = createMemo(() => params.id) + const info = createMemo(() => { + const id = sessionID() + if (!id) return + return sync.session.get(id) + }) + const titleValue = createMemo(() => info()?.title) + const parentID = createMemo(() => info()?.parentID) + const showHeader = createMemo(() => !!(titleValue() || parentID())) + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + + 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") + } + + createEffect( + on( + sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!sessionID()) return + setTitle({ editing: true, draft: titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) 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, 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) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .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.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + + const byParent = new Map() + for (const item of draft.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) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + return ( - +
- + - + - {props.title} + {titleValue()} } > { + titleRef = el + }} + value={title.draft} + disabled={title.saving} class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => props.onTitleDraft(event.currentTarget.value)} + onInput={(event) => setTitle("draft", event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() if (event.key === "Enter") { event.preventDefault() - void props.saveTitleEditor() + void saveTitleEditor() return } if (event.key === "Escape") { event.preventDefault() - props.closeTitleEditor() + closeTitleEditor() } }} - onBlur={props.closeTitleEditor} + onBlur={closeTitleEditor} />
- + {(id) => (
setTitle("menuOpen", open)} > { - if (!props.titleState.pendingRename) return + if (!title.pendingRename) return event.preventDefault() - props.onTitlePendingRename(false) - props.openTitleEditor() + setTitle("pendingRename", false) + openTitleEditor() }} > { - props.onTitlePendingRename(true) - props.onTitleMenuOpen(false) + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - {props.t("common.rename")} + {language.t("common.rename")} - props.onArchiveSession(id())}> - {props.t("common.archive")} + void archiveSession(id())}> + {language.t("common.archive")} - props.onDeleteSession(id())}> - {props.t("common.delete")} + dialog.show(() => )} + > + {language.t("common.delete")} @@ -282,7 +496,7 @@ export function MessageTimeline(props: { 0}>
@@ -296,8 +510,8 @@ export function MessageTimeline(props: { onClick={props.onLoadEarlier} > {props.historyLoading - ? props.t("session.messages.loadingEarlier") - : props.t("session.messages.loadEarlier")} + ? language.t("session.messages.loadingEarlier") + : language.t("session.messages.loadEarlier")}
@@ -321,7 +535,7 @@ export function MessageTimeline(props: { }} > void onChanges: () => void - t: (key: string, vars?: Record) => string }) { + const language = useLanguage() + return ( @@ -20,7 +22,7 @@ export function SessionMobileTabs(props: { classes={{ button: "w-full" }} onClick={props.onSession} > - {props.t("session.tab.session")} + {language.t("session.tab.session")} {props.hasReview - ? props.t("session.review.filesChanged", { count: props.reviewCount }) - : props.t("session.review.change.other")} + ? language.t("session.review.filesChanged", { count: props.reviewCount }) + : language.t("session.review.change.other")} diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 83fc615b5..3f0b7a6e8 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -1,35 +1,105 @@ import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" -import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import { useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" import { PromptInput } from "@/components/prompt-input" import { QuestionDock } from "@/components/question-dock" import { SessionTodoDock } from "@/components/session-todo-dock" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" export function SessionPromptDock(props: { centered: boolean - questionRequest: () => QuestionRequest | undefined - permissionRequest: () => { patterns: string[]; permission: string } | undefined - blocked: boolean - todos: Todo[] - promptReady: boolean - handoffPrompt?: string - t: (key: string, vars?: Record) => string - responding: boolean - onDecide: (response: "once" | "always" | "reject") => void inputRef: (el: HTMLDivElement) => void newSessionWorktree: string onNewSessionWorktreeReset: () => void onSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void }) { - const done = createMemo( - () => - props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"), + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const globalSync = useGlobalSync() + const prompt = usePrompt() + const language = useLanguage() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) + + const todos = createMemo((): Todo[] => { + const id = params.id + if (!id) return [] + return globalSync.data.session_todo[id] ?? [] + }) + + const questionRequest = createMemo((): QuestionRequest | undefined => { + const sessionID = params.id + if (!sessionID) return + return sync.data.question[sessionID]?.[0] + }) + + const permissionRequest = createMemo((): PermissionRequest | undefined => { + const sessionID = params.id + if (!sessionID) return + return sync.data.permission[sessionID]?.[0] + }) + + const blocked = createMemo(() => !!permissionRequest() || !!questionRequest()) + + const previewPrompt = () => + prompt + .current() + .map((part) => { + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[image:${part.filename}]` + return part.content + }) + .join("") + .trim() + + createEffect(() => { + if (!prompt.ready()) return + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) + }) + + const [responding, setResponding] = createSignal(false) + + createEffect( + on( + () => permissionRequest()?.id, + () => setResponding(false), + { defer: true }, + ), ) - const [dock, setDock] = createSignal(props.todos.length > 0) + const decide = (response: "once" | "always" | "reject") => { + const perm = permissionRequest() + if (!perm) return + if (responding()) return + + setResponding(true) + sdk.client.permission + .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setResponding(false)) + } + + const done = createMemo( + () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + const [dock, setDock] = createSignal(todos().length > 0) const [closing, setClosing] = createSignal(false) const [opening, setOpening] = createSignal(false) let timer: number | undefined @@ -46,7 +116,7 @@ export function SessionPromptDock(props: { createEffect( on( - () => [props.todos.length, done()] as const, + () => [todos().length, done()] as const, ([count, complete], prev) => { if (raf) cancelAnimationFrame(raf) raf = undefined @@ -113,7 +183,7 @@ export function SessionPromptDock(props: { "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - + {(req) => { return (
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: { }} - + {(perm) => { const toolDescription = () => { const key = `settings.permissions.tool.${perm.permission}.description` - const value = props.t(key) + const value = language.t(key as Parameters[0]) if (value === key) return "" return value } @@ -141,36 +211,26 @@ export function SessionPromptDock(props: { -
{props.t("notification.permission.title")}
+
{language.t("notification.permission.title")}
} footer={ <>
- -
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: { }} - + - {props.handoffPrompt || props.t("prompt.loading")} + {handoffPrompt() || language.t("prompt.loading")}
} > @@ -219,10 +279,10 @@ export function SessionPromptDock(props: { }} >
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 33954f64a..68dfc346f 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -1,156 +1,269 @@ -import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" +import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createMediaQuery } from "@solid-primitives/media" +import { useParams } from "@solidjs/router" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { useDialog } from "@opencode-ai/ui/context/dialog" + import FileTree from "@/components/file-tree" import { SessionContextUsage } from "@/components/session-context-usage" -import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { DialogSelectFile } from "@/components/dialog-select-file" -import { createFileTabListSync } from "@/pages/session/file-tab-scroll" -import { FileTabContent } from "@/pages/session/file-tabs" -import { StickyAddButton } from "@/pages/session/review-tab" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import { ConstrainDragYAxis } from "@/utils/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { useComments } from "@/context/comments" +import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { useCommand } from "@/context/command" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" -import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client" - -type SessionSidePanelViewModel = { - messages: () => Message[] - visibleUserMessages: () => UserMessage[] - view: () => ReturnType["view"]> - info: () => ReturnType["session"]["get"]> -} +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { getTabReorderIndex } from "@/pages/session/helpers" +import { StickyAddButton } from "@/pages/session/review-tab" +import { setSessionHandoff } from "@/pages/session/handoff" export function SessionSidePanel(props: { - open: boolean - reviewOpen: boolean - language: ReturnType - layout: ReturnType - command: ReturnType - dialog: ReturnType - file: ReturnType - comments: ReturnType - hasReview: boolean - reviewCount: number - reviewTab: boolean - contextOpen: () => boolean - openedTabs: () => string[] - activeTab: () => string - activeFileTab: () => string | undefined - tabs: () => ReturnType["tabs"]> - openTab: (value: string) => void - showAllFiles: () => void reviewPanel: () => JSX.Element - vm: SessionSidePanelViewModel - handoffFiles: () => Record | undefined - codeComponent: NonNullable - addCommentToContext: (input: { - file: string - selection: SelectedLineRange - comment: string - preview?: string - origin?: "review" | "file" - }) => void - activeDraggable: () => string | undefined - onDragStart: (event: unknown) => void - onDragEnd: () => void - onDragOver: (event: DragEvent) => void - fileTreeTab: () => "changes" | "all" - setFileTreeTabValue: (value: string) => void - diffsReady: boolean - diffFiles: string[] - kinds: Map activeDiff?: string focusReviewDiff: (path: string) => void }) { - const openedTabs = createMemo(() => props.openedTabs()) + const params = useParams() + const layout = useLayout() + const sync = useSync() + const file = useFile() + const language = useLanguage() + const command = useCommand() + const dialog = useDialog() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + + const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened())) + const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened()) + + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + + const diffFiles = createMemo(() => diffs().map((d) => d.file)) + const kinds = createMemo(() => { + const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { + if (!a) return b + if (a === b) return a + return "mix" as const + } + + const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") + + const out = new Map() + for (const diff of diffs()) { + const file = normalize(diff.file) + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" + + out.set(file, kind) + + const parts = file.split("/") + for (const [idx] of parts.slice(0, -1).entries()) { + const dir = parts.slice(0, idx + 1).join("/") + if (!dir) continue + out.set(dir, merge(out.get(dir), kind)) + } + } + return out + }) + + 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 = (value: string) => { + const next = normalizeTab(value) + tabs().open(next) + + const path = file.pathFromTab(next) + if (!path) return + file.load(path) + openReviewPanel() + } + + const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) + const openedTabs = createMemo(() => + tabs() + .all() + .filter((tab) => tab !== "context" && tab !== "review"), + ) + + const activeTab = createMemo(() => { + const active = tabs().active() + if (active === "context") return "context" + if (active === "review" && reviewTab()) return "review" + if (active && file.pathFromTab(active)) return normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + if (reviewTab() && hasReview()) return "review" + return "empty" + }) + + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + + 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 [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const currentTabs = tabs().all() + const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) + if (toIndex === undefined) return + tabs().move(draggable.id.toString(), toIndex) + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + createEffect(() => { + if (!file.ready()) return + + setSessionHandoff(sessionKey(), { + files: tabs() + .all() + .reduce>((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 ( - +