diff options
| author | Adam <[email protected]> | 2026-03-12 11:32:05 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-12 11:32:05 -0500 |
| commit | dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2 (patch) | |
| tree | 31cd7d7aa33733579134e9a6cf3a61762599d8e0 /packages/app/src/pages | |
| parent | 0e077f748352df6d44c811829baff3c26b3436ac (diff) | |
| download | opencode-dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2.tar.gz opencode-dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2.zip | |
chore: cleanup (#17197)
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/session.tsx | 126 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-region.tsx | 31 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-todo-dock.tsx | 46 | ||||
| -rw-r--r-- | packages/app/src/pages/session/file-tabs.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/pages/session/helpers.test.ts | 73 | ||||
| -rw-r--r-- | packages/app/src/pages/session/helpers.ts | 74 | ||||
| -rw-r--r-- | packages/app/src/pages/session/review-tab.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-mobile-tabs.tsx | 41 | ||||
| -rw-r--r-- | packages/app/src/pages/session/session-side-panel.tsx | 45 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 29 | ||||
| -rw-r--r-- | packages/app/src/pages/session/use-session-commands.tsx | 756 |
11 files changed, 650 insertions, 591 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2454acf4d..7642ac165 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -19,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" +import { Tabs } from "@opencode-ai/ui/tabs" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" @@ -36,12 +37,11 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers" +import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" -import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" @@ -373,18 +373,22 @@ export default function Page() { if (!view().reviewPanel.opened()) view().reviewPanel.open() } - createEffect(() => { - const active = tabs().active() - if (!active) return - - const path = file.pathFromTab(active) - if (path) file.load(path) - }) - 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 reviewTab = createMemo(() => isDesktop()) + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: reviewTab, + hasReview, + }) + const contextOpen = tabState.contextOpen + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { @@ -421,6 +425,14 @@ export default function Page() { ) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) + createEffect(() => { + const tab = activeFileTab() + if (!tab) return + + const path = file.pathFromTab(tab) + if (path) file.load(path) + }) + createEffect( on( () => lastUserMessage()?.id, @@ -806,15 +818,7 @@ export default function Page() { } } - const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) - const openedTabs = createMemo(() => - tabs() - .all() - .filter((tab) => tab !== "context" && tab !== "review"), - ) - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const reviewTab = createMemo(() => isDesktop()) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -850,6 +854,7 @@ export default function Page() { navigateMessageByOffset, setActiveMessage, focusInput, + review: reviewTab, }) const openReviewFile = createOpenReviewFile({ @@ -964,11 +969,10 @@ export default function Page() { createEffect( on( - () => tabs().active(), + activeFileTab, (active) => { if (!active) return if (fileTreeTab() !== "changes") return - if (!file.pathFromTab(active)) return showAllFiles() }, { defer: true }, @@ -1011,8 +1015,7 @@ export default function Page() { const focusReviewDiff = (path: string) => { openReviewPanel() - const current = view().review.open() ?? [] - if (!current.includes(path)) view().review.setOpen([...current, path]) + view().review.openPath(path) setTree({ activeDiff: path, pendingDiff: path }) } @@ -1057,29 +1060,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - 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" - }) - - createEffect(() => { - if (!layout.ready()) return - if (tabs().active()) return - if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return - - const next = activeTab() - if (next === "empty") return - tabs().setActive(next) - }) - createEffect(() => { const id = params.id if (!id) return @@ -1146,9 +1126,9 @@ export default function Page() { () => { void file.tree.list("") - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) + const tab = activeFileTab() + if (!tab) return + const path = file.pathFromTab(tab) if (!path) return void file.load(path, { force: true }) }, @@ -1400,14 +1380,30 @@ export default function Page() { <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> <div class="flex-1 min-h-0 flex flex-col md:flex-row"> - <SessionMobileTabs - open={!isDesktop() && !!params.id} - mobileTab={store.mobileTab} - hasReview={hasReview()} - reviewCount={reviewCount()} - onSession={() => setStore("mobileTab", "session")} - onChanges={() => setStore("mobileTab", "changes")} - /> + <Show when={!isDesktop() && !!params.id}> + <Tabs value={store.mobileTab} class="h-auto"> + <Tabs.List> + <Tabs.Trigger + value="session" + class="!w-1/2 !max-w-none" + classes={{ button: "w-full" }} + onClick={() => setStore("mobileTab", "session")} + > + {language.t("session.tab.session")} + </Tabs.Trigger> + <Tabs.Trigger + value="changes" + class="!w-1/2 !max-w-none !border-r-0" + classes={{ button: "w-full" }} + onClick={() => setStore("mobileTab", "changes")} + > + {hasReview() + ? language.t("session.review.filesChanged", { count: reviewCount() }) + : language.t("session.review.change.other")} + </Tabs.Trigger> + </Tabs.List> + </Tabs> + </Show> {/* Session panel */} <div @@ -1467,23 +1463,7 @@ export default function Page() { </Show> </Match> <Match when={true}> - <NewSessionView - worktree={newSessionWorktree()} - onWorktreeChange={(value) => { - if (value === "create") { - setStore("newSessionWorktree", value) - return - } - - setStore("newSessionWorktree", "main") - - const target = value === "main" ? sync.project?.worktree : value - if (!target) return - if (target === sdk.directory) return - layout.projects.open(target) - navigate(`/${base64Encode(target)}/session`) - }} - /> + <NewSessionView worktree={newSessionWorktree()} /> </Match> </Switch> </div> diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 964bf18dd..6d60d81b5 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,4 +1,5 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { Show, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" @@ -50,7 +51,11 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [ready, setReady] = createSignal(false) + const [store, setStore] = createStore({ + ready: false, + height: 320, + body: undefined as HTMLDivElement | undefined, + }) let timer: number | undefined let frame: number | undefined @@ -67,17 +72,17 @@ export function SessionComposerRegion(props: { createEffect(() => { sessionKey() - const active = props.ready + const ready = props.ready const delay = 140 clear() - setReady(false) - if (!active) return + setStore("ready", false) + if (!ready) return frame = requestAnimationFrame(() => { frame = undefined timer = window.setTimeout(() => { - setReady(true) + setStore("ready", true) timer = undefined }, delay) }) @@ -85,21 +90,19 @@ export function SessionComposerRegion(props: { onCleanup(clear) - const open = createMemo(() => ready() && props.state.dock() && !props.state.closing()) + const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing()) const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (ready() && props.state.dock()) || value() > 0.001) + const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001) const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) const lift = createMemo(() => (rolled() ? 18 : 36 * value())) - const full = createMemo(() => Math.max(78, height())) - const [contentRef, setContentRef] = createSignal<HTMLDivElement>() + const full = createMemo(() => Math.max(78, store.height)) createEffect(() => { - const el = contentRef() + const el = store.body if (!el) return const update = () => { - setHeight(el.getBoundingClientRect().height) + setStore("height", el.getBoundingClientRect().height) } update() const observer = new ResizeObserver(update) @@ -174,7 +177,7 @@ export function SessionComposerRegion(props: { "max-height": `${full() * value()}px`, }} > - <div ref={setContentRef}> + <div ref={(el) => setStore("body", el)}> <SessionTodoDock todos={props.state.todos()} title={language.t("session.todo.title")} diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index baea51593..c7907bb54 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -6,7 +6,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { useSpring } from "@opencode-ai/ui/motion-spring" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" -import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined @@ -40,8 +41,12 @@ export function SessionTodoDock(props: { expandLabel: string dockProgress: number }) { - const [collapsed, setCollapsed] = createSignal(false) - const toggle = () => setCollapsed((value) => !value) + const [store, setStore] = createStore({ + collapsed: false, + height: 320, + }) + + const toggle = () => setStore("collapsed", (value) => !value) const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) @@ -56,22 +61,21 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) const hide = createMemo(() => Math.max(value(), shut())) const off = createMemo(() => hide() > 0.98) const turn = createMemo(() => Math.max(0, Math.min(1, value()))) - const [height, setHeight] = createSignal(320) - const full = createMemo(() => Math.max(78, height())) + const full = createMemo(() => Math.max(78, store.height)) let contentRef: HTMLDivElement | undefined createEffect(() => { const el = contentRef if (!el) return const update = () => { - setHeight(el.getBoundingClientRect().height) + setStore("height", el.getBoundingClientRect().height) } update() const observer = new ResizeObserver(update) @@ -127,7 +131,7 @@ export function SessionTodoDock(props: { > <TextReveal class="text-14-regular text-text-base cursor-default" - text={collapsed() ? preview() : undefined} + text={store.collapsed ? preview() : undefined} duration={600} travel={25} edge={17} @@ -140,7 +144,7 @@ export function SessionTodoDock(props: { <div class="ml-auto"> <IconButton data-action="session-todo-toggle-button" - data-collapsed={collapsed() ? "true" : "false"} + data-collapsed={store.collapsed ? "true" : "false"} icon="chevron-down" size="normal" variant="ghost" @@ -153,14 +157,14 @@ export function SessionTodoDock(props: { event.stopPropagation() toggle() }} - aria-label={collapsed() ? props.expandLabel : props.collapseLabel} + aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} /> </div> </div> <div data-slot="session-todo-list" - aria-hidden={collapsed() || off()} + aria-hidden={store.collapsed || off()} classList={{ "pointer-events-none": hide() > 0.1, }} @@ -169,7 +173,7 @@ export function SessionTodoDock(props: { opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, }} > - <TodoList todos={props.todos} open={!collapsed()} /> + <TodoList todos={props.todos} open={!store.collapsed} /> </div> </div> </DockTray> @@ -177,8 +181,10 @@ export function SessionTodoDock(props: { } function TodoList(props: { todos: Todo[]; open: boolean }) { - const [stuck, setStuck] = createSignal(false) - const [scrolling, setScrolling] = createSignal(false) + const [store, setStore] = createStore({ + stuck: false, + scrolling: false, + }) let scrollRef!: HTMLDivElement let timer: number | undefined @@ -186,7 +192,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { const ensure = () => { if (!props.open) return - if (scrolling()) return + if (store.scrolling) return if (!scrollRef || scrollRef.offsetParent === null) return const el = scrollRef.querySelector("[data-in-progress]") @@ -207,7 +213,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) } - setStuck(scrollRef.scrollTop > 0) + setStore("stuck", scrollRef.scrollTop > 0) } createEffect( @@ -229,11 +235,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { ref={scrollRef} style={{ "overflow-anchor": "none" }} onScroll={(e) => { - setStuck(e.currentTarget.scrollTop > 0) - setScrolling(true) + setStore("stuck", e.currentTarget.scrollTop > 0) + setStore("scrolling", true) if (timer) window.clearTimeout(timer) timer = window.setTimeout(() => { - setScrolling(false) + setStore("scrolling", false) if (inProgress() < 0) return requestAnimationFrame(ensure) }, 250) @@ -278,7 +284,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" style={{ background: "linear-gradient(to bottom, var(--background-base), transparent)", - opacity: stuck() ? 1 : 0, + opacity: store.stuck ? 1 : 0, }} /> </div> diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 4b322368f..a3379905d 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -17,6 +17,7 @@ import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" function FileCommentMenu(props: { moreLabel: string @@ -58,6 +59,11 @@ export function FileTabContent(props: { tab: string }) { const prompt = usePrompt() const fileComponent = useFileComponent() const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined @@ -228,7 +234,7 @@ export function FileTabContent(props: { tab: string }) { if (typeof window === "undefined") return const onKeyDown = (event: KeyboardEvent) => { - if (tabs().active() !== props.tab) return + if (activeFileTab() !== props.tab) return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (event.key.toLowerCase() !== "f") return @@ -256,7 +262,7 @@ export function FileTabContent(props: { tab: string }) { const p = path() if (!focus || !p) return if (focus.file !== p) return - if (tabs().active() !== props.tab) return + if (activeFileTab() !== props.tab) return const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return @@ -376,7 +382,7 @@ export function FileTabContent(props: { tab: string }) { createEffect(() => { const loaded = !!state()?.loaded const ready = file.ready() - const active = tabs().active() === props.tab + const active = activeFileTab() === props.tab const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) prev = { loaded, ready, active } if (!restore) return diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 9c77c34af..047946fc1 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test" -import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers" +import { createMemo, createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { + createOpenReviewFile, + createOpenSessionFileTab, + createSessionTabs, + focusTerminalById, + getTabReorderIndex, +} from "./helpers" describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { @@ -87,3 +95,66 @@ describe("getTabReorderIndex", () => { expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() }) }) + +describe("createSessionTabs", () => { + test("normalizes the effective file tab", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["file://src/a.ts", "context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + }) + + expect(result.activeTab()).toBe("norm:src/a.ts") + expect(result.activeFileTab()).toBe("norm:src/a.ts") + expect(result.closableTab()).toBe("norm:src/a.ts") + dispose() + }) + }) + + test("prefers context and review fallbacks when no file tab is active", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("context") + expect(result.closableTab()).toBe("context") + dispose() + }) + + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: [], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("review") + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 2da5ce6b8..c3571f3ff 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,5 +1,77 @@ -import { batch, onCleanup, onMount } from "solid-js" +import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" +import { same } from "@/utils/same" + +const emptyTabs: string[] = [] + +type Tabs = { + active: Accessor<string | undefined> + all: Accessor<string[]> +} + +type TabsInput = { + tabs: Accessor<Tabs> + pathFromTab: (tab: string) => string | undefined + normalizeTab: (tab: string) => string + review?: Accessor<boolean> + hasReview?: Accessor<boolean> +} + +export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` + +export const createSessionTabs = (input: TabsInput) => { + const review = input.review ?? (() => false) + const hasReview = input.hasReview ?? (() => false) + const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) + const openedTabs = createMemo( + () => { + const seen = new Set<string>() + return input + .tabs() + .all() + .flatMap((tab) => { + if (tab === "context" || tab === "review") return [] + const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab + if (seen.has(value)) return [] + seen.add(value) + return [value] + }) + }, + emptyTabs, + { equals: same }, + ) + const activeTab = createMemo(() => { + const active = input.tabs().active() + if (active === "context") return active + if (active === "review" && review()) return active + if (active && input.pathFromTab(active)) return input.normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + if (review() && hasReview()) return "review" + return "empty" + }) + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + const closableTab = createMemo(() => { + const active = activeTab() + if (active === "context") return active + if (!openedTabs().includes(active)) return + return active + }) + + return { + contextOpen, + openedTabs, + activeTab, + activeFileTab, + closableTab, + } +} export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 142ee7ad9..c073e6214 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -37,14 +37,6 @@ export interface SessionReviewTabProps { } } -export function StickyAddButton(props: { children: JSX.Element }) { - return ( - <div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"> - {props.children} - </div> - ) -} - export function SessionReviewTab(props: SessionReviewTabProps) { let scroll: HTMLDivElement | undefined let restoreFrame: number | undefined diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx deleted file mode 100644 index f97199b49..000000000 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Show } from "solid-js" -import { Tabs } from "@opencode-ai/ui/tabs" -import { useLanguage } from "@/context/language" - -export function SessionMobileTabs(props: { - open: boolean - mobileTab: "session" | "changes" - hasReview: boolean - reviewCount: number - onSession: () => void - onChanges: () => void -}) { - const language = useLanguage() - - return ( - <Show when={props.open}> - <Tabs value={props.mobileTab} class="h-auto"> - <Tabs.List> - <Tabs.Trigger - value="session" - class="!w-1/2 !max-w-none" - classes={{ button: "w-full" }} - onClick={props.onSession} - > - {language.t("session.tab.session")} - </Tabs.Trigger> - <Tabs.Trigger - value="changes" - class="!w-1/2 !max-w-none !border-r-0" - classes={{ button: "w-full" }} - onClick={props.onChanges} - > - {props.hasReview - ? language.t("session.review.filesChanged", { count: props.reviewCount }) - : language.t("session.review.change.other")} - </Tabs.Trigger> - </Tabs.List> - </Tabs> - </Show> - ) -} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 2c499d9f4..3b8b0c96b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -22,8 +22,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" -import { StickyAddButton } from "@/pages/session/review-tab" +import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" @@ -132,31 +131,17 @@ export function SessionSidePanel(props: { setActive: tabs().setActive, }) - 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 tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: reviewTab, + hasReview, }) + const contextOpen = tabState.contextOpen + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab const fileTreeTab = () => layout.fileTree.tab() @@ -297,7 +282,7 @@ export function SessionSidePanel(props: { <SortableProvider ids={openedTabs()}> <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> </SortableProvider> - <StickyAddButton> + <div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"> <TooltipKeybind title={language.t("command.file.open")} keybind={command.keybind("file.open")} @@ -314,7 +299,7 @@ export function SessionSidePanel(props: { aria-label={language.t("command.file.open")} /> </TooltipKeybind> - </StickyAddButton> + </div> </Tabs.List> </div> @@ -354,10 +339,10 @@ export function SessionSidePanel(props: { <DragOverlay> <Show when={store.activeDraggable} keyed> {(tab) => { - const path = createMemo(() => file.pathFromTab(tab)) + const path = file.pathFromTab(tab) return ( <div data-component="tabs-drag-preview"> - <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> + <Show when={path}>{(p) => <FileVisual active path={p()} />}</Show> </div> ) }} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index c49518656..e78ebecfc 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js" +import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" @@ -13,7 +13,7 @@ import { Terminal } from "@/components/terminal" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useTerminal } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" @@ -41,7 +41,7 @@ export function TerminalPanel() { const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) - createEffect(() => { + onMount(() => { if (typeof window === "undefined") return const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight) @@ -144,9 +144,8 @@ export function TerminalPanel() { return getTerminalHandoff(dir) ?? [] }) - const all = createMemo(() => terminal.all()) + const all = 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) @@ -159,8 +158,8 @@ export function TerminalPanel() { 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()) + const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { terminal.move(draggable.id.toString(), toIndex) } @@ -253,13 +252,7 @@ export function TerminalPanel() { > <Tabs.List class="h-10 border-b border-border-weaker-base"> <SortableProvider ids={ids()}> - <For each={ids()}> - {(id) => ( - <Show when={byId().get(id)}> - {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />} - </Show> - )} - </For> + <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For> </SortableProvider> <div class="h-full flex items-center justify-center"> <TooltipKeybind @@ -281,7 +274,7 @@ export function TerminalPanel() { <div class="flex-1 min-h-0 relative"> <Show when={terminal.active()} keyed> {(id) => ( - <Show when={byId().get(id)}> + <Show when={all().find((pty) => pty.id === id)}> {(pty) => ( <div id={`terminal-wrapper-${id}`} class="absolute inset-0"> <Terminal @@ -299,9 +292,9 @@ export function TerminalPanel() { </div> </div> <DragOverlay> - <Show when={store.activeDraggable}> - {(draggedId) => ( - <Show when={byId().get(draggedId())}> + <Show when={store.activeDraggable} keyed> + {(id) => ( + <Show when={all().find((pty) => pty.id === id)}> {(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {terminalTabLabel({ diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 6799504ca..f5a4c0576 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -1,4 +1,3 @@ -import { createMemo } from "solid-js" import { useNavigate } from "@solidjs/router" import { useCommand, type CommandOption } from "@/context/command" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -18,6 +17,7 @@ import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" +import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" @@ -26,6 +26,7 @@ export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void + review?: () => boolean } const withCategory = (category: string) => { @@ -50,17 +51,43 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const navigate = useNavigate() const { params, tabs, view } = useSessionLayout() - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = () => { + const id = params.id + if (!id) return + return sync.session.get(id) + } + const hasReview = () => { + const id = params.id + if (!id) return false + return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0 + } + const normalizeTab = (tab: string) => { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: actions.review, + hasReview, + }) + const activeFileTab = tabState.activeFileTab + const closableTab = tabState.closableTab 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 status = () => sync.data.session_status[params.id ?? ""] ?? idle + const messages = () => { + const id = params.id + if (!id) return [] + return sync.data.message[id] ?? [] + } + const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] + const visibleUserMessages = () => { const revert = info()?.revert?.messageID if (!revert) return userMessages() return userMessages().filter((m) => m.id < revert) - }) + } const showAllFiles = () => { if (layout.fileTree.tab() !== "changes") return @@ -79,9 +106,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const canAddSelectionContext = () => { - const active = tabs().active() - if (!active) return false - const path = file.pathFromTab(active) + const tab = activeFileTab() + if (!tab) return false + const path = file.pathFromTab(tab) if (!path) return false return file.selectedLines(path) != null } @@ -100,404 +127,369 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const agentCommand = withCategory(language.t("command.category.agent")) const permissionsCommand = withCategory(language.t("command.category.permissions")) - const sessionCommands = createMemo(() => [ - sessionCommand({ - id: "session.new", - title: language.t("command.session.new"), - keybind: "mod+shift+s", - slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), - }), - ]) - - const fileCommands = createMemo(() => [ - fileCommand({ - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - keybind: "mod+p", - slash: "open", - onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), - }), - fileCommand({ - id: "tab.close", - title: language.t("command.tab.close"), - keybind: "mod+w", - disabled: !tabs().active(), - onSelect: () => { - const active = tabs().active() - if (!active) return - tabs().close(active) - }, - }), - ]) - - const contextCommands = createMemo(() => [ - contextCommand({ - id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), - keybind: "mod+shift+l", - disabled: !canAddSelectionContext(), - onSelect: () => { - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) - if (!path) return - - const range = file.selectedLines(path) as SelectedLineRange | null | undefined - if (!range) { - showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), - }) - return - } - - addSelectionToContext(path, selectionFromLines(range)) - }, - }), - ]) - - const viewCommands = createMemo(() => [ - viewCommand({ - id: "terminal.toggle", - title: language.t("command.terminal.toggle"), - keybind: "ctrl+`", - slash: "terminal", - onSelect: () => view().terminal.toggle(), - }), - viewCommand({ - id: "review.toggle", - title: language.t("command.review.toggle"), - keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), - }), - viewCommand({ - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), - }), - viewCommand({ - id: "input.focus", - title: language.t("command.input.focus"), - keybind: "ctrl+l", - onSelect: () => focusInput(), - }), - terminalCommand({ - id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), - keybind: "ctrl+alt+t", - onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }), - ]) - - const messageCommands = createMemo(() => [ - sessionCommand({ - id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), - keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(-1), - }), - sessionCommand({ - id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), - keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(1), - }), - ]) - - const agentCommands = createMemo(() => [ - modelCommand({ - id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - keybind: "mod+'", - slash: "model", - onSelect: () => dialog.show(() => <DialogSelectModel />), - }), - mcpCommand({ - id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - keybind: "mod+;", - slash: "mcp", - onSelect: () => dialog.show(() => <DialogSelectMcp />), - }), - agentCommand({ - id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - keybind: "mod+.", - slash: "agent", - onSelect: () => local.agent.move(1), - }), - agentCommand({ - id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), - }), - modelCommand({ - id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - keybind: "shift+mod+d", - onSelect: () => { - local.model.variant.cycle() - }, - }), - ]) - const isAutoAcceptActive = () => { const sessionID = params.id if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory) } + command.register("session", () => { + const share = + sync.data.config.share === "disabled" + ? [] + : [ + 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"), + slash: "share", + disabled: !params.id, + onSelect: async () => { + if (!params.id) return - const permissionCommands = createMemo(() => [ - permissionsCommand({ - id: "permissions.autoaccept", - title: isAutoAcceptActive() - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), - keybind: "mod+shift+a", - disabled: false, - onSelect: () => { - const sessionID = params.id - if (sessionID) { - permission.toggleAutoAccept(sessionID, sdk.directory) - } else { - permission.toggleAutoAcceptDirectory(sdk.directory) - } - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk.directory) - : permission.isAutoAcceptingDirectory(sdk.directory) - showToast({ - title: active - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: active - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), - }) - }, - }), - ]) + const write = (value: string) => { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } - const sessionActionCommands = createMemo(() => [ - sessionCommand({ - id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), - slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) - } - const revert = info()?.revert?.messageID - const message = findLast(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] - if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) - } - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - setActiveMessage(priorMessage) - }, - }), - sessionCommand({ - id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), - slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const revertMessageID = info()?.revert?.messageID - if (!revertMessageID) return - const nextMessage = userMessages().find((x) => x.id > revertMessageID) - if (!nextMessage) { - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - setActiveMessage(lastMsg) - return - } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - setActiveMessage(priorMsg) - }, - }), - sessionCommand({ - id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), - slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const model = local.model.current() - if (!model) { - showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), - }) - return - } - await sdk.client.session.summarize({ - sessionID, - modelID: model.id, - providerID: model.provider.id, - }) - }, - }), - sessionCommand({ - id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), - slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => <DialogFork />), - }), - ]) + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } - const shareCommands = createMemo(() => { - if (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"), - slash: "share", - disabled: !params.id, - onSelect: async () => { - if (!params.id) return + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { + showToast({ + title: language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return + } - const write = (value: string) => { - const body = typeof document === "undefined" ? undefined : document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = value - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - textarea.style.pointerEvents = "none" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return Promise.resolve(true) - } + showToast({ + title: existing + ? language.t("session.share.copy.copied") + : language.t("toast.session.share.success.title"), + description: language.t("toast.session.share.success.description"), + variant: "success", + }) + } - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return Promise.resolve(false) - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } + const existing = info()?.share?.url + if (existing) { + await copy(existing, true) + return + } - const copy = async (url: string, existing: boolean) => { - const ok = await write(url) - if (!ok) { - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }) - return - } + const url = await sdk.client.session + .share({ sessionID: 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"), + variant: "error", + }) + return + } + await copy(url, false) + }, + }), + sessionCommand({ + id: "session.unshare", + title: language.t("command.session.unshare"), + description: language.t("command.session.unshare.description"), + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: language.t("toast.session.unshare.success.title"), + description: 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"), + variant: "error", + }), + ) + }, + }), + ] + + return [ + sessionCommand({ + id: "session.new", + title: language.t("command.session.new"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => navigate(`/${params.dir}/session`), + }), + fileCommand({ + id: "file.open", + title: language.t("command.file.open"), + description: language.t("palette.search.placeholder"), + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), + }), + fileCommand({ + id: "tab.close", + title: language.t("command.tab.close"), + keybind: "mod+w", + disabled: !closableTab(), + onSelect: () => { + const tab = closableTab() + if (!tab) return + tabs().close(tab) + }, + }), + contextCommand({ + id: "context.addSelection", + title: language.t("command.context.addSelection"), + description: language.t("command.context.addSelection.description"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext(), + onSelect: () => { + const tab = activeFileTab() + if (!tab) return + const path = file.pathFromTab(tab) + if (!path) return + + const range = file.selectedLines(path) as SelectedLineRange | null | undefined + if (!range) { showToast({ - title: existing - ? language.t("session.share.copy.copied") - : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", + title: language.t("toast.context.noLineSelection.title"), + description: language.t("toast.context.noLineSelection.description"), }) + return } - const existing = info()?.share?.url - if (existing) { - await copy(existing, true) + addSelectionToContext(path, selectionFromLines(range)) + }, + }), + viewCommand({ + id: "terminal.toggle", + title: language.t("command.terminal.toggle"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => view().terminal.toggle(), + }), + viewCommand({ + id: "review.toggle", + title: language.t("command.review.toggle"), + keybind: "mod+shift+r", + onSelect: () => view().reviewPanel.toggle(), + }), + viewCommand({ + id: "fileTree.toggle", + title: language.t("command.fileTree.toggle"), + keybind: "mod+\\", + onSelect: () => layout.fileTree.toggle(), + }), + viewCommand({ + id: "input.focus", + title: language.t("command.input.focus"), + keybind: "ctrl+l", + onSelect: focusInput, + }), + terminalCommand({ + id: "terminal.new", + title: language.t("command.terminal.new"), + description: language.t("command.terminal.new.description"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (terminal.all().length > 0) terminal.new() + view().terminal.open() + }, + }), + sessionCommand({ + id: "message.previous", + title: language.t("command.message.previous"), + description: language.t("command.message.previous.description"), + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }), + sessionCommand({ + id: "message.next", + title: language.t("command.message.next"), + description: language.t("command.message.next.description"), + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }), + modelCommand({ + id: "model.choose", + title: language.t("command.model.choose"), + description: language.t("command.model.choose.description"), + keybind: "mod+'", + slash: "model", + onSelect: () => dialog.show(() => <DialogSelectModel />), + }), + mcpCommand({ + id: "mcp.toggle", + title: language.t("command.mcp.toggle"), + description: language.t("command.mcp.toggle.description"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => <DialogSelectMcp />), + }), + agentCommand({ + id: "agent.cycle", + title: language.t("command.agent.cycle"), + description: language.t("command.agent.cycle.description"), + keybind: "mod+.", + slash: "agent", + onSelect: () => local.agent.move(1), + }), + agentCommand({ + id: "agent.cycle.reverse", + title: language.t("command.agent.cycle.reverse"), + description: language.t("command.agent.cycle.reverse.description"), + keybind: "shift+mod+.", + onSelect: () => local.agent.move(-1), + }), + modelCommand({ + id: "model.variant.cycle", + title: language.t("command.model.variant.cycle"), + description: language.t("command.model.variant.cycle.description"), + keybind: "shift+mod+d", + onSelect: () => local.model.variant.cycle(), + }), + permissionsCommand({ + id: "permissions.autoaccept", + title: isAutoAcceptActive() + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), + keybind: "mod+shift+a", + disabled: false, + onSelect: () => { + const sessionID = params.id + if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory) + else permission.toggleAutoAcceptDirectory(sdk.directory) + + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk.directory) + : permission.isAutoAcceptingDirectory(sdk.directory) + showToast({ + title: active + ? language.t("toast.permissions.autoaccept.on.title") + : language.t("toast.permissions.autoaccept.off.title"), + description: active + ? language.t("toast.permissions.autoaccept.on.description") + : language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }), + sessionCommand({ + id: "session.undo", + title: language.t("command.session.undo"), + description: language.t("command.session.undo.description"), + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status().type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + const message = findLast(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] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + prompt.set(restored) + } + const priorMessage = findLast(userMessages(), (x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }), + sessionCommand({ + id: "session.redo", + title: language.t("command.session.redo"), + description: language.t("command.session.redo.description"), + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) return } - - const url = await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => res.data?.share?.url) - .catch(() => undefined) - if (!url) { + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }), + sessionCommand({ + id: "session.compact", + title: language.t("command.session.compact"), + description: language.t("command.session.compact.description"), + slash: "compact", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const model = local.model.current() + if (!model) { showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", + title: language.t("toast.model.none.title"), + description: language.t("toast.model.none.description"), }) return } - - await copy(url, false) + await sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) }, }), sessionCommand({ - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: 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"), - variant: "error", - }), - ) - }, + id: "session.fork", + title: language.t("command.session.fork"), + description: language.t("command.session.fork.description"), + slash: "fork", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: () => dialog.show(() => <DialogFork />), }), + ...share, ] }) - - command.register("session", () => - [ - sessionCommands(), - fileCommands(), - contextCommands(), - viewCommands(), - messageCommands(), - agentCommands(), - permissionCommands(), - sessionActionCommands(), - shareCommands(), - ].flatMap((x) => x), - ) } |
