summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-18 08:26:15 -0600
committerGitHub <[email protected]>2026-02-18 08:26:15 -0600
commit00c238777ae11dfd61c6249426cd201fc3612f1b (patch)
tree32c0a3c9a2c42734a2d9c3886b7c92d0b62eeee3 /packages/app/src
parente4b548fa768a59cea7e5c8279e327d990cd36c27 (diff)
downloadopencode-00c238777ae11dfd61c6249426cd201fc3612f1b.tar.gz
opencode-00c238777ae11dfd61c6249426cd201fc3612f1b.zip
chore: cleanup (#14113)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx66
-rw-r--r--packages/app/src/pages/session.tsx522
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx127
-rw-r--r--packages/app/src/pages/session/handoff.ts36
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx328
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx10
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx144
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx373
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx235
9 files changed, 943 insertions, 898 deletions
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 81220b3ad..162e016c6 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -5,6 +5,7 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
+import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -16,13 +17,6 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
-interface SessionContextTabProps {
- messages: () => Message[]
- visibleUserMessages: () => UserMessage[]
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
-}
-
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
@@ -91,11 +85,45 @@ function RawMessage(props: {
)
}
-export function SessionContextTab(props: SessionContextTabProps) {
+const emptyMessages: Message[] = []
+const emptyUserMessages: UserMessage[] = []
+
+export function SessionContextTab() {
const params = useParams()
const sync = useSync()
+ const layout = useLayout()
const language = useLanguage()
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const view = createMemo(() => layout.view(sessionKey))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+
+ const messages = createMemo(
+ () => {
+ const id = params.id
+ if (!id) return emptyMessages
+ return (sync.data.message[id] ?? []) as Message[]
+ },
+ emptyMessages,
+ { equals: same },
+ )
+
+ const userMessages = createMemo(
+ () => messages().filter((m) => m.role === "user") as UserMessage[],
+ emptyUserMessages,
+ { equals: same },
+ )
+
+ const visibleUserMessages = createMemo(
+ () => {
+ const revert = info()?.revert?.messageID
+ if (!revert) return userMessages()
+ return userMessages().filter((m) => m.id < revert)
+ },
+ emptyUserMessages,
+ { equals: same },
+ )
+
const usd = createMemo(
() =>
new Intl.NumberFormat(language.locale(), {
@@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
- const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const counts = createMemo(() => {
- const all = props.messages()
+ const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
@@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
})
const systemPrompt = createMemo(() => {
- const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
+ const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
@@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo(
on(
- () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
+ () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
- messages: props.messages(),
+ messages: messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
@@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}
const stats = [
- { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
+ { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
@@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
- { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
+ { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
@@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll
if (!el) return
- const s = props.view()?.scroll("context")
+ const s = view().scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined
if (!next) return
- props.view().setScroll("context", next)
+ view().setScroll("context", next)
})
}
createEffect(
on(
- () => props.messages().length,
+ () => messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
@@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
- <For each={props.messages()}>
+ <For each={messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 23dc0304e..7d950b346 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,26 +1,20 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
-import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore, produce } from "solid-js/store"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { Dialog } from "@opencode-ai/ui/dialog"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select"
-import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
-import { useGlobalSync } from "@/context/global-sync"
-import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useTerminal } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
@@ -34,16 +28,14 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
-import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
-import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
+import { SessionHeader, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
-import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
+import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
-import { FileTabContent } from "@/pages/session/file-tabs"
import {
SessionReviewTab,
StickyAddButton,
@@ -51,7 +43,6 @@ import {
type SessionReviewTabProps,
} from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel"
-import { terminalTabLabel } from "@/pages/session/terminal-label"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
@@ -59,42 +50,13 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
-type HandoffSession = {
- prompt: string
- files: Record<string, SelectedLineRange | null>
-}
-
-const HANDOFF_MAX = 40
-
-const handoff = {
- session: new Map<string, HandoffSession>(),
- terminal: new Map<string, string[]>(),
-}
-
-const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
- map.delete(key)
- map.set(key, value)
- while (map.size > HANDOFF_MAX) {
- const first = map.keys().next().value
- if (first === undefined) return
- map.delete(first)
- }
-}
-
-const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
- const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
- touch(handoff.session, key, { ...prev, ...patch })
-}
-
export default function Page() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const sync = useSync()
- const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
- const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const params = useParams()
@@ -104,53 +66,21 @@ export default function Page() {
const comments = useComments()
const permission = usePermission()
- const permRequest = createMemo(() => {
- const sessionID = params.id
- if (!sessionID) return
- return sync.data.permission[sessionID]?.[0]
- })
-
- const questionRequest = createMemo(() => {
- const sessionID = params.id
- if (!sessionID) return
- return sync.data.question[sessionID]?.[0]
- })
-
- const blocked = createMemo(() => !!permRequest() || !!questionRequest())
-
const [ui, setUi] = createStore({
- responding: false,
pendingMessage: undefined as string | undefined,
scrollGesture: 0,
- autoCreated: false,
scroll: {
overflow: false,
bottom: true,
},
})
- createEffect(
- on(
- () => permRequest()?.id,
- () => setUi("responding", false),
- { defer: true },
- ),
- )
+ const blocked = createMemo(() => {
+ const sessionID = params.id
+ if (!sessionID) return false
+ return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
+ })
- const decide = (response: "once" | "always" | "reject") => {
- const perm = permRequest()
- if (!perm) return
- if (ui.responding) return
-
- setUi("responding", true)
- sdk.client.permission
- .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
- .catch((err: unknown) => {
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: language.t("common.requestFailed"), description: message })
- })
- .finally(() => setUi("responding", false))
- }
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -323,206 +253,6 @@ export default function Page() {
return sync.session.history.loading(id)
})
- const [title, setTitle] = createStore({
- draft: "",
- editing: false,
- saving: false,
- menuOpen: false,
- pendingRename: false,
- })
- let titleRef: HTMLInputElement | undefined
-
- const errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
-
- createEffect(
- on(
- sessionKey,
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
- { defer: true },
- ),
- )
-
- const openTitleEditor = () => {
- if (!params.id) return
- setTitle({ editing: true, draft: info()?.title ?? "" })
- requestAnimationFrame(() => {
- titleRef?.focus()
- titleRef?.select()
- })
- }
-
- const closeTitleEditor = () => {
- if (title.saving) return
- setTitle({ editing: false, saving: false })
- }
-
- const saveTitleEditor = async () => {
- const sessionID = params.id
- if (!sessionID) return
- if (title.saving) return
-
- const next = title.draft.trim()
- if (!next || next === (info()?.title ?? "")) {
- setTitle({ editing: false, saving: false })
- return
- }
-
- setTitle("saving", true)
- await sdk.client.session
- .update({ sessionID, title: next })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((s) => s.id === sessionID)
- if (index !== -1) draft.session[index].title = next
- }),
- )
- setTitle({ editing: false, saving: false })
- })
- .catch((err) => {
- setTitle("saving", false)
- showToast({
- title: language.t("common.requestFailed"),
- description: errorMessage(err),
- })
- })
- }
-
- const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
- if (params.id !== sessionID) return
- if (parentID) {
- navigate(`/${params.dir}/session/${parentID}`)
- return
- }
- if (nextSessionID) {
- navigate(`/${params.dir}/session/${nextSessionID}`)
- return
- }
- navigate(`/${params.dir}/session`)
- }
-
- async function archiveSession(sessionID: string) {
- const session = sync.session.get(sessionID)
- if (!session) return
-
- const sessions = sync.data.session ?? []
- const index = sessions.findIndex((s) => s.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- await sdk.client.session
- .update({ sessionID, time: { archived: Date.now() } })
- .then(() => {
- sync.set(
- produce((draft) => {
- const index = draft.session.findIndex((s) => s.id === sessionID)
- if (index !== -1) draft.session.splice(index, 1)
- }),
- )
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- })
- .catch((err) => {
- showToast({
- title: language.t("common.requestFailed"),
- description: errorMessage(err),
- })
- })
- }
-
- async function deleteSession(sessionID: string) {
- const session = sync.session.get(sessionID)
- if (!session) return false
-
- const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
- const index = sessions.findIndex((s) => s.id === sessionID)
- const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
-
- const result = await sdk.client.session
- .delete({ sessionID })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("session.delete.failed.title"),
- description: errorMessage(err),
- })
- return false
- })
-
- if (!result) return false
-
- sync.set(
- produce((draft) => {
- const removed = new Set<string>([sessionID])
-
- const byParent = new Map<string, string[]>()
- for (const item of draft.session) {
- const parentID = item.parentID
- if (!parentID) continue
- const existing = byParent.get(parentID)
- if (existing) {
- existing.push(item.id)
- continue
- }
- byParent.set(parentID, [item.id])
- }
-
- const stack = [sessionID]
- while (stack.length) {
- const parentID = stack.pop()
- if (!parentID) continue
-
- const children = byParent.get(parentID)
- if (!children) continue
-
- for (const child of children) {
- if (removed.has(child)) continue
- removed.add(child)
- stack.push(child)
- }
- }
-
- draft.session = draft.session.filter((s) => !removed.has(s.id))
- }),
- )
-
- navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
- return true
- }
-
- function DialogDeleteSession(props: { sessionID: string }) {
- const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
- const handleDelete = async () => {
- await deleteSession(props.sessionID)
- dialog.close()
- }
-
- return (
- <Dialog title={language.t("session.delete.title")} fit>
- <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
- <div class="flex flex-col gap-1">
- <span class="text-14-regular text-text-strong">
- {language.t("session.delete.confirm", { name: title() })}
- </span>
- </div>
- <div class="flex justify-end gap-2">
- <Button variant="ghost" size="large" onClick={() => dialog.close()}>
- {language.t("common.cancel")}
- </Button>
- <Button variant="primary" size="large" onClick={handleDelete}>
- {language.t("session.delete.button")}
- </Button>
- </div>
- </div>
- </Dialog>
- )
- }
-
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -555,8 +285,6 @@ export default function Page() {
)
const [store, setStore] = createStore({
- activeDraggable: undefined as string | undefined,
- activeTerminalDraggable: undefined as string | undefined,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -679,43 +407,6 @@ export default function Page() {
void sync.session.todo(id)
})
- createEffect(() => {
- if (!view().terminal.opened()) {
- setUi("autoCreated", false)
- return
- }
- if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
- terminal.new()
- setUi("autoCreated", true)
- })
-
- createEffect(
- on(
- () => terminal.all().length,
- (count, prevCount) => {
- if (prevCount !== undefined && prevCount > 0 && count === 0) {
- if (view().terminal.opened()) {
- view().terminal.toggle()
- }
- }
- },
- ),
- )
-
- createEffect(
- on(
- () => terminal.active(),
- (activeId) => {
- if (!activeId || !view().terminal.opened()) return
- // Immediately remove focus
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur()
- }
- focusTerminalById(activeId)
- },
- ),
- )
-
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -729,11 +420,6 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
- const todos = createMemo(() => {
- const id = params.id
- if (!id) return []
- return globalSync.data.session_todo[id] ?? []
- })
createEffect(
on(
@@ -741,7 +427,6 @@ export default function Page() {
() => {
setStore("messageId", undefined)
setStore("changes", "session")
- setUi("autoCreated", false)
},
{ defer: true },
),
@@ -827,53 +512,6 @@ export default function Page() {
}
}
- const handleDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeDraggable", id)
- }
-
- const handleDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const currentTabs = tabs().all()
- const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
- if (toIndex === undefined) return
- tabs().move(draggable.id.toString(), toIndex)
- }
- }
-
- const handleDragEnd = () => {
- setStore("activeDraggable", undefined)
- }
-
- const handleTerminalDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeTerminalDraggable", id)
- }
-
- const handleTerminalDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const terminals = terminal.all()
- const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
- const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
- if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
- terminal.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleTerminalDragEnd = () => {
- setStore("activeTerminalDraggable", undefined)
- const activeId = terminal.active()
- if (!activeId) return
- setTimeout(() => {
- focusTerminalById(activeId)
- }, 0)
- }
-
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
@@ -1485,58 +1123,6 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown)
})
- const previewPrompt = () =>
- prompt
- .current()
- .map((part) => {
- if (part.type === "file") return `[file:${part.path}]`
- if (part.type === "agent") return `@${part.name}`
- if (part.type === "image") return `[image:${part.filename}]`
- return part.content
- })
- .join("")
- .trim()
-
- createEffect(() => {
- if (!prompt.ready()) return
- setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
- })
-
- createEffect(() => {
- if (!terminal.ready()) return
- language.locale()
-
- touch(
- handoff.terminal,
- params.dir!,
- terminal.all().map((pty) =>
- terminalTabLabel({
- title: pty.title,
- titleNumber: pty.titleNumber,
- t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
- }),
- ),
- )
- })
-
- createEffect(() => {
- if (!file.ready()) return
- setSessionHandoff(sessionKey(), {
- files: tabs()
- .all()
- .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
- const path = file.pathFromTab(tab)
- if (!path) return acc
- const selected = file.selectedLines(path)
- acc[path] =
- selected && typeof selected === "object" && "start" in selected && "end" in selected
- ? (selected as SelectedLineRange)
- : null
- return acc
- }, {}),
- })
- })
-
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
@@ -1555,7 +1141,6 @@ export default function Page() {
reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")}
onChanges={() => setStore("mobileTab", "changes")}
- t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
/>
{/* Session panel */}
@@ -1595,27 +1180,7 @@ export default function Page() {
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
- showHeader={!!(info()?.title || info()?.parentID)}
centered={centered()}
- title={info()?.title}
- parentID={info()?.parentID}
- openTitleEditor={openTitleEditor}
- closeTitleEditor={closeTitleEditor}
- saveTitleEditor={saveTitleEditor}
- titleRef={(el) => {
- titleRef = el
- }}
- titleState={title}
- onTitleDraft={(value) => setTitle("draft", value)}
- onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
- onTitlePendingRename={(value) => setTitle("pendingRename", value)}
- onNavigateParent={() => {
- navigate(`/${params.dir}/session/${info()?.parentID}`)
- }}
- sessionID={params.id!}
- onArchiveSession={(sessionID) => void archiveSession(sessionID)}
- onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
- t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
@@ -1670,15 +1235,6 @@ export default function Page() {
<SessionPromptDock
centered={centered()}
- questionRequest={questionRequest}
- permissionRequest={permRequest}
- blocked={blocked()}
- todos={todos()}
- promptReady={prompt.ready()}
- handoffPrompt={handoff.session.get(sessionKey())?.prompt}
- t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
- responding={ui.responding}
- onDecide={decide}
inputRef={(el) => {
inputRef = el
}}
@@ -1688,7 +1244,9 @@ export default function Page() {
comments.clear()
resumeScroll()
}}
- setPromptDockRef={(el) => (promptDock = el)}
+ setPromptDockRef={(el) => {
+ promptDock = el
+ }}
/>
<Show when={desktopReviewOpen()}>
@@ -1702,64 +1260,10 @@ export default function Page() {
</Show>
</div>
- <SessionSidePanel
- open={desktopSidePanelOpen()}
- reviewOpen={desktopReviewOpen()}
- language={language}
- layout={layout}
- command={command}
- dialog={dialog}
- file={file}
- comments={comments}
- hasReview={hasReview()}
- reviewCount={reviewCount()}
- reviewTab={reviewTab()}
- contextOpen={contextOpen}
- openedTabs={openedTabs}
- activeTab={activeTab}
- activeFileTab={activeFileTab}
- tabs={tabs}
- openTab={openTab}
- showAllFiles={showAllFiles}
- reviewPanel={reviewPanel}
- vm={{
- messages,
- visibleUserMessages,
- view,
- info,
- }}
- handoffFiles={() => handoff.session.get(sessionKey())?.files}
- codeComponent={codeComponent}
- addCommentToContext={addCommentToContext}
- activeDraggable={() => store.activeDraggable}
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- fileTreeTab={fileTreeTab}
- setFileTreeTabValue={setFileTreeTabValue}
- diffsReady={diffsReady()}
- diffFiles={diffFiles()}
- kinds={kinds()}
- activeDiff={tree.activeDiff}
- focusReviewDiff={focusReviewDiff}
- />
+ <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
</div>
- <TerminalPanel
- open={isDesktop() && view().terminal.opened()}
- height={layout.terminal.height()}
- resize={layout.terminal.resize}
- close={view().terminal.close}
- terminal={terminal}
- language={language}
- command={command}
- handoff={() => handoff.terminal.get(params.dir!) ?? []}
- activeTerminalDraggable={() => store.activeTerminalDraggable}
- handleTerminalDragStart={handleTerminalDragStart}
- handleTerminalDragOver={handleTerminalDragOver}
- handleTerminalDragEnd={handleTerminalDragEnd}
- onCloseTab={() => setUi("autoCreated", false)}
- />
+ <TerminalPanel />
</div>
)
}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index d22fa358b..9e3a54311 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -1,6 +1,8 @@
-import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
+import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
+import { useParams } from "@solidjs/router"
+import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
@@ -8,9 +10,11 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useLayout } from "@/context/layout"
-import { useFile, type SelectedLineRange } from "@/context/file"
+import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language"
+import { usePrompt } from "@/context/prompt"
+import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
@@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}`
}
-export function FileTabContent(props: {
- tab: string
- activeTab: () => string
- tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
- file: ReturnType<typeof useFile>
- comments: ReturnType<typeof useComments>
- language: ReturnType<typeof useLanguage>
- codeComponent: NonNullable<ValidComponent>
- addCommentToContext: (input: {
- file: string
- selection: SelectedLineRange
- comment: string
- preview?: string
- origin?: "review" | "file"
- }) => void
-}) {
+export function FileTabContent(props: { tab: string }) {
+ const params = useParams()
+ const layout = useLayout()
+ const file = useFile()
+ const comments = useComments()
+ const language = useLanguage()
+ const prompt = usePrompt()
+ const codeComponent = useCodeComponent()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey))
+ const view = createMemo(() => layout.view(sessionKey))
+
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
- const path = createMemo(() => props.file.pathFromTab(props.tab))
+ const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
const p = path()
if (!p) return
- return props.file.get(p)
+ return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
@@ -82,7 +81,7 @@ export function FileTabContent(props: {
svgToast.shown = true
showToast({
variant: "error",
- title: props.language.t("toast.file.loadFailed.title"),
+ title: language.t("toast.file.loadFailed.title"),
})
})
const svgPreviewUrl = createMemo(() => {
@@ -100,16 +99,57 @@ export function FileTabContent(props: {
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
- if (props.file.ready()) return props.file.selectedLines(p) ?? null
- return props.handoffFiles()?.[p] ?? null
+ if (file.ready()) return file.selectedLines(p) ?? null
+ return getSessionHandoff(sessionKey())?.files[p] ?? null
})
+ const selectionPreview = (source: string, selection: FileSelection) => {
+ const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
+ const end = Math.max(selection.startLine, selection.endLine)
+ const lines = source.split("\n").slice(start - 1, end)
+ if (lines.length === 0) return undefined
+ return lines.slice(0, 2).join("\n")
+ }
+
+ const addCommentToContext = (input: {
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+ }) => {
+ const selection = selectionFromLines(input.selection)
+ const preview =
+ input.preview ??
+ (() => {
+ if (input.file === path()) return selectionPreview(contents(), selection)
+ const source = file.get(input.file)?.content?.content
+ if (!source) return undefined
+ return selectionPreview(source, selection)
+ })()
+
+ const saved = comments.add({
+ file: input.file,
+ selection: input.selection,
+ comment: input.comment,
+ })
+ prompt.context.add({
+ type: "file",
+ path: input.file,
+ selection,
+ comment: input.comment,
+ commentID: saved.id,
+ commentOrigin: input.origin,
+ preview,
+ })
+ }
+
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
- return props.comments.list(p)
+ return comments.list(p)
})
const commentLayout = createMemo(() => {
@@ -228,19 +268,19 @@ export function FileTabContent(props: {
})
createEffect(() => {
- const focus = props.comments.focus()
+ const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
- if (props.activeTab() !== props.tab) return
+ if (tabs().active() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
setNote("openedComment", target.id)
setNote("commenting", null)
- props.file.setSelectedLines(p, target.selection)
- requestAnimationFrame(() => props.comments.clearFocus())
+ file.setSelectedLines(p, target.selection)
+ requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
@@ -269,7 +309,7 @@ export function FileTabContent(props: {
pending = undefined
if (!out) return
- props.view().setScroll(props.tab, out)
+ view().setScroll(props.tab, out)
})
}
@@ -305,7 +345,7 @@ export function FileTabContent(props: {
const el = scroll
if (!el) return
- const s = props.view()?.scroll(props.tab)
+ const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
@@ -343,7 +383,7 @@ export function FileTabContent(props: {
createEffect(
on(
- () => props.file.ready(),
+ () => file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
@@ -354,7 +394,7 @@ export function FileTabContent(props: {
createEffect(
on(
- () => props.tabs().active() === props.tab,
+ () => tabs().active() === props.tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
@@ -381,7 +421,7 @@ export function FileTabContent(props: {
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic
- component={props.codeComponent}
+ component={codeComponent}
file={{
name: path() ?? "",
contents: source,
@@ -397,7 +437,7 @@ export function FileTabContent(props: {
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
- props.file.setSelectedLines(p, range)
+ file.setSelectedLines(p, range)
if (!range) setNote("commenting", null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
@@ -423,14 +463,14 @@ export function FileTabContent(props: {
onMouseEnter={() => {
const p = path()
if (!p) return
- props.file.setSelectedLines(p, comment.selection)
+ file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setNote("commenting", null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
- props.file.setSelectedLines(p, comment.selection)
+ file.setSelectedLines(p, comment.selection)
}}
/>
)}
@@ -447,12 +487,7 @@ export function FileTabContent(props: {
onSubmit={(value) => {
const p = path()
if (!p) return
- props.addCommentToContext({
- file: p,
- selection: range(),
- comment: value,
- origin: "file",
- })
+ addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setNote("commenting", null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
@@ -509,13 +544,13 @@ export function FileTabContent(props: {
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
- <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
+ <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
- <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
+ <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
diff --git a/packages/app/src/pages/session/handoff.ts b/packages/app/src/pages/session/handoff.ts
new file mode 100644
index 000000000..61bdca934
--- /dev/null
+++ b/packages/app/src/pages/session/handoff.ts
@@ -0,0 +1,36 @@
+import type { SelectedLineRange } from "@/context/file"
+
+type HandoffSession = {
+ prompt: string
+ files: Record<string, SelectedLineRange | null>
+}
+
+const MAX = 40
+
+const store = {
+ session: new Map<string, HandoffSession>(),
+ terminal: new Map<string, string[]>(),
+}
+
+const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
+ map.delete(key)
+ map.set(key, value)
+ while (map.size > MAX) {
+ const first = map.keys().next().value
+ if (first === undefined) return
+ map.delete(first)
+ }
+}
+
+export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
+ const prev = store.session.get(key) ?? { prompt: "", files: {} }
+ touch(store.session, key, { ...prev, ...patch })
+}
+
+export const getSessionHandoff = (key: string) => store.session.get(key)
+
+export const setTerminalHandoff = (key: string, value: string[]) => {
+ touch(store.terminal, key, value)
+}
+
+export const getTerminalHandoff = (key: string) => store.terminal.get(key)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index a8d22ccc8..b94942408 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,13 +1,21 @@
-import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
+import { For, createEffect, createMemo, on, onCleanup, onMount, Show, type JSX } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@@ -53,29 +61,7 @@ export function MessageTimeline(props: {
isDesktop: boolean
onScrollSpyScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
- showHeader: boolean
centered: boolean
- title?: string
- parentID?: string
- openTitleEditor: () => void
- closeTitleEditor: () => void
- saveTitleEditor: () => void | Promise<void>
- titleRef: (el: HTMLInputElement) => void
- titleState: {
- draft: string
- editing: boolean
- saving: boolean
- menuOpen: boolean
- pendingRename: boolean
- }
- onTitleDraft: (value: string) => void
- onTitleMenuOpen: (open: boolean) => void
- onTitlePendingRename: (value: boolean) => void
- onNavigateParent: () => void
- sessionID: string
- onArchiveSession: (sessionID: string) => void
- onDeleteSession: (sessionID: string) => void
- t: (key: string, vars?: Record<string, string | number | boolean>) => string
setContentRef: (el: HTMLDivElement) => void
turnStart: number
onRenderEarlier: () => void
@@ -91,6 +77,230 @@ export function MessageTimeline(props: {
}) {
let touchGesture: number | undefined
+ const params = useParams()
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const dialog = useDialog()
+ const language = useLanguage()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const sessionID = createMemo(() => params.id)
+ const info = createMemo(() => {
+ const id = sessionID()
+ if (!id) return
+ return sync.session.get(id)
+ })
+ const titleValue = createMemo(() => info()?.title)
+ const parentID = createMemo(() => info()?.parentID)
+ const showHeader = createMemo(() => !!(titleValue() || parentID()))
+
+ const [title, setTitle] = createStore({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ })
+ let titleRef: HTMLInputElement | undefined
+
+ const errorMessage = (err: unknown) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return language.t("common.requestFailed")
+ }
+
+ createEffect(
+ on(
+ sessionKey,
+ () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ { defer: true },
+ ),
+ )
+
+ const openTitleEditor = () => {
+ if (!sessionID()) return
+ setTitle({ editing: true, draft: titleValue() ?? "" })
+ requestAnimationFrame(() => {
+ titleRef?.focus()
+ titleRef?.select()
+ })
+ }
+
+ const closeTitleEditor = () => {
+ if (title.saving) return
+ setTitle({ editing: false, saving: false })
+ }
+
+ const saveTitleEditor = async () => {
+ const id = sessionID()
+ if (!id) return
+ if (title.saving) return
+
+ const next = title.draft.trim()
+ if (!next || next === (titleValue() ?? "")) {
+ setTitle({ editing: false, saving: false })
+ return
+ }
+
+ setTitle("saving", true)
+ await sdk.client.session
+ .update({ sessionID: id, title: next })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === id)
+ if (index !== -1) draft.session[index].title = next
+ }),
+ )
+ setTitle({ editing: false, saving: false })
+ })
+ .catch((err) => {
+ setTitle("saving", false)
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(err),
+ })
+ })
+ }
+
+ const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+ if (params.id !== sessionID) return
+ if (parentID) {
+ navigate(`/${params.dir}/session/${parentID}`)
+ return
+ }
+ if (nextSessionID) {
+ navigate(`/${params.dir}/session/${nextSessionID}`)
+ return
+ }
+ navigate(`/${params.dir}/session`)
+ }
+
+ const archiveSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return
+
+ const sessions = sync.data.session ?? []
+ const index = sessions.findIndex((s) => s.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ await sdk.client.session
+ .update({ sessionID, time: { archived: Date.now() } })
+ .then(() => {
+ sync.set(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === sessionID)
+ if (index !== -1) draft.session.splice(index, 1)
+ }),
+ )
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(err),
+ })
+ })
+ }
+
+ const deleteSession = async (sessionID: string) => {
+ const session = sync.session.get(sessionID)
+ if (!session) return false
+
+ const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
+ const index = sessions.findIndex((s) => s.id === sessionID)
+ const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
+
+ const result = await sdk.client.session
+ .delete({ sessionID })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: errorMessage(err),
+ })
+ return false
+ })
+
+ if (!result) return false
+
+ sync.set(
+ produce((draft) => {
+ const removed = new Set<string>([sessionID])
+
+ const byParent = new Map<string, string[]>()
+ for (const item of draft.session) {
+ const parentID = item.parentID
+ if (!parentID) continue
+ const existing = byParent.get(parentID)
+ if (existing) {
+ existing.push(item.id)
+ continue
+ }
+ byParent.set(parentID, [item.id])
+ }
+
+ const stack = [sessionID]
+ while (stack.length) {
+ const parentID = stack.pop()
+ if (!parentID) continue
+
+ const children = byParent.get(parentID)
+ if (!children) continue
+
+ for (const child of children) {
+ if (removed.has(child)) continue
+ removed.add(child)
+ stack.push(child)
+ }
+ }
+
+ draft.session = draft.session.filter((s) => !removed.has(s.id))
+ }),
+ )
+
+ navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
+ return true
+ }
+
+ const navigateParent = () => {
+ const id = parentID()
+ if (!id) return
+ navigate(`/${params.dir}/session/${id}`)
+ }
+
+ function DialogDeleteSession(props: { sessionID: string }) {
+ const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
+ const handleDelete = async () => {
+ await deleteSession(props.sessionID)
+ dialog.close()
+ }
+
+ return (
+ <Dialog title={language.t("session.delete.title")} fit>
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
+ <div class="flex flex-col gap-1">
+ <span class="text-14-regular text-text-strong">
+ {language.t("session.delete.confirm", { name: name() })}
+ </span>
+ </div>
+ <div class="flex justify-end gap-2">
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
+ {language.t("common.cancel")}
+ </Button>
+ <Button variant="primary" size="large" onClick={handleDelete}>
+ {language.t("session.delete.button")}
+ </Button>
+ </div>
+ </div>
+ </Dialog>
+ )
+ }
+
return (
<Show
when={!props.mobileChanges}
@@ -157,9 +367,9 @@ export function MessageTimeline(props: {
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
- style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
+ style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
>
- <Show when={props.showHeader}>
+ <Show when={showHeader()}>
<div
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
@@ -171,92 +381,96 @@ export function MessageTimeline(props: {
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
- <Show when={props.parentID}>
+ <Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
- onClick={props.onNavigateParent}
- aria-label={props.t("common.goBack")}
+ onClick={navigateParent}
+ aria-label={language.t("common.goBack")}
/>
</Show>
- <Show when={props.title || props.titleState.editing}>
+ <Show when={titleValue() || title.editing}>
<Show
- when={props.titleState.editing}
+ when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
- onDblClick={props.openTitleEditor}
+ onDblClick={openTitleEditor}
>
- {props.title}
+ {titleValue()}
</h1>
}
>
<InlineInput
- ref={props.titleRef}
- value={props.titleState.draft}
- disabled={props.titleState.saving}
+ ref={(el) => {
+ titleRef = el
+ }}
+ value={title.draft}
+ disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
- onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
+ onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
- void props.saveTitleEditor()
+ void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
- props.closeTitleEditor()
+ closeTitleEditor()
}
}}
- onBlur={props.closeTitleEditor}
+ onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
- <Show when={props.sessionID}>
+ <Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
- open={props.titleState.menuOpen}
- onOpenChange={props.onTitleMenuOpen}
+ open={title.menuOpen}
+ onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- aria-label={props.t("common.moreOptions")}
+ aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
- if (!props.titleState.pendingRename) return
+ if (!title.pendingRename) return
event.preventDefault()
- props.onTitlePendingRename(false)
- props.openTitleEditor()
+ setTitle("pendingRename", false)
+ openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
- props.onTitlePendingRename(true)
- props.onTitleMenuOpen(false)
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
}}
>
- <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
- <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
- <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
+ <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+ <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
- <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
- <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
+ <DropdownMenu.Item
+ onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -282,7 +496,7 @@ export function MessageTimeline(props: {
<Show when={props.turnStart > 0}>
<div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
- {props.t("session.messages.renderEarlier")}
+ {language.t("session.messages.renderEarlier")}
</Button>
</div>
</Show>
@@ -296,8 +510,8 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier}
>
{props.historyLoading
- ? props.t("session.messages.loadingEarlier")
- : props.t("session.messages.loadEarlier")}
+ ? language.t("session.messages.loadingEarlier")
+ : language.t("session.messages.loadEarlier")}
</Button>
</div>
</Show>
@@ -321,7 +535,7 @@ export function MessageTimeline(props: {
}}
>
<SessionTurn
- sessionID={props.sessionID}
+ sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
index 73aebc079..f97199b49 100644
--- a/packages/app/src/pages/session/session-mobile-tabs.tsx
+++ b/packages/app/src/pages/session/session-mobile-tabs.tsx
@@ -1,5 +1,6 @@
import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
+import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: {
open: boolean
@@ -8,8 +9,9 @@ export function SessionMobileTabs(props: {
reviewCount: number
onSession: () => void
onChanges: () => void
- t: (key: string, vars?: Record<string, string | number | boolean>) => string
}) {
+ const language = useLanguage()
+
return (
<Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto">
@@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }}
onClick={props.onSession}
>
- {props.t("session.tab.session")}
+ {language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="changes"
@@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges}
>
{props.hasReview
- ? props.t("session.review.filesChanged", { count: props.reviewCount })
- : props.t("session.review.change.other")}
+ ? language.t("session.review.filesChanged", { count: props.reviewCount })
+ : language.t("session.review.change.other")}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx
index 83fc615b5..3f0b7a6e8 100644
--- a/packages/app/src/pages/session/session-prompt-dock.tsx
+++ b/packages/app/src/pages/session/session-prompt-dock.tsx
@@ -1,35 +1,105 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
-import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
+import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
+import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { usePrompt } from "@/context/prompt"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: {
centered: boolean
- questionRequest: () => QuestionRequest | undefined
- permissionRequest: () => { patterns: string[]; permission: string } | undefined
- blocked: boolean
- todos: Todo[]
- promptReady: boolean
- handoffPrompt?: string
- t: (key: string, vars?: Record<string, string | number | boolean>) => string
- responding: boolean
- onDecide: (response: "once" | "always" | "reject") => void
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
+ const params = useParams()
+ const sdk = useSDK()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+ const prompt = usePrompt()
+ const language = useLanguage()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
+
+ const todos = createMemo((): Todo[] => {
+ const id = params.id
+ if (!id) return []
+ return globalSync.data.session_todo[id] ?? []
+ })
+
+ const questionRequest = createMemo((): QuestionRequest | undefined => {
+ const sessionID = params.id
+ if (!sessionID) return
+ return sync.data.question[sessionID]?.[0]
+ })
+
+ const permissionRequest = createMemo((): PermissionRequest | undefined => {
+ const sessionID = params.id
+ if (!sessionID) return
+ return sync.data.permission[sessionID]?.[0]
+ })
+
+ const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
+
+ const previewPrompt = () =>
+ prompt
+ .current()
+ .map((part) => {
+ if (part.type === "file") return `[file:${part.path}]`
+ if (part.type === "agent") return `@${part.name}`
+ if (part.type === "image") return `[image:${part.filename}]`
+ return part.content
+ })
+ .join("")
+ .trim()
+
+ createEffect(() => {
+ if (!prompt.ready()) return
+ setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
+ })
+
+ const [responding, setResponding] = createSignal(false)
+
+ createEffect(
+ on(
+ () => permissionRequest()?.id,
+ () => setResponding(false),
+ { defer: true },
+ ),
+ )
+
+ const decide = (response: "once" | "always" | "reject") => {
+ const perm = permissionRequest()
+ if (!perm) return
+ if (responding()) return
+
+ setResponding(true)
+ sdk.client.permission
+ .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
+ .catch((err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: message })
+ })
+ .finally(() => setResponding(false))
+ }
+
const done = createMemo(
- () =>
- props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
+ () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
- const [dock, setDock] = createSignal(props.todos.length > 0)
+ const [dock, setDock] = createSignal(todos().length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
@@ -46,7 +116,7 @@ export function SessionPromptDock(props: {
createEffect(
on(
- () => [props.todos.length, done()] as const,
+ () => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
@@ -113,7 +183,7 @@ export function SessionPromptDock(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
- <Show when={props.questionRequest()} keyed>
+ <Show when={questionRequest()} keyed>
{(req) => {
return (
<div>
@@ -123,11 +193,11 @@ export function SessionPromptDock(props: {
}}
</Show>
- <Show when={props.permissionRequest()} keyed>
+ <Show when={permissionRequest()} keyed>
{(perm) => {
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
- const value = props.t(key)
+ const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
@@ -141,36 +211,26 @@ export function SessionPromptDock(props: {
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
- <div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
+ <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
- <Button
- variant="ghost"
- size="normal"
- onClick={() => props.onDecide("reject")}
- disabled={props.responding}
- >
- {props.t("ui.permission.deny")}
+ <Button variant="ghost" size="normal" onClick={() => decide("reject")} disabled={responding()}>
+ {language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
- onClick={() => props.onDecide("always")}
- disabled={props.responding}
+ onClick={() => decide("always")}
+ disabled={responding()}
>
- {props.t("ui.permission.allowAlways")}
+ {language.t("ui.permission.allowAlways")}
</Button>
- <Button
- variant="primary"
- size="normal"
- onClick={() => props.onDecide("once")}
- disabled={props.responding}
- >
- {props.t("ui.permission.allowOnce")}
+ <Button variant="primary" size="normal" onClick={() => decide("once")} disabled={responding()}>
+ {language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
@@ -199,12 +259,12 @@ export function SessionPromptDock(props: {
}}
</Show>
- <Show when={!props.blocked}>
+ <Show when={!blocked()}>
<Show
- when={props.promptReady}
+ when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
- {props.handoffPrompt || props.t("prompt.loading")}
+ {handoffPrompt() || language.t("prompt.loading")}
</div>
}
>
@@ -219,10 +279,10 @@ export function SessionPromptDock(props: {
}}
>
<SessionTodoDock
- todos={props.todos}
- title={props.t("session.todo.title")}
- collapseLabel={props.t("session.todo.collapse")}
- expandLabel={props.t("session.todo.expand")}
+ todos={todos()}
+ title={language.t("session.todo.title")}
+ collapseLabel={language.t("session.todo.collapse")}
+ expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 33954f64a..68dfc346f 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/packages/app/src/pages/session/session-side-panel.tsx
@@ -1,156 +1,269 @@
-import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
+import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createMediaQuery } from "@solid-primitives/media"
+import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
-import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file"
-import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
-import { FileTabContent } from "@/pages/session/file-tabs"
-import { StickyAddButton } from "@/pages/session/review-tab"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
-import { ConstrainDragYAxis } from "@/utils/solid-dnd"
-import type { DragEvent } from "@thisbeyond/solid-dnd"
-import { useComments } from "@/context/comments"
+import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
-import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
-
-type SessionSidePanelViewModel = {
- messages: () => Message[]
- visibleUserMessages: () => UserMessage[]
- view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
- info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
-}
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import { getTabReorderIndex } from "@/pages/session/helpers"
+import { StickyAddButton } from "@/pages/session/review-tab"
+import { setSessionHandoff } from "@/pages/session/handoff"
export function SessionSidePanel(props: {
- open: boolean
- reviewOpen: boolean
- language: ReturnType<typeof useLanguage>
- layout: ReturnType<typeof useLayout>
- command: ReturnType<typeof useCommand>
- dialog: ReturnType<typeof useDialog>
- file: ReturnType<typeof useFile>
- comments: ReturnType<typeof useComments>
- hasReview: boolean
- reviewCount: number
- reviewTab: boolean
- contextOpen: () => boolean
- openedTabs: () => string[]
- activeTab: () => string
- activeFileTab: () => string | undefined
- tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
- openTab: (value: string) => void
- showAllFiles: () => void
reviewPanel: () => JSX.Element
- vm: SessionSidePanelViewModel
- handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
- codeComponent: NonNullable<ValidComponent>
- addCommentToContext: (input: {
- file: string
- selection: SelectedLineRange
- comment: string
- preview?: string
- origin?: "review" | "file"
- }) => void
- activeDraggable: () => string | undefined
- onDragStart: (event: unknown) => void
- onDragEnd: () => void
- onDragOver: (event: DragEvent) => void
- fileTreeTab: () => "changes" | "all"
- setFileTreeTabValue: (value: string) => void
- diffsReady: boolean
- diffFiles: string[]
- kinds: Map<string, "add" | "del" | "mix">
activeDiff?: string
focusReviewDiff: (path: string) => void
}) {
- const openedTabs = createMemo(() => props.openedTabs())
+ const params = useParams()
+ const layout = useLayout()
+ const sync = useSync()
+ const file = useFile()
+ const language = useLanguage()
+ const command = useCommand()
+ const dialog = useDialog()
+
+ const isDesktop = createMediaQuery("(min-width: 768px)")
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey))
+ const view = createMemo(() => layout.view(sessionKey))
+
+ const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
+ const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
+ const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
+
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+ const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
+ const hasReview = createMemo(() => reviewCount() > 0)
+ const diffsReady = createMemo(() => {
+ const id = params.id
+ if (!id) return true
+ if (!hasReview()) return true
+ return sync.data.session_diff[id] !== undefined
+ })
+
+ const diffFiles = createMemo(() => diffs().map((d) => d.file))
+ const kinds = createMemo(() => {
+ const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
+ if (!a) return b
+ if (a === b) return a
+ return "mix" as const
+ }
+
+ const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
+
+ const out = new Map<string, "add" | "del" | "mix">()
+ for (const diff of diffs()) {
+ const file = normalize(diff.file)
+ const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
+
+ out.set(file, kind)
+
+ const parts = file.split("/")
+ for (const [idx] of parts.slice(0, -1).entries()) {
+ const dir = parts.slice(0, idx + 1).join("/")
+ if (!dir) continue
+ out.set(dir, merge(out.get(dir), kind))
+ }
+ }
+ return out
+ })
+
+ const normalizeTab = (tab: string) => {
+ if (!tab.startsWith("file://")) return tab
+ return file.tab(tab)
+ }
+
+ const openReviewPanel = () => {
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
+ }
+
+ const openTab = (value: string) => {
+ const next = normalizeTab(value)
+ tabs().open(next)
+
+ const path = file.pathFromTab(next)
+ if (!path) return
+ file.load(path)
+ openReviewPanel()
+ }
+
+ const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
+ const openedTabs = createMemo(() =>
+ tabs()
+ .all()
+ .filter((tab) => tab !== "context" && tab !== "review"),
+ )
+
+ const activeTab = createMemo(() => {
+ const active = tabs().active()
+ if (active === "context") return "context"
+ if (active === "review" && reviewTab()) return "review"
+ if (active && file.pathFromTab(active)) return normalizeTab(active)
+
+ const first = openedTabs()[0]
+ if (first) return first
+ if (contextOpen()) return "context"
+ if (reviewTab() && hasReview()) return "review"
+ return "empty"
+ })
+
+ const activeFileTab = createMemo(() => {
+ const active = activeTab()
+ if (!openedTabs().includes(active)) return
+ return active
+ })
+
+ const fileTreeTab = () => layout.fileTree.tab()
+
+ const setFileTreeTabValue = (value: string) => {
+ if (value !== "changes" && value !== "all") return
+ layout.fileTree.setTab(value)
+ }
+
+ const showAllFiles = () => {
+ if (fileTreeTab() !== "changes") return
+ layout.fileTree.setTab("all")
+ }
+
+ const [store, setStore] = createStore({
+ activeDraggable: undefined as string | undefined,
+ })
+
+ const handleDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (!draggable || !droppable) return
+
+ const currentTabs = tabs().all()
+ const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
+ if (toIndex === undefined) return
+ tabs().move(draggable.id.toString(), toIndex)
+ }
+
+ const handleDragEnd = () => {
+ setStore("activeDraggable", undefined)
+ }
+
+ createEffect(() => {
+ if (!file.ready()) return
+
+ setSessionHandoff(sessionKey(), {
+ files: tabs()
+ .all()
+ .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
+ const path = file.pathFromTab(tab)
+ if (!path) return acc
+
+ const selected = file.selectedLines(path)
+ acc[path] =
+ selected && typeof selected === "object" && "start" in selected && "end" in selected
+ ? (selected as SelectedLineRange)
+ : null
+
+ return acc
+ }, {}),
+ })
+ })
return (
- <Show when={props.open}>
+ <Show when={open()}>
<aside
id="review-panel"
- aria-label={props.language.t("session.panel.reviewAndFiles")}
+ aria-label={language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{
- "flex-1": props.reviewOpen,
- "shrink-0": !props.reviewOpen,
+ "flex-1": reviewOpen(),
+ "shrink-0": !reviewOpen(),
}}
- style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
+ style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
>
- <Show when={props.reviewOpen}>
+ <Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full">
<Show
- when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
+ when={layout.fileTree.opened() && fileTreeTab() === "changes"}
fallback={
<DragDropProvider
- onDragStart={props.onDragStart}
- onDragEnd={props.onDragEnd}
- onDragOver={props.onDragOver}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs value={props.activeTab()} onChange={props.openTab}>
+ <Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List
ref={(el: HTMLDivElement) => {
- const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
+ const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop)
}}
>
- <Show when={props.reviewTab}>
+ <Show when={reviewTab()}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5">
- <div>{props.language.t("session.tab.review")}</div>
- <Show when={props.hasReview}>
+ <div>{language.t("session.tab.review")}</div>
+ <Show when={hasReview()}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {props.reviewCount}
+ {reviewCount()}
</div>
</Show>
</div>
</Tabs.Trigger>
</Show>
- <Show when={props.contextOpen()}>
+ <Show when={contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
- <Tooltip value={props.language.t("common.closeTab")} placement="bottom">
+ <Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
class="h-5 w-5"
- onClick={() => props.tabs().close("context")}
- aria-label={props.language.t("common.closeTab")}
+ onClick={() => tabs().close("context")}
+ aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
- onMiddleClick={() => props.tabs().close("context")}
+ onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
- <div>{props.language.t("session.tab.context")}</div>
+ <div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
- <For each={openedTabs()}>
- {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
- </For>
+ <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<StickyAddButton>
<TooltipKeybind
- title={props.language.t("command.file.open")}
- keybind={props.command.keybind("file.open")}
+ title={language.t("command.file.open")}
+ keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
@@ -158,72 +271,52 @@ export function SessionSidePanel(props: {
variant="ghost"
iconSize="large"
onClick={() =>
- props.dialog.show(() => (
- <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
- ))
+ dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
}
- aria-label={props.language.t("command.file.open")}
+ aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</StickyAddButton>
</Tabs.List>
</div>
- <Show when={props.reviewTab}>
+ <Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
+ <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={props.activeTab() === "empty"}>
+ <Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
- {props.language.t("session.files.selectToOpen")}
+ {language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
- <Show when={props.contextOpen()}>
+ <Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
- <Show when={props.activeTab() === "context"}>
+ <Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <SessionContextTab
- messages={props.vm.messages}
- visibleUserMessages={props.vm.visibleUserMessages}
- view={props.vm.view}
- info={props.vm.info}
- />
+ <SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
- <Show when={props.activeFileTab()} keyed>
- {(tab) => (
- <FileTabContent
- tab={tab}
- activeTab={props.activeTab}
- tabs={props.tabs}
- view={props.vm.view}
- handoffFiles={props.handoffFiles}
- file={props.file}
- comments={props.comments}
- language={props.language}
- codeComponent={props.codeComponent}
- addCommentToContext={props.addCommentToContext}
- />
- )}
+ <Show when={activeFileTab()} keyed>
+ {(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
- <Show when={props.activeDraggable()}>
+ <Show when={store.activeDraggable} keyed>
{(tab) => {
- const path = createMemo(() => props.file.pathFromTab(tab()))
+ const path = createMemo(() => file.pathFromTab(tab))
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
@@ -240,50 +333,44 @@ export function SessionSidePanel(props: {
</div>
</Show>
- <Show when={props.layout.fileTree.opened()}>
- <div
- id="file-tree-panel"
- class="relative shrink-0 h-full"
- style={{ width: `${props.layout.fileTree.width()}px` }}
- >
+ <Show when={layout.fileTree.opened()}>
+ <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
- classList={{ "border-l border-border-weak-base": props.reviewOpen }}
+ classList={{ "border-l border-border-weak-base": reviewOpen() }}
>
<Tabs
variant="pill"
- value={props.fileTreeTab()}
- onChange={props.setFileTreeTabValue}
+ value={fileTreeTab()}
+ onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
- {props.reviewCount}{" "}
- {props.language.t(
- props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
- )}
+ {reviewCount()}{" "}
+ {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
- {props.language.t("session.files.all")}
+ {language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Switch>
- <Match when={props.hasReview}>
+ <Match when={hasReview()}>
<Show
- when={props.diffsReady}
+ when={diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
- {props.language.t("common.loading")}
- {props.language.t("common.loading.ellipsis")}
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
- allowed={props.diffFiles}
- kinds={props.kinds}
+ allowed={diffFiles()}
+ kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -292,7 +379,7 @@ export function SessionSidePanel(props: {
</Match>
<Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak">
- {props.language.t("session.review.noChanges")}
+ {language.t("session.review.noChanges")}
</div>
</Match>
</Switch>
@@ -300,9 +387,9 @@ export function SessionSidePanel(props: {
<Tabs.Content value="all" class="bg-background-base px-3 py-0">
<FileTree
path=""
- modified={props.diffFiles}
- kinds={props.kinds}
- onFileClick={(node) => props.openTab(props.file.tab(node.path))}
+ modified={diffFiles()}
+ kinds={kinds()}
+ onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Tabs.Content>
</Tabs>
@@ -310,12 +397,12 @@ export function SessionSidePanel(props: {
<ResizeHandle
direction="horizontal"
edge="start"
- size={props.layout.fileTree.width()}
+ size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
- onResize={props.layout.fileTree.resize}
- onCollapse={props.layout.fileTree.close}
+ onResize={layout.fileTree.resize}
+ onCollapse={layout.fileTree.close}
/>
</div>
</Show>
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 7ec4356b1..33421c386 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -1,61 +1,161 @@
-import { For, Show, createMemo } from "solid-js"
+import { For, Show, createEffect, createMemo, on } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createMediaQuery } from "@solid-primitives/media"
+import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
-import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+
import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal"
-import { useTerminal } from "@/context/terminal"
-import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label"
+import { focusTerminalById } from "@/pages/session/helpers"
+import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
+
+export function TerminalPanel() {
+ const params = useParams()
+ const layout = useLayout()
+ const terminal = useTerminal()
+ const language = useLanguage()
+ const command = useCommand()
+
+ const isDesktop = createMediaQuery("(min-width: 768px)")
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const view = createMemo(() => layout.view(sessionKey))
+
+ const opened = createMemo(() => view().terminal.opened())
+ const open = createMemo(() => isDesktop() && opened())
+ const height = createMemo(() => layout.terminal.height())
+ const close = () => view().terminal.close()
+
+ const [store, setStore] = createStore({
+ autoCreated: false,
+ activeDraggable: undefined as string | undefined,
+ })
+
+ createEffect(() => {
+ if (!opened()) {
+ setStore("autoCreated", false)
+ return
+ }
+
+ if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
+ terminal.new()
+ setStore("autoCreated", true)
+ })
+
+ createEffect(
+ on(
+ () => terminal.all().length,
+ (count, prevCount) => {
+ if (prevCount !== undefined && prevCount > 0 && count === 0) {
+ if (opened()) view().terminal.toggle()
+ }
+ },
+ ),
+ )
-export function TerminalPanel(props: {
- open: boolean
- height: number
- resize: (value: number) => void
- close: () => void
- terminal: ReturnType<typeof useTerminal>
- language: ReturnType<typeof useLanguage>
- command: ReturnType<typeof useCommand>
- handoff: () => string[]
- activeTerminalDraggable: () => string | undefined
- handleTerminalDragStart: (event: unknown) => void
- handleTerminalDragOver: (event: DragEvent) => void
- handleTerminalDragEnd: () => void
- onCloseTab: () => void
-}) {
- const all = createMemo(() => props.terminal.all())
+ createEffect(
+ on(
+ () => terminal.active(),
+ (activeId) => {
+ if (!activeId || !opened()) return
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur()
+ }
+ focusTerminalById(activeId)
+ },
+ ),
+ )
+
+ createEffect(() => {
+ const dir = params.dir
+ if (!dir) return
+ if (!terminal.ready()) return
+ language.locale()
+
+ setTerminalHandoff(
+ dir,
+ terminal.all().map((pty) =>
+ terminalTabLabel({
+ title: pty.title,
+ titleNumber: pty.titleNumber,
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+ }),
+ ),
+ )
+ })
+
+ const handoff = createMemo(() => {
+ const dir = params.dir
+ if (!dir) return []
+ return getTerminalHandoff(dir) ?? []
+ })
+
+ const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
+ const handleTerminalDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleTerminalDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (!draggable || !droppable) return
+
+ const terminals = terminal.all()
+ const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+ const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
+ if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
+ terminal.move(draggable.id.toString(), toIndex)
+ }
+ }
+
+ const handleTerminalDragEnd = () => {
+ setStore("activeDraggable", undefined)
+
+ const activeId = terminal.active()
+ if (!activeId) return
+ setTimeout(() => {
+ focusTerminalById(activeId)
+ }, 0)
+ }
+
return (
- <Show when={props.open}>
+ <Show when={open()}>
<div
id="terminal-panel"
role="region"
- aria-label={props.language.t("terminal.title")}
+ aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
- style={{ height: `${props.height}px` }}
+ style={{ height: `${height()}px` }}
>
<ResizeHandle
direction="vertical"
- size={props.height}
+ size={height()}
min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
- onResize={props.resize}
- onCollapse={props.close}
+ onResize={layout.terminal.resize}
+ onCollapse={close}
/>
<Show
- when={props.terminal.ready()}
+ when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
- <For each={props.handoff()}>
+ <For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
@@ -64,20 +164,18 @@ export function TerminalPanel(props: {
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">
- {props.language.t("common.loading")}
- {props.language.t("common.loading.ellipsis")}
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
</div>
</div>
- <div class="flex-1 flex items-center justify-center text-text-weak">
- {props.language.t("terminal.loading")}
- </div>
+ <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
</div>
}
>
<DragDropProvider
- onDragStart={props.handleTerminalDragStart}
- onDragEnd={props.handleTerminalDragEnd}
- onDragOver={props.handleTerminalDragOver}
+ onDragStart={handleTerminalDragStart}
+ onDragEnd={handleTerminalDragEnd}
+ onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
@@ -85,36 +183,26 @@ export function TerminalPanel(props: {
<div class="flex flex-col h-full">
<Tabs
variant="alt"
- value={props.terminal.active()}
- onChange={(id) => props.terminal.open(id)}
+ value={terminal.active()}
+ onChange={(id) => terminal.open(id)}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={ids()}>
- <For each={all()}>
- {(pty) => (
- <SortableTerminalTab
- terminal={pty}
- onClose={() => {
- props.close()
- props.onCloseTab()
- }}
- />
- )}
- </For>
+ <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
- title={props.language.t("command.terminal.new")}
- keybind={props.command.keybind("terminal.new")}
+ title={language.t("command.terminal.new")}
+ keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
- onClick={props.terminal.new}
- aria-label={props.language.t("command.terminal.new")}
+ onClick={terminal.new}
+ aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
@@ -127,15 +215,11 @@ export function TerminalPanel(props: {
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
- display: props.terminal.active() === pty.id ? "block" : "none",
+ display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
- <Terminal
- pty={pty}
- onCleanup={props.terminal.update}
- onConnectError={() => props.terminal.clone(pty.id)}
- />
+ <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Show>
</div>
)}
@@ -143,25 +227,20 @@ export function TerminalPanel(props: {
</div>
</div>
<DragOverlay>
- <Show when={props.activeTerminalDraggable()}>
- {(draggedId) => {
- return (
- <Show when={byId().get(draggedId())}>
- {(t) => (
- <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
- {terminalTabLabel({
- title: t().title,
- titleNumber: t().titleNumber,
- t: props.language.t as (
- key: string,
- vars?: Record<string, string | number | boolean>,
- ) => string,
- })}
- </div>
- )}
- </Show>
- )
- }}
+ <Show when={store.activeDraggable}>
+ {(draggedId) => (
+ <Show when={byId().get(draggedId())}>
+ {(t) => (
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+ {terminalTabLabel({
+ title: t().title,
+ titleNumber: t().titleNumber,
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+ })}
+ </div>
+ )}
+ </Show>
+ )}
</Show>
</DragOverlay>
</DragDropProvider>