diff options
| author | Adam <[email protected]> | 2026-02-17 07:16:23 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-17 07:16:23 -0600 |
| commit | 10985671ad9553e7ac594ede30981166f69ba3c5 (patch) | |
| tree | 0f501ccba5dcf90e86beb18a0732826f7a3b1d0a /packages/app/src/pages | |
| parent | 3dfbb7059345350fdcb3f45fe9a44697c08a040a (diff) | |
| download | opencode-10985671ad9553e7ac594ede30981166f69ba3c5.tar.gz opencode-10985671ad9553e7ac594ede30981166f69ba3c5.zip | |
feat(app): session timeline/turn rework (#13196)
Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/directory-layout.tsx | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-shell.tsx | 4 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 81 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 54 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-prompt-dock.tsx | 138 | ||||
| -rw-r--r-- | packages/app/src/pages/session/use-session-commands.tsx | 368 |
7 files changed, 367 insertions, 287 deletions
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 2dee09dfb..4f1d93ab2 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} - onSyncSession={(sessionID: string) => sync.session.sync(sessionID)} > <LocalProvider>{props.children}</LocalProvider> </DataProvider> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7d4a5c0cb..cecb52617 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1710,7 +1710,7 @@ export default function Layout(props: ParentProps) { return ( <div classList={{ - "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true, + "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true, "flex-1 min-w-0": panelProps.mobile, }} style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} @@ -1725,8 +1725,8 @@ export default function Layout(props: ParentProps) { id={`project:${projectId()}`} value={projectName} onSave={(next) => renameProject(p(), next)} - class="text-16-medium text-text-strong truncate" - displayClass="text-16-medium text-text-strong truncate" + class="text-14-medium text-text-strong truncate" + displayClass="text-14-medium text-text-strong truncate" stopPropagation /> @@ -2042,7 +2042,7 @@ export default function Layout(props: ParentProps) { <main classList={{ "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true, - "xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(), + "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(), }} > <Show when={!autoselecting()} fallback={<div class="size-full" />}> diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index 23abdf157..d813ef3e1 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -51,7 +51,7 @@ export const SidebarContent = (props: { > <DragDropSensors /> <ConstrainDragXAxis /> - <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar"> + <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar"> <SortableProvider ids={props.projects().map((p) => p.worktree)}> <For each={props.projects()}>{(project) => props.renderProject(project)}</For> </SortableProvider> @@ -78,7 +78,7 @@ export const SidebarContent = (props: { <DragOverlay>{props.renderProjectOverlay()}</DragOverlay> </DragDropProvider> </div> - <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> + <div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2"> <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}> <IconButton icon="settings-gear" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d958990c2..23dc0304e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -5,7 +5,6 @@ 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 { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" @@ -20,9 +19,11 @@ 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 { useLayout } from "@/context/layout" import { checksum, base64Encode } from "@opencode-ai/util/encode" +import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" @@ -34,6 +35,7 @@ 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 { navMark, navParams } from "@/utils/perf" @@ -89,6 +91,7 @@ export default function Page() { const local = useLocal() const file = useFile() const sync = useSync() + const globalSync = useGlobalSync() const terminal = useTerminal() const dialog = useDialog() const codeComponent = useCodeComponent() @@ -99,6 +102,7 @@ export default function Page() { const sdk = useSDK() const prompt = usePrompt() const comments = useComments() + const permission = usePermission() const permRequest = createMemo(() => { const sessionID = params.id @@ -229,7 +233,7 @@ export default function Page() { }) } - const isDesktop = createMediaQuery("(min-width: 1024px)") + const isDesktop = createMediaQuery("(min-width: 768px)") const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) @@ -269,7 +273,6 @@ export default function Page() { if (!path) return file.load(path) openReviewPanel() - tabs().setActive(next) } createEffect(() => { @@ -554,13 +557,11 @@ export default function Page() { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, - expanded: {} as Record<string, boolean>, messageId: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "changes", changes: "session" as "session" | "turn", newSessionWorktree: "main", - promptHeight: 0, }) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) @@ -651,6 +652,7 @@ export default function Page() { const idle = { type: "idle" as const } let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined + let dockHeight = 0 let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined @@ -673,7 +675,8 @@ export default function Page() { sdk.directory const id = params.id if (!id) return - sync.session.sync(id) + void sync.session.sync(id) + void sync.session.todo(id) }) createEffect(() => { @@ -726,13 +729,17 @@ 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( sessionKey, () => { setStore("messageId", undefined) - setStore("expanded", {}) setStore("changes", "session") setUi("autoCreated", false) }, @@ -751,12 +758,6 @@ export default function Page() { ), ) - createEffect(() => { - const id = lastUserMessage()?.id - if (!id) return - setStore("expanded", id, status().type !== "idle") - }) - const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined @@ -767,6 +768,11 @@ export default function Page() { return lines.slice(0, 2).join("\n") } + const addSelectionToContext = (path: string, selection: FileSelection) => { + const preview = selectionPreview(path, selection) + prompt.context.add({ type: "file", path, selection, preview }) + } + const addCommentToContext = (input: { file: string selection: SelectedLineRange @@ -806,8 +812,8 @@ export default function Page() { return } - // Don't autofocus chat if terminal panel is open - if (view().terminal.opened()) return + // Don't autofocus chat if desktop terminal panel is open + if (isDesktop() && view().terminal.opened()) return // Only treat explicit scroll keys as potential "user scroll" gestures. if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { @@ -905,11 +911,29 @@ export default function Page() { const focusInput = () => inputRef?.focus() useSessionCommands({ - activeMessage, + command, + dialog, + file, + language, + local, + permission, + prompt, + sdk, + sync, + terminal, + layout, + params, + navigate, + tabs, + view, + info, + status, + userMessages, + visibleUserMessages, showAllFiles, navigateMessageByOffset, - setExpanded: (id, fn) => setStore("expanded", id, fn), setActiveMessage, + addSelectionToContext, focusInput, }) @@ -933,7 +957,6 @@ export default function Page() { onSelect={(option) => option && setStore("changes", option)} variant="ghost" size="large" - triggerStyle={{ "font-size": "var(--font-size-large)" }} /> ) @@ -1421,12 +1444,12 @@ export default function Page() { ({ height }) => { const next = Math.ceil(height) - if (next === store.promptHeight) return + if (next === dockHeight) return const el = scroller const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false - setStore("promptHeight", next) + dockHeight = next if (stick && el) { requestAnimationFrame(() => { @@ -1524,13 +1547,7 @@ export default function Page() { return ( <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> - <div - class="flex-1 min-h-0 flex" - classList={{ - "flex-col": !isDesktop(), - "flex-row": isDesktop(), - }} - > + <div class="flex-1 min-h-0 flex flex-col md:flex-row"> <SessionMobileTabs open={!isDesktop() && !!params.id} mobileTab={store.mobileTab} @@ -1545,12 +1562,11 @@ export default function Page() { <div classList={{ "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true, - "flex-1 pt-2 md:pt-3": true, + "flex-1": true, "md:flex-none": desktopSidePanelOpen(), }} style={{ width: sessionPanelWidth(), - "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined, }} > <div class="flex-1 min-h-0 overflow-hidden"> @@ -1562,7 +1578,7 @@ export default function Page() { mobileFallback={reviewContent({ diffStyle: "unified", classes: { - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-8", header: "px-4", container: "px-4", }, @@ -1627,8 +1643,6 @@ export default function Page() { navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) }} lastUserMessageID={lastUserMessage()?.id} - expanded={store.expanded} - onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)} /> </Show> </Match> @@ -1659,6 +1673,7 @@ export default function Page() { 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} @@ -1731,7 +1746,7 @@ export default function Page() { </div> <TerminalPanel - open={view().terminal.opened()} + open={isDesktop() && view().terminal.opened()} height={layout.terminal.height()} resize={layout.terminal.resize} close={view().terminal.close} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index d5f04ccf9..53cf441d6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -4,10 +4,10 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { InlineInput } from "@opencode-ai/ui/inline-input" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { SessionTurn } from "@opencode-ai/ui/session-turn" import type { UserMessage } from "@opencode-ai/sdk/v2" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" +import { SessionContextUsage } from "@/components/session-context-usage" const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined @@ -88,8 +88,6 @@ export function MessageTimeline(props: { onUnregisterMessage: (id: string) => void onFirstTurnMount?: () => void lastUserMessageID?: string - expanded: Record<string, boolean> - onToggleExpanded: (id: string) => void }) { let touchGesture: number | undefined @@ -100,7 +98,7 @@ export function MessageTimeline(props: { > <div class="relative w-full h-full min-w-0"> <div - class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out" + class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out" classList={{ "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom, "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom, @@ -164,14 +162,15 @@ export function MessageTimeline(props: { <Show when={props.showHeader}> <div classList={{ - "sticky top-0 z-30 bg-background-stronger": true, + "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "w-full": true, - "px-4 md:px-6": true, + "pb-4": true, + "pl-2 pr-4 md:pl-4 md:pr-6": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - <div class="h-10 w-full flex items-center justify-between gap-2"> - <div class="flex items-center gap-1 min-w-0 flex-1"> + <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}> <IconButton tabIndex={-1} @@ -185,7 +184,10 @@ export function MessageTimeline(props: { <Show when={props.titleState.editing} fallback={ - <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}> + <h1 + class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" + onDblClick={props.openTitleEditor} + > {props.title} </h1> } @@ -194,7 +196,8 @@ export function MessageTimeline(props: { ref={props.titleRef} value={props.titleState.draft} disabled={props.titleState.saving} - class="text-16-medium text-text-strong grow-1 min-w-0" + 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)} onKeyDown={(event) => { event.stopPropagation() @@ -215,19 +218,24 @@ export function MessageTimeline(props: { </div> <Show when={props.sessionID}> {(id) => ( - <div class="shrink-0 flex items-center"> - <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}> - <Tooltip value={props.t("common.moreOptions")} placement="top"> - <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")} - /> - </Tooltip> + <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} + > + <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")} + /> <DropdownMenu.Portal> <DropdownMenu.Content + style={{ "min-width": "104px" }} onCloseAutoFocus={(event) => { if (!props.titleState.pendingRename) return event.preventDefault() @@ -263,7 +271,7 @@ export function MessageTimeline(props: { <div ref={props.setContentRef} role="log" - class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" + class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" classList={{ "w-full": true, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, @@ -316,8 +324,6 @@ export function MessageTimeline(props: { sessionID={props.sessionID} messageID={message.id} lastUserMessageID={props.lastUserMessageID} - stepsExpanded={props.expanded[message.id] ?? false} - onStepsExpandedToggle={() => props.onToggleExpanded(message.id)} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 8ec4f3b9f..2b9571982 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -1,16 +1,17 @@ -import { For, Show } from "solid-js" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { Button } from "@opencode-ai/ui/button" import { BasicTool } from "@opencode-ai/ui/basic-tool" import { PromptInput } from "@/components/prompt-input" import { QuestionDock } from "@/components/question-dock" -import { questionSubtitle } from "@/pages/session/session-prompt-helpers" +import { SessionTodoDock } from "@/components/session-todo-dock" 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 @@ -22,10 +23,88 @@ export function SessionPromptDock(props: { onSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void }) { + const done = createMemo( + () => + props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + const [dock, setDock] = createSignal(props.todos.length > 0) + const [closing, setClosing] = createSignal(false) + const [opening, setOpening] = createSignal(false) + let timer: number | undefined + let raf: number | undefined + + const scheduleClose = () => { + if (timer) window.clearTimeout(timer) + timer = window.setTimeout(() => { + setDock(false) + setClosing(false) + timer = undefined + }, 400) + } + + createEffect( + on( + () => [props.todos.length, done()] as const, + ([count, complete], prev) => { + if (raf) cancelAnimationFrame(raf) + raf = undefined + + if (count === 0) { + if (timer) window.clearTimeout(timer) + timer = undefined + setDock(false) + setClosing(false) + setOpening(false) + return + } + + if (!complete) { + if (timer) window.clearTimeout(timer) + timer = undefined + const wasHidden = !dock() || closing() + setDock(true) + setClosing(false) + if (wasHidden) { + setOpening(true) + raf = requestAnimationFrame(() => { + setOpening(false) + raf = undefined + }) + return + } + setOpening(false) + return + } + + if (prev && prev[1]) { + if (closing() && !timer) scheduleClose() + return + } + + setDock(true) + setOpening(false) + setClosing(true) + scheduleClose() + }, + ), + ) + + onCleanup(() => { + if (!timer) return + window.clearTimeout(timer) + }) + + onCleanup(() => { + if (!raf) return + cancelAnimationFrame(raf) + }) + return ( <div ref={props.setPromptDockRef} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + data-component="session-prompt-dock" + class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none" > <div classList={{ @@ -35,18 +114,8 @@ export function SessionPromptDock(props: { > <Show when={props.questionRequest()} keyed> {(req) => { - const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key)) return ( - <div data-component="tool-part-wrapper" data-question="true" class="mb-3"> - <BasicTool - icon="bubble-5" - locked - defaultOpen - trigger={{ - title: props.t("ui.tool.questions"), - subtitle, - }} - /> + <div> <QuestionDock request={req} /> </div> ) @@ -122,12 +191,39 @@ export function SessionPromptDock(props: { </div> } > - <PromptInput - ref={props.inputRef} - newSessionWorktree={props.newSessionWorktree} - onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} - onSubmit={props.onSubmit} - /> + <Show when={dock()}> + <div + classList={{ + "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true, + "max-h-[320px]": !closing(), + "max-h-0 pointer-events-none": closing(), + "opacity-0 translate-y-9": closing() || opening(), + "opacity-100 translate-y-0": !closing() && !opening(), + }} + > + <SessionTodoDock + todos={props.todos} + title={props.t("session.todo.title")} + collapseLabel={props.t("session.todo.collapse")} + expandLabel={props.t("session.todo.expand")} + /> + </div> + </Show> + <div + classList={{ + "relative z-10": true, + "transition-[margin] duration-[400ms] ease-out": true, + "-mt-9": dock() && !closing(), + "mt-0": !dock() || closing(), + }} + > + <PromptInput + ref={props.inputRef} + newSessionWorktree={props.newSessionWorktree} + onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} + onSubmit={props.onSubmit} + /> + </div> </Show> </Show> </div> diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index d2f74288f..4d68afd66 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -22,11 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import { canAddSelectionContext } from "@/pages/session/session-command-helpers" export type SessionCommandContext = { - activeMessage: () => UserMessage | undefined + command: ReturnType<typeof useCommand> + dialog: ReturnType<typeof useDialog> + file: ReturnType<typeof useFile> + language: ReturnType<typeof useLanguage> + local: ReturnType<typeof useLocal> + permission: ReturnType<typeof usePermission> + prompt: ReturnType<typeof usePrompt> + sdk: ReturnType<typeof useSDK> + sync: ReturnType<typeof useSync> + terminal: ReturnType<typeof useTerminal> + layout: ReturnType<typeof useLayout> + params: ReturnType<typeof useParams> + navigate: ReturnType<typeof useNavigate> + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined + status: () => { type: string } + userMessages: () => UserMessage[] + visibleUserMessages: () => UserMessage[] showAllFiles: () => void navigateMessageByOffset: (offset: number) => void - setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void setActiveMessage: (message: UserMessage | undefined) => void + addSelectionToContext: (path: string, selection: FileSelection) => void focusInput: () => void } @@ -37,88 +55,45 @@ const withCategory = (category: string) => { }) } -export const useSessionCommands = (args: SessionCommandContext) => { - const command = useCommand() - const dialog = useDialog() - const file = useFile() - const language = useLanguage() - const local = useLocal() - const permission = usePermission() - const prompt = usePrompt() - const sdk = useSDK() - const sync = useSync() - const terminal = useTerminal() - const layout = useLayout() - const params = useParams() - const navigate = useNavigate() - - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const idle = { type: "idle" as const } - const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[]) - const visibleUserMessages = createMemo(() => { - const revert = info()?.revert?.messageID - if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) - }) - - const selectionPreview = (path: string, selection: FileSelection) => { - const content = file.get(path)?.content?.content - if (!content) return undefined - const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) - const end = Math.max(selection.startLine, selection.endLine) - const lines = content.split("\n").slice(start - 1, end) - if (lines.length === 0) return undefined - return lines.slice(0, 2).join("\n") - } - - const addSelectionToContext = (path: string, selection: FileSelection) => { - const preview = selectionPreview(path, selection) - prompt.context.add({ type: "file", path, selection, preview }) - } - - const sessionCommand = withCategory(language.t("command.category.session")) - const fileCommand = withCategory(language.t("command.category.file")) - const contextCommand = withCategory(language.t("command.category.context")) - const viewCommand = withCategory(language.t("command.category.view")) - const terminalCommand = withCategory(language.t("command.category.terminal")) - const modelCommand = withCategory(language.t("command.category.model")) - const mcpCommand = withCategory(language.t("command.category.mcp")) - const agentCommand = withCategory(language.t("command.category.agent")) - const permissionsCommand = withCategory(language.t("command.category.permissions")) +export const useSessionCommands = (input: SessionCommandContext) => { + const sessionCommand = withCategory(input.language.t("command.category.session")) + const fileCommand = withCategory(input.language.t("command.category.file")) + const contextCommand = withCategory(input.language.t("command.category.context")) + const viewCommand = withCategory(input.language.t("command.category.view")) + const terminalCommand = withCategory(input.language.t("command.category.terminal")) + const modelCommand = withCategory(input.language.t("command.category.model")) + const mcpCommand = withCategory(input.language.t("command.category.mcp")) + const agentCommand = withCategory(input.language.t("command.category.agent")) + const permissionsCommand = withCategory(input.language.t("command.category.permissions")) const sessionCommands = createMemo(() => [ sessionCommand({ id: "session.new", - title: language.t("command.session.new"), + title: input.language.t("command.session.new"), keybind: "mod+shift+s", slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), + onSelect: () => input.navigate(`/${input.params.dir}/session`), }), ]) const fileCommands = createMemo(() => [ fileCommand({ id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), + title: input.language.t("command.file.open"), + description: input.language.t("palette.search.placeholder"), keybind: "mod+p", slash: "open", - onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />), + onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />), }), fileCommand({ id: "tab.close", - title: language.t("command.tab.close"), + title: input.language.t("command.tab.close"), keybind: "mod+w", - disabled: !tabs().active(), + disabled: !input.tabs().active(), onSelect: () => { - const active = tabs().active() + const active = input.tabs().active() if (!active) return - tabs().close(active) + input.tabs().close(active) }, }), ]) @@ -126,30 +101,30 @@ export const useSessionCommands = (args: SessionCommandContext) => { const contextCommands = createMemo(() => [ contextCommand({ id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), + title: input.language.t("command.context.addSelection"), + description: input.language.t("command.context.addSelection.description"), keybind: "mod+shift+l", disabled: !canAddSelectionContext({ - active: tabs().active(), - pathFromTab: file.pathFromTab, - selectedLines: file.selectedLines, + active: input.tabs().active(), + pathFromTab: input.file.pathFromTab, + selectedLines: input.file.selectedLines, }), onSelect: () => { - const active = tabs().active() + const active = input.tabs().active() if (!active) return - const path = file.pathFromTab(active) + const path = input.file.pathFromTab(active) if (!path) return - const range = file.selectedLines(path) as SelectedLineRange | null | undefined + const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined if (!range) { showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), + title: input.language.t("toast.context.noLineSelection.title"), + description: input.language.t("toast.context.noLineSelection.description"), }) return } - addSelectionToContext(path, selectionFromLines(range)) + input.addSelectionToContext(path, selectionFromLines(range)) }, }), ]) @@ -157,50 +132,37 @@ export const useSessionCommands = (args: SessionCommandContext) => { const viewCommands = createMemo(() => [ viewCommand({ id: "terminal.toggle", - title: language.t("command.terminal.toggle"), + title: input.language.t("command.terminal.toggle"), keybind: "ctrl+`", slash: "terminal", - onSelect: () => view().terminal.toggle(), + onSelect: () => input.view().terminal.toggle(), }), viewCommand({ id: "review.toggle", - title: language.t("command.review.toggle"), + title: input.language.t("command.review.toggle"), keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), + onSelect: () => input.view().reviewPanel.toggle(), }), viewCommand({ id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), + title: input.language.t("command.fileTree.toggle"), keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), + onSelect: () => input.layout.fileTree.toggle(), }), viewCommand({ id: "input.focus", - title: language.t("command.input.focus"), + title: input.language.t("command.input.focus"), keybind: "ctrl+l", - onSelect: () => args.focusInput(), + onSelect: () => input.focusInput(), }), terminalCommand({ id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), + title: input.language.t("command.terminal.new"), + description: input.language.t("command.terminal.new.description"), keybind: "ctrl+alt+t", onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }), - viewCommand({ - id: "steps.toggle", - title: language.t("command.steps.toggle"), - description: language.t("command.steps.toggle.description"), - keybind: "mod+e", - slash: "steps", - disabled: !params.id, - onSelect: () => { - const msg = args.activeMessage() - if (!msg) return - args.setExpanded(msg.id, (open: boolean | undefined) => !open) + if (input.terminal.all().length > 0) input.terminal.new() + input.view().terminal.open() }, }), ]) @@ -208,61 +170,61 @@ export const useSessionCommands = (args: SessionCommandContext) => { const messageCommands = createMemo(() => [ sessionCommand({ id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), + title: input.language.t("command.message.previous"), + description: input.language.t("command.message.previous.description"), keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => args.navigateMessageByOffset(-1), + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(-1), }), sessionCommand({ id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), + title: input.language.t("command.message.next"), + description: input.language.t("command.message.next.description"), keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => args.navigateMessageByOffset(1), + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(1), }), ]) const agentCommands = createMemo(() => [ modelCommand({ id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), + title: input.language.t("command.model.choose"), + description: input.language.t("command.model.choose.description"), keybind: "mod+'", slash: "model", - onSelect: () => dialog.show(() => <DialogSelectModel />), + onSelect: () => input.dialog.show(() => <DialogSelectModel />), }), mcpCommand({ id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), + title: input.language.t("command.mcp.toggle"), + description: input.language.t("command.mcp.toggle.description"), keybind: "mod+;", slash: "mcp", - onSelect: () => dialog.show(() => <DialogSelectMcp />), + onSelect: () => input.dialog.show(() => <DialogSelectMcp />), }), agentCommand({ id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), + title: input.language.t("command.agent.cycle"), + description: input.language.t("command.agent.cycle.description"), keybind: "mod+.", slash: "agent", - onSelect: () => local.agent.move(1), + onSelect: () => input.local.agent.move(1), }), agentCommand({ id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), + title: input.language.t("command.agent.cycle.reverse"), + description: input.language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), + onSelect: () => input.local.agent.move(-1), }), modelCommand({ id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), + title: input.language.t("command.model.variant.cycle"), + description: input.language.t("command.model.variant.cycle.description"), keybind: "shift+mod+d", onSelect: () => { - local.model.variant.cycle() + input.local.model.variant.cycle() }, }), ]) @@ -271,22 +233,22 @@ export const useSessionCommands = (args: SessionCommandContext) => { permissionsCommand({ id: "permissions.autoaccept", title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) + ? input.language.t("command.permissions.autoaccept.disable") + : input.language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), + disabled: !input.params.id || !input.permission.permissionsEnabled(), onSelect: () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) + input.permission.toggleAutoAccept(sessionID, input.sdk.directory) showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), + title: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.title") + : input.language.t("toast.permissions.autoaccept.off.title"), + description: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.description") + : input.language.t("toast.permissions.autoaccept.off.description"), }) }, }), @@ -295,71 +257,71 @@ export const useSessionCommands = (args: SessionCommandContext) => { const sessionActionCommands = createMemo(() => [ sessionCommand({ id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), + title: input.language.t("command.session.undo"), + description: input.language.t("command.session.undo.description"), slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !input.params.id || input.visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) + if (input.status()?.type !== "idle") { + await input.sdk.client.session.abort({ sessionID }).catch(() => {}) } - const revert = info()?.revert?.messageID - const message = findLast(userMessages(), (x) => !revert || x.id < revert) + const revert = input.info()?.revert?.messageID + const message = findLast(input.userMessages(), (x) => !revert || x.id < revert) if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) - const parts = sync.data.part[message.id] + await input.sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = input.sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) + const restored = extractPromptFromParts(parts, { directory: input.sdk.directory }) + input.prompt.set(restored) } - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - args.setActiveMessage(priorMessage) + const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) + input.setActiveMessage(priorMessage) }, }), sessionCommand({ id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), + title: input.language.t("command.session.redo"), + description: input.language.t("command.session.redo.description"), slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, + disabled: !input.params.id || !input.info()?.revert?.messageID, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - const revertMessageID = info()?.revert?.messageID + const revertMessageID = input.info()?.revert?.messageID if (!revertMessageID) return - const nextMessage = userMessages().find((x) => x.id > revertMessageID) + const nextMessage = input.userMessages().find((x) => x.id > revertMessageID) if (!nextMessage) { - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - args.setActiveMessage(lastMsg) + await input.sdk.client.session.unrevert({ sessionID }) + input.prompt.reset() + const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID) + input.setActiveMessage(lastMsg) return } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - args.setActiveMessage(priorMsg) + await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) + input.setActiveMessage(priorMsg) }, }), sessionCommand({ id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), + title: input.language.t("command.session.compact"), + description: input.language.t("command.session.compact.description"), slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !input.params.id || input.visibleUserMessages().length === 0, onSelect: async () => { - const sessionID = params.id + const sessionID = input.params.id if (!sessionID) return - const model = local.model.current() + const model = input.local.model.current() if (!model) { showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), + title: input.language.t("toast.model.none.title"), + description: input.language.t("toast.model.none.description"), }) return } - await sdk.client.session.summarize({ + await input.sdk.client.session.summarize({ sessionID, modelID: model.id, providerID: model.provider.id, @@ -368,27 +330,29 @@ export const useSessionCommands = (args: SessionCommandContext) => { }), sessionCommand({ id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), + title: input.language.t("command.session.fork"), + description: input.language.t("command.session.fork.description"), slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => <DialogFork />), + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: () => input.dialog.show(() => <DialogFork />), }), ]) const shareCommands = createMemo(() => { - if (sync.data.config.share === "disabled") return [] + if (input.sync.data.config.share === "disabled") return [] return [ sessionCommand({ id: "session.share", - title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), - description: info()?.share?.url - ? language.t("toast.session.share.success.description") - : language.t("command.session.share.description"), + title: input.info()?.share?.url + ? input.language.t("session.share.copy.copyLink") + : input.language.t("command.session.share"), + description: input.info()?.share?.url + ? input.language.t("toast.session.share.success.description") + : input.language.t("command.session.share.description"), slash: "share", - disabled: !params.id, + disabled: !input.params.id, onSelect: async () => { - if (!params.id) return + if (!input.params.id) return const write = (value: string) => { const body = typeof document === "undefined" ? undefined : document.body @@ -418,7 +382,7 @@ export const useSessionCommands = (args: SessionCommandContext) => { const ok = await write(url) if (!ok) { showToast({ - title: language.t("toast.session.share.copyFailed.title"), + title: input.language.t("toast.session.share.copyFailed.title"), variant: "error", }) return @@ -426,27 +390,27 @@ export const useSessionCommands = (args: SessionCommandContext) => { showToast({ title: existing - ? language.t("session.share.copy.copied") - : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), + ? input.language.t("session.share.copy.copied") + : input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), variant: "success", }) } - const existing = info()?.share?.url + const existing = input.info()?.share?.url if (existing) { await copy(existing, true) return } - const url = await sdk.client.session - .share({ sessionID: params.id }) + const url = await input.sdk.client.session + .share({ sessionID: input.params.id }) .then((res) => res.data?.share?.url) .catch(() => undefined) if (!url) { showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), variant: "error", }) return @@ -457,25 +421,25 @@ export const useSessionCommands = (args: SessionCommandContext) => { }), sessionCommand({ id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), + title: input.language.t("command.session.unshare"), + description: input.language.t("command.session.unshare.description"), slash: "unshare", - disabled: !params.id || !info()?.share?.url, + disabled: !input.params.id || !input.info()?.share?.url, onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) + if (!input.params.id) return + await input.sdk.client.session + .unshare({ sessionID: input.params.id }) .then(() => showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), + title: input.language.t("toast.session.unshare.success.title"), + description: input.language.t("toast.session.unshare.success.description"), variant: "success", }), ) .catch(() => showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), + title: input.language.t("toast.session.unshare.failed.title"), + description: input.language.t("toast.session.unshare.failed.description"), variant: "error", }), ) @@ -484,7 +448,7 @@ export const useSessionCommands = (args: SessionCommandContext) => { ] }) - command.register("session", () => + input.command.register("session", () => [ sessionCommands(), fileCommands(), @@ -495,6 +459,6 @@ export const useSessionCommands = (args: SessionCommandContext) => { permissionCommands(), sessionActionCommands(), shareCommands(), - ].flatMap((section) => section), + ].flatMap((x) => x), ) } |
