summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-12 11:32:05 -0500
committerGitHub <[email protected]>2026-03-12 11:32:05 -0500
commitdce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2 (patch)
tree31cd7d7aa33733579134e9a6cf3a61762599d8e0 /packages/app/src/pages
parent0e077f748352df6d44c811829baff3c26b3436ac (diff)
downloadopencode-dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2.tar.gz
opencode-dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2.zip
chore: cleanup (#17197)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/session.tsx126
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx31
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx46
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx12
-rw-r--r--packages/app/src/pages/session/helpers.test.ts73
-rw-r--r--packages/app/src/pages/session/helpers.ts74
-rw-r--r--packages/app/src/pages/session/review-tab.tsx8
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx41
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx45
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx29
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx756
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),
- )
}