diff options
| author | Adam <[email protected]> | 2026-02-18 08:26:15 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-18 08:26:15 -0600 |
| commit | 00c238777ae11dfd61c6249426cd201fc3612f1b (patch) | |
| tree | 32c0a3c9a2c42734a2d9c3886b7c92d0b62eeee3 /packages/app/src | |
| parent | e4b548fa768a59cea7e5c8279e327d990cd36c27 (diff) | |
| download | opencode-00c238777ae11dfd61c6249426cd201fc3612f1b.tar.gz opencode-00c238777ae11dfd61c6249426cd201fc3612f1b.zip | |
chore: cleanup (#14113)
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/components/session/session-context-tab.tsx | 66 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 522 | ||||
| -rw-r--r-- | packages/app/src/pages/session/file-tabs.tsx | 127 | ||||
| -rw-r--r-- | packages/app/src/pages/session/handoff.ts | 36 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 328 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-mobile-tabs.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-prompt-dock.tsx | 144 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-side-panel.tsx | 373 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 235 |
9 files changed, 943 insertions, 898 deletions
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<ReturnType<typeof useLayout>["view"]> - info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> -} - const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { 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<string, Part[] | undefined>, 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) { <div class="flex flex-col gap-2"> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <Accordion multiple> - <For each={props.messages()}> + <For each={messages()}> {(message) => ( <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> )} 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<string, SelectedLineRange | null> -} - -const HANDOFF_MAX = 40 - -const handoff = { - session: new Map<string, HandoffSession>(), - terminal: new Map<string, string[]>(), -} - -const touch = <K, V>(map: Map<K, V>, 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<HandoffSession>) => { - 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<string>([sessionID]) - - const byParent = new Map<string, string[]>() - 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 ( - <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: title() })} - </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 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, string | number | boolean>) => string, - }), - ), - ) - }) - - 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 - }, {}), - }) - }) - 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, string | number | boolean>) => 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(() => <DialogDeleteSession sessionID={sessionID} />)} - t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string} setContentRef={(el) => { content = el autoScroll.contentRef(el) @@ -1670,15 +1235,6 @@ export default function Page() { <SessionPromptDock centered={centered()} - questionRequest={questionRequest} - permissionRequest={permRequest} - blocked={blocked()} - todos={todos()} - promptReady={prompt.ready()} - handoffPrompt={handoff.session.get(sessionKey())?.prompt} - t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => 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 + }} /> <Show when={desktopReviewOpen()}> @@ -1702,64 +1260,10 @@ export default function Page() { </Show> </div> - <SessionSidePanel - open={desktopSidePanelOpen()} - reviewOpen={desktopReviewOpen()} - language={language} - layout={layout} - command={command} - dialog={dialog} - file={file} - comments={comments} - hasReview={hasReview()} - reviewCount={reviewCount()} - reviewTab={reviewTab()} - contextOpen={contextOpen} - openedTabs={openedTabs} - activeTab={activeTab} - activeFileTab={activeFileTab} - tabs={tabs} - openTab={openTab} - showAllFiles={showAllFiles} - reviewPanel={reviewPanel} - vm={{ - messages, - visibleUserMessages, - view, - info, - }} - handoffFiles={() => 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} - /> + <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} /> </div> - <TerminalPanel - open={isDesktop() && view().terminal.opened()} - height={layout.terminal.height()} - resize={layout.terminal.resize} - close={view().terminal.close} - terminal={terminal} - language={language} - command={command} - handoff={() => handoff.terminal.get(params.dir!) ?? []} - activeTerminalDraggable={() => store.activeTerminalDraggable} - handleTerminalDragStart={handleTerminalDragStart} - handleTerminalDragOver={handleTerminalDragOver} - handleTerminalDragEnd={handleTerminalDragEnd} - onCloseTab={() => setUi("autoCreated", false)} - /> + <TerminalPanel /> </div> ) } 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<ReturnType<typeof useLayout>["tabs"]> - view: () => ReturnType<ReturnType<typeof useLayout>["view"]> - handoffFiles: () => Record<string, SelectedLineRange | null> | undefined - file: ReturnType<typeof useFile> - comments: ReturnType<typeof useComments> - language: ReturnType<typeof useLanguage> - codeComponent: NonNullable<ValidComponent> - 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}`} > <Dynamic - component={props.codeComponent} + component={codeComponent} file={{ name: path() ?? "", contents: source, @@ -397,7 +437,7 @@ export function FileTabContent(props: { onLineSelected={(range: SelectedLineRange | null) => { 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: { <Mark class="w-14 opacity-10" /> <div class="flex flex-col gap-2 max-w-md"> <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div> - <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div> + <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div> </div> </div> </Match> <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <Match when={state()?.loading}> - <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div> + <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> </Match> <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match> </Switch> 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<string, SelectedLineRange | null> +} + +const MAX = 40 + +const store = { + session: new Map<string, HandoffSession>(), + terminal: new Map<string, string[]>(), +} + +const touch = <K, V>(map: Map<K, V>, 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<HandoffSession>) => { + 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<void> - 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, string | number | boolean>) => 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<string>([sessionID]) + + const byParent = new Map<string, string[]>() + 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 ( + <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> + ) + } + return ( <Show when={!props.mobileChanges} @@ -157,9 +367,9 @@ export function MessageTimeline(props: { }} onClick={props.onAutoScrollInteraction} class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" - style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} + style={{ "--session-title-height": showHeader() ? "40px" : "0px" }} > - <Show when={props.showHeader}> + <Show when={showHeader()}> <div classList={{ "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, @@ -171,92 +381,96 @@ export function MessageTimeline(props: { > <div class="h-12 w-full flex items-center justify-between gap-2"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> - <Show when={props.parentID}> + <Show when={parentID()}> <IconButton tabIndex={-1} icon="arrow-left" variant="ghost" - onClick={props.onNavigateParent} - aria-label={props.t("common.goBack")} + onClick={navigateParent} + aria-label={language.t("common.goBack")} /> </Show> - <Show when={props.title || props.titleState.editing}> + <Show when={titleValue() || title.editing}> <Show - when={props.titleState.editing} + when={title.editing} fallback={ <h1 class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" - onDblClick={props.openTitleEditor} + onDblClick={openTitleEditor} > - {props.title} + {titleValue()} </h1> } > <InlineInput - ref={props.titleRef} - value={props.titleState.draft} - disabled={props.titleState.saving} + ref={(el) => { + 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} /> </Show> </Show> </div> - <Show when={props.sessionID}> + <Show when={sessionID()}> {(id) => ( <div class="shrink-0 flex items-center gap-3"> <SessionContextUsage placement="bottom" /> <DropdownMenu gutter={4} placement="bottom-end" - open={props.titleState.menuOpen} - onOpenChange={props.onTitleMenuOpen} + open={title.menuOpen} + onOpenChange={(open) => setTitle("menuOpen", open)} > <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - aria-label={props.t("common.moreOptions")} + aria-label={language.t("common.moreOptions")} /> <DropdownMenu.Portal> <DropdownMenu.Content style={{ "min-width": "104px" }} onCloseAutoFocus={(event) => { - if (!props.titleState.pendingRename) return + if (!title.pendingRename) return event.preventDefault() - props.onTitlePendingRename(false) - props.openTitleEditor() + setTitle("pendingRename", false) + openTitleEditor() }} > <DropdownMenu.Item onSelect={() => { - props.onTitlePendingRename(true) - props.onTitleMenuOpen(false) + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel> + <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}> - <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel> + <DropdownMenu.Item onSelect={() => void archiveSession(id())}> + <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> <DropdownMenu.Separator /> - <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}> - <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel> + <DropdownMenu.Item + onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} + > + <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> </DropdownMenu.Content> </DropdownMenu.Portal> @@ -282,7 +496,7 @@ export function MessageTimeline(props: { <Show when={props.turnStart > 0}> <div class="w-full flex justify-center"> <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}> - {props.t("session.messages.renderEarlier")} + {language.t("session.messages.renderEarlier")} </Button> </div> </Show> @@ -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")} </Button> </div> </Show> @@ -321,7 +535,7 @@ export function MessageTimeline(props: { }} > <SessionTurn - sessionID={props.sessionID} + sessionID={sessionID() ?? ""} messageID={message.id} lastUserMessageID={props.lastUserMessageID} classes={{ diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx index 73aebc079..f97199b49 100644 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -1,5 +1,6 @@ import { Show } from "solid-js" import { Tabs } from "@opencode-ai/ui/tabs" +import { useLanguage } from "@/context/language" export function SessionMobileTabs(props: { open: boolean @@ -8,8 +9,9 @@ export function SessionMobileTabs(props: { reviewCount: number onSession: () => void onChanges: () => void - t: (key: string, vars?: Record<string, string | number | boolean>) => string }) { + const language = useLanguage() + return ( <Show when={props.open}> <Tabs value={props.mobileTab} class="h-auto"> @@ -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")} </Tabs.Trigger> <Tabs.Trigger value="changes" @@ -29,8 +31,8 @@ export function SessionMobileTabs(props: { onClick={props.onChanges} > {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")} </Tabs.Trigger> </Tabs.List> </Tabs> 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, string | number | boolean>) => string - responding: boolean - onDecide: (response: "once" | "always" | "reject") => void inputRef: (el: HTMLDivElement) => void newSessionWorktree: string onNewSessionWorktreeReset: () => void onSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void }) { + 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 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( - () => - props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"), + () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), ) - const [dock, setDock] = createSignal(props.todos.length > 0) + 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, }} > - <Show when={props.questionRequest()} keyed> + <Show when={questionRequest()} keyed> {(req) => { return ( <div> @@ -123,11 +193,11 @@ export function SessionPromptDock(props: { }} </Show> - <Show when={props.permissionRequest()} keyed> + <Show when={permissionRequest()} keyed> {(perm) => { const toolDescription = () => { const key = `settings.permissions.tool.${perm.permission}.description` - const value = props.t(key) + const value = language.t(key as Parameters<typeof language.t>[0]) if (value === key) return "" return value } @@ -141,36 +211,26 @@ export function SessionPromptDock(props: { <span data-slot="permission-icon"> <Icon name="warning" size="normal" /> </span> - <div data-slot="permission-header-title">{props.t("notification.permission.title")}</div> + <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div> </div> } footer={ <> <div /> <div data-slot="permission-footer-actions"> - <Button - variant="ghost" - size="normal" - onClick={() => props.onDecide("reject")} - disabled={props.responding} - > - {props.t("ui.permission.deny")} + <Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}> + {language.t("ui.permission.deny")} </Button> <Button variant="secondary" size="normal" - onClick={() => props.onDecide("always")} - disabled={props.responding} + onClick={() => decide("always")} + disabled={responding()} > - {props.t("ui.permission.allowAlways")} + {language.t("ui.permission.allowAlways")} </Button> - <Button - variant="primary" - size="normal" - onClick={() => props.onDecide("once")} - disabled={props.responding} - > - {props.t("ui.permission.allowOnce")} + <Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}> + {language.t("ui.permission.allowOnce")} </Button> </div> </> @@ -199,12 +259,12 @@ export function SessionPromptDock(props: { }} </Show> - <Show when={!props.blocked}> + <Show when={!blocked()}> <Show - when={props.promptReady} + when={prompt.ready()} fallback={ <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> - {props.handoffPrompt || props.t("prompt.loading")} + {handoffPrompt() || language.t("prompt.loading")} </div> } > @@ -219,10 +279,10 @@ export function SessionPromptDock(props: { }} > <SessionTodoDock - todos={props.todos} - title={props.t("session.todo.title")} - collapseLabel={props.t("session.todo.collapse")} - expandLabel={props.t("session.todo.expand")} + todos={todos()} + title={language.t("session.todo.title")} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} /> </div> </Show> 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<ReturnType<typeof useLayout>["view"]> - info: () => ReturnType<ReturnType<typeof useSync>["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<typeof useLanguage> - layout: ReturnType<typeof useLayout> - command: ReturnType<typeof useCommand> - dialog: ReturnType<typeof useDialog> - file: ReturnType<typeof useFile> - comments: ReturnType<typeof useComments> - hasReview: boolean - reviewCount: number - reviewTab: boolean - contextOpen: () => boolean - openedTabs: () => string[] - activeTab: () => string - activeFileTab: () => string | undefined - tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> - openTab: (value: string) => void - showAllFiles: () => void reviewPanel: () => JSX.Element - vm: SessionSidePanelViewModel - handoffFiles: () => Record<string, SelectedLineRange | null> | undefined - codeComponent: NonNullable<ValidComponent> - 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<string, "add" | "del" | "mix"> 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<string, "add" | "del" | "mix">() + 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<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={props.open}> + <Show when={open()}> <aside id="review-panel" - aria-label={props.language.t("session.panel.reviewAndFiles")} + aria-label={language.t("session.panel.reviewAndFiles")} class="relative min-w-0 h-full border-l border-border-weak-base flex" classList={{ - "flex-1": props.reviewOpen, - "shrink-0": !props.reviewOpen, + "flex-1": reviewOpen(), + "shrink-0": !reviewOpen(), }} - style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }} + style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }} > - <Show when={props.reviewOpen}> + <Show when={reviewOpen()}> <div class="flex-1 min-w-0 h-full"> <Show - when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"} + when={layout.fileTree.opened() && fileTreeTab() === "changes"} fallback={ <DragDropProvider - onDragStart={props.onDragStart} - onDragEnd={props.onDragEnd} - onDragOver={props.onDragOver} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} collisionDetector={closestCenter} > <DragDropSensors /> <ConstrainDragYAxis /> - <Tabs value={props.activeTab()} onChange={props.openTab}> + <Tabs value={activeTab()} onChange={openTab}> <div class="sticky top-0 shrink-0 flex"> <Tabs.List ref={(el: HTMLDivElement) => { - const stop = createFileTabListSync({ el, contextOpen: props.contextOpen }) + const stop = createFileTabListSync({ el, contextOpen }) onCleanup(stop) }} > - <Show when={props.reviewTab}> + <Show when={reviewTab()}> <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}> <div class="flex items-center gap-1.5"> - <div>{props.language.t("session.tab.review")}</div> - <Show when={props.hasReview}> + <div>{language.t("session.tab.review")}</div> + <Show when={hasReview()}> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> - {props.reviewCount} + {reviewCount()} </div> </Show> </div> </Tabs.Trigger> </Show> - <Show when={props.contextOpen()}> + <Show when={contextOpen()}> <Tabs.Trigger value="context" closeButton={ - <Tooltip value={props.language.t("common.closeTab")} placement="bottom"> + <Tooltip value={language.t("common.closeTab")} placement="bottom"> <IconButton icon="close-small" variant="ghost" class="h-5 w-5" - onClick={() => props.tabs().close("context")} - aria-label={props.language.t("common.closeTab")} + onClick={() => tabs().close("context")} + aria-label={language.t("common.closeTab")} /> </Tooltip> } hideCloseButton - onMiddleClick={() => props.tabs().close("context")} + onMiddleClick={() => tabs().close("context")} > <div class="flex items-center gap-2"> <SessionContextUsage variant="indicator" /> - <div>{props.language.t("session.tab.context")}</div> + <div>{language.t("session.tab.context")}</div> </div> </Tabs.Trigger> </Show> <SortableProvider ids={openedTabs()}> - <For each={openedTabs()}> - {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />} - </For> + <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> </SortableProvider> <StickyAddButton> <TooltipKeybind - title={props.language.t("command.file.open")} - keybind={props.command.keybind("file.open")} + title={language.t("command.file.open")} + keybind={command.keybind("file.open")} class="flex items-center" > <IconButton @@ -158,72 +271,52 @@ export function SessionSidePanel(props: { variant="ghost" iconSize="large" onClick={() => - props.dialog.show(() => ( - <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} /> - )) + dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />) } - aria-label={props.language.t("command.file.open")} + aria-label={language.t("command.file.open")} /> </TooltipKeybind> </StickyAddButton> </Tabs.List> </div> - <Show when={props.reviewTab}> + <Show when={reviewTab()}> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show> + <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> </Tabs.Content> </Show> <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={props.activeTab() === "empty"}> + <Show when={activeTab() === "empty"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> <Mark class="w-14 opacity-10" /> <div class="text-14-regular text-text-weak max-w-56"> - {props.language.t("session.files.selectToOpen")} + {language.t("session.files.selectToOpen")} </div> </div> </div> </Show> </Tabs.Content> - <Show when={props.contextOpen()}> + <Show when={contextOpen()}> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={props.activeTab() === "context"}> + <Show when={activeTab() === "context"}> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <SessionContextTab - messages={props.vm.messages} - visibleUserMessages={props.vm.visibleUserMessages} - view={props.vm.view} - info={props.vm.info} - /> + <SessionContextTab /> </div> </Show> </Tabs.Content> </Show> - <Show when={props.activeFileTab()} keyed> - {(tab) => ( - <FileTabContent - tab={tab} - activeTab={props.activeTab} - tabs={props.tabs} - view={props.vm.view} - handoffFiles={props.handoffFiles} - file={props.file} - comments={props.comments} - language={props.language} - codeComponent={props.codeComponent} - addCommentToContext={props.addCommentToContext} - /> - )} + <Show when={activeFileTab()} keyed> + {(tab) => <FileTabContent tab={tab} />} </Show> </Tabs> <DragOverlay> - <Show when={props.activeDraggable()}> + <Show when={store.activeDraggable} keyed> {(tab) => { - const path = createMemo(() => props.file.pathFromTab(tab())) + const path = createMemo(() => file.pathFromTab(tab)) return ( <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent"> <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> @@ -240,50 +333,44 @@ export function SessionSidePanel(props: { </div> </Show> - <Show when={props.layout.fileTree.opened()}> - <div - id="file-tree-panel" - class="relative shrink-0 h-full" - style={{ width: `${props.layout.fileTree.width()}px` }} - > + <Show when={layout.fileTree.opened()}> + <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> <div class="h-full flex flex-col overflow-hidden group/filetree" - classList={{ "border-l border-border-weak-base": props.reviewOpen }} + classList={{ "border-l border-border-weak-base": reviewOpen() }} > <Tabs variant="pill" - value={props.fileTreeTab()} - onChange={props.setFileTreeTabValue} + value={fileTreeTab()} + onChange={setFileTreeTabValue} class="h-full" data-scope="filetree" > <Tabs.List> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> - {props.reviewCount}{" "} - {props.language.t( - props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {reviewCount()}{" "} + {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} </Tabs.Trigger> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> - {props.language.t("session.files.all")} + {language.t("session.files.all")} </Tabs.Trigger> </Tabs.List> <Tabs.Content value="changes" class="bg-background-base px-3 py-0"> <Switch> - <Match when={props.hasReview}> + <Match when={hasReview()}> <Show - when={props.diffsReady} + when={diffsReady()} fallback={ <div class="px-2 py-2 text-12-regular text-text-weak"> - {props.language.t("common.loading")} - {props.language.t("common.loading.ellipsis")} + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} </div> } > <FileTree path="" - allowed={props.diffFiles} - kinds={props.kinds} + allowed={diffFiles()} + kinds={kinds()} draggable={false} active={props.activeDiff} onFileClick={(node) => props.focusReviewDiff(node.path)} @@ -292,7 +379,7 @@ export function SessionSidePanel(props: { </Match> <Match when={true}> <div class="mt-8 text-center text-12-regular text-text-weak"> - {props.language.t("session.review.noChanges")} + {language.t("session.review.noChanges")} </div> </Match> </Switch> @@ -300,9 +387,9 @@ export function SessionSidePanel(props: { <Tabs.Content value="all" class="bg-background-base px-3 py-0"> <FileTree path="" - modified={props.diffFiles} - kinds={props.kinds} - onFileClick={(node) => props.openTab(props.file.tab(node.path))} + modified={diffFiles()} + kinds={kinds()} + onFileClick={(node) => openTab(file.tab(node.path))} /> </Tabs.Content> </Tabs> @@ -310,12 +397,12 @@ export function SessionSidePanel(props: { <ResizeHandle direction="horizontal" edge="start" - size={props.layout.fileTree.width()} + size={layout.fileTree.width()} min={200} max={480} collapseThreshold={160} - onResize={props.layout.fileTree.resize} - onCollapse={props.layout.fileTree.close} + onResize={layout.fileTree.resize} + onCollapse={layout.fileTree.close} /> </div> </Show> diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 7ec4356b1..33421c386 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,61 +1,161 @@ -import { For, Show, createMemo } from "solid-js" +import { For, Show, createEffect, createMemo, on } 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 { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" + import { SortableTerminalTab } from "@/components/session" import { Terminal } from "@/components/terminal" -import { useTerminal } from "@/context/terminal" -import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useTerminal, type LocalPTY } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" +import { focusTerminalById } from "@/pages/session/helpers" +import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" + +export function TerminalPanel() { + const params = useParams() + const layout = useLayout() + const terminal = useTerminal() + const language = useLanguage() + const command = useCommand() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const view = createMemo(() => layout.view(sessionKey)) + + const opened = createMemo(() => view().terminal.opened()) + const open = createMemo(() => isDesktop() && opened()) + const height = createMemo(() => layout.terminal.height()) + const close = () => view().terminal.close() + + const [store, setStore] = createStore({ + autoCreated: false, + activeDraggable: undefined as string | undefined, + }) + + createEffect(() => { + if (!opened()) { + setStore("autoCreated", false) + return + } + + if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return + terminal.new() + setStore("autoCreated", true) + }) + + createEffect( + on( + () => terminal.all().length, + (count, prevCount) => { + if (prevCount !== undefined && prevCount > 0 && count === 0) { + if (opened()) view().terminal.toggle() + } + }, + ), + ) -export function TerminalPanel(props: { - open: boolean - height: number - resize: (value: number) => void - close: () => void - terminal: ReturnType<typeof useTerminal> - language: ReturnType<typeof useLanguage> - command: ReturnType<typeof useCommand> - handoff: () => string[] - activeTerminalDraggable: () => string | undefined - handleTerminalDragStart: (event: unknown) => void - handleTerminalDragOver: (event: DragEvent) => void - handleTerminalDragEnd: () => void - onCloseTab: () => void -}) { - const all = createMemo(() => props.terminal.all()) + createEffect( + on( + () => terminal.active(), + (activeId) => { + if (!activeId || !opened()) return + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + focusTerminalById(activeId) + }, + ), + ) + + createEffect(() => { + const dir = params.dir + if (!dir) return + if (!terminal.ready()) return + language.locale() + + setTerminalHandoff( + dir, + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string, + }), + ), + ) + }) + + const handoff = createMemo(() => { + const dir = params.dir + if (!dir) return [] + return getTerminalHandoff(dir) ?? [] + }) + + const all = createMemo(() => terminal.all()) const ids = createMemo(() => all().map((pty) => pty.id)) const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty]))) + const handleTerminalDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleTerminalDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + 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("activeDraggable", undefined) + + const activeId = terminal.active() + if (!activeId) return + setTimeout(() => { + focusTerminalById(activeId) + }, 0) + } + return ( - <Show when={props.open}> + <Show when={open()}> <div id="terminal-panel" role="region" - aria-label={props.language.t("terminal.title")} + aria-label={language.t("terminal.title")} class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" - style={{ height: `${props.height}px` }} + style={{ height: `${height()}px` }} > <ResizeHandle direction="vertical" - size={props.height} + size={height()} min={100} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} collapseThreshold={50} - onResize={props.resize} - onCollapse={props.close} + onResize={layout.terminal.resize} + onCollapse={close} /> <Show - when={props.terminal.ready()} + when={terminal.ready()} fallback={ <div class="flex flex-col h-full pointer-events-none"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> - <For each={props.handoff()}> + <For each={handoff()}> {(title) => ( <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> {title} @@ -64,20 +164,18 @@ export function TerminalPanel(props: { </For> <div class="flex-1" /> <div class="text-text-weak pr-2"> - {props.language.t("common.loading")} - {props.language.t("common.loading.ellipsis")} + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} </div> </div> - <div class="flex-1 flex items-center justify-center text-text-weak"> - {props.language.t("terminal.loading")} - </div> + <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div> </div> } > <DragDropProvider - onDragStart={props.handleTerminalDragStart} - onDragEnd={props.handleTerminalDragEnd} - onDragOver={props.handleTerminalDragOver} + onDragStart={handleTerminalDragStart} + onDragEnd={handleTerminalDragEnd} + onDragOver={handleTerminalDragOver} collisionDetector={closestCenter} > <DragDropSensors /> @@ -85,36 +183,26 @@ export function TerminalPanel(props: { <div class="flex flex-col h-full"> <Tabs variant="alt" - value={props.terminal.active()} - onChange={(id) => props.terminal.open(id)} + value={terminal.active()} + onChange={(id) => terminal.open(id)} class="!h-auto !flex-none" > <Tabs.List class="h-10"> <SortableProvider ids={ids()}> - <For each={all()}> - {(pty) => ( - <SortableTerminalTab - terminal={pty} - onClose={() => { - props.close() - props.onCloseTab() - }} - /> - )} - </For> + <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For> </SortableProvider> <div class="h-full flex items-center justify-center"> <TooltipKeybind - title={props.language.t("command.terminal.new")} - keybind={props.command.keybind("terminal.new")} + title={language.t("command.terminal.new")} + keybind={command.keybind("terminal.new")} class="flex items-center" > <IconButton icon="plus-small" variant="ghost" iconSize="large" - onClick={props.terminal.new} - aria-label={props.language.t("command.terminal.new")} + onClick={terminal.new} + aria-label={language.t("command.terminal.new")} /> </TooltipKeybind> </div> @@ -127,15 +215,11 @@ export function TerminalPanel(props: { id={`terminal-wrapper-${pty.id}`} class="absolute inset-0" style={{ - display: props.terminal.active() === pty.id ? "block" : "none", + display: terminal.active() === pty.id ? "block" : "none", }} > <Show when={pty.id} keyed> - <Terminal - pty={pty} - onCleanup={props.terminal.update} - onConnectError={() => props.terminal.clone(pty.id)} - /> + <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> </Show> </div> )} @@ -143,25 +227,20 @@ export function TerminalPanel(props: { </div> </div> <DragOverlay> - <Show when={props.activeTerminalDraggable()}> - {(draggedId) => { - return ( - <Show when={byId().get(draggedId())}> - {(t) => ( - <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> - {terminalTabLabel({ - title: t().title, - titleNumber: t().titleNumber, - t: props.language.t as ( - key: string, - vars?: Record<string, string | number | boolean>, - ) => string, - })} - </div> - )} - </Show> - ) - }} + <Show when={store.activeDraggable}> + {(draggedId) => ( + <Show when={byId().get(draggedId())}> + {(t) => ( + <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> + {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string, + })} + </div> + )} + </Show> + )} </Show> </DragOverlay> </DragDropProvider> |
