Files
opencode/packages/app/src/pages/session.tsx
2026-01-26 11:07:52 -06:00

2841 lines
105 KiB
TypeScript

import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } 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 } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionReview } from "@opencode-ai/ui/session-review"
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 { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments, type LineComment } from "@/context/comments"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import {
SessionHeader,
SessionContextTab,
SortableTab,
FileVisual,
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
}
interface SessionReviewTabProps {
diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
comments?: LineComment[]
focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
onScrollRef?: (el: HTMLDivElement) => void
classes?: {
root?: string
header?: string
container?: string
}
}
function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const sdk = useSDK()
const readFile = async (path: string) => {
return sdk.client.file
.read({ path })
.then((x) => x.data)
.catch(() => undefined)
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = props.view().scroll("review")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const next = pending
pending = undefined
if (!next) return
props.view().setScroll("review", next)
})
}
createEffect(
on(
() => props.diffs().length,
() => {
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
return (
<SessionReview
scrollRef={(el) => {
scroll = el
props.onScrollRef?.(el)
restoreScroll()
}}
onScroll={handleScroll}
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
root: props.classes?.root ?? "pb-40",
header: props.classes?.header ?? "px-6",
container: props.classes?.container ?? "px-6",
}}
diffs={props.diffs()}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
readFile={readFile}
onLineComment={props.onLineComment}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
/>
)
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
const file = useFile()
const sync = useSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const request = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
const next = sync.data.permission[sessionID]?.[0]
if (!next) return
if (next.tool) return
return next
})
const [responding, setResponding] = createSignal(false)
createEffect(
on(
() => request()?.id,
() => setResponding(false),
{ defer: true },
),
)
const decide = (response: "once" | "always" | "reject") => {
const perm = request()
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 [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)")
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
function normalizeTabs(list: string[]) {
const seen = new Set<string>()
const next: string[] = []
for (const item of list) {
const value = normalizeTab(item)
if (seen.has(value)) continue
seen.add(value)
next.push(value)
}
return next
}
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (path) file.load(path)
}
createEffect(() => {
const active = tabs().active()
if (!active) return
const path = file.pathFromTab(active)
if (path) file.load(path)
})
createEffect(() => {
const current = tabs().all()
if (current.length === 0) return
const next = normalizeTabs(current)
if (same(current, next)) return
tabs().setAll(next)
const active = tabs().active()
if (!active) return
if (!active.startsWith("file://")) return
const normalized = normalizeTab(active)
if (active === normalized) return
tabs().setActive(normalized)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => {
const id = params.id
if (!id) return true
return sync.data.message[id] !== undefined
})
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{
equals: same,
},
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
on(
() => lastUserMessage()?.id,
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
},
),
)
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "review",
newSessionWorktree: "main",
promptHeight: 0,
})
const renderedUserMessages = createMemo(
() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
return "main"
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setStore("messageId", message?.id)
}
function navigateMessageByOffset(offset: number) {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
const current = activeMessage()
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
if (targetIndex < 0 || targetIndex >= msgs.length) return
scrollToMessage(msgs[targetIndex], "auto")
}
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
const [scrollGesture, setScrollGesture] = createSignal(0)
const scrollGestureWindowMs = 250
const markScrollGesture = (target?: EventTarget | null) => {
const root = scroller
if (!root) return
const el = target instanceof Element ? target : undefined
const nested = el?.closest("[data-scrollable]")
if (nested && nested !== root) return
setScrollGesture(Date.now())
}
const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
})
const [autoCreated, setAutoCreated] = createSignal(false)
createEffect(() => {
if (!view().terminal.opened()) {
setAutoCreated(false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
terminal.new()
setAutoCreated(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()
}
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
// Find and focus the ghostty textarea (the actual input element)
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
// Fallback: focus container and dispatch pointer event
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
},
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
(lastId, prevLastId) => {
if (lastId && prevLastId && lastId > prevLastId) {
setStore("messageId", undefined)
}
},
{ defer: true },
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => params.id,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
},
{ defer: true },
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? selectionPreview(input.file, 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,
})
}
command.register(() => [
{
id: "session.new",
title: "New session",
category: "Session",
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
{
id: "file.open",
title: "Open file",
description: "Search files and commands",
category: "File",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
{
id: "context.addSelection",
title: "Add selection to context",
description: "Add selected lines from the current file",
category: "Context",
keybind: "mod+shift+l",
disabled: (() => {
const active = tabs().active()
if (!active) return true
const path = file.pathFromTab(active)
if (!path) return true
return file.selectedLines(path) == null
})(),
onSelect: () => {
const active = tabs().active()
if (!active) return
const path = file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path)
if (!range) {
showToast({
title: "No line selection",
description: "Select a line range in a file tab first.",
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
},
{
id: "terminal.toggle",
title: "Toggle terminal",
description: "",
category: "View",
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
},
{
id: "review.toggle",
title: "Toggle review",
description: "",
category: "View",
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
},
{
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
category: language.t("command.category.terminal"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
},
{
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide steps for the current message",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
{
id: "message.previous",
title: "Previous message",
description: "Go to the previous user message",
category: "Session",
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
},
{
id: "message.next",
title: "Next message",
description: "Go to the next user message",
category: "Session",
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
{
id: "model.choose",
title: "Choose model",
description: "Select a different model",
category: "Model",
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
},
{
id: "mcp.toggle",
title: "Toggle MCPs",
description: "Toggle MCPs",
category: "MCP",
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{
id: "agent.cycle",
title: "Cycle agent",
description: "Switch to the next agent",
category: "Agent",
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
},
{
id: "agent.cycle.reverse",
title: "Cycle agent backwards",
description: "Switch to the previous agent",
category: "Agent",
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "model.variant.cycle",
title: "Cycle thinking effort",
description: "Switch to the next effort level",
category: "Model",
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
},
},
{
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? "Stop auto-accepting edits"
: "Auto-accept edits",
category: "Permissions",
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? "Auto-accepting edits"
: "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID, sdk.directory)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
},
},
{
id: "session.undo",
title: "Undo",
description: "Undo the last message",
category: "Session",
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
// Find the last user message that's not already reverted
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
{
id: "session.redo",
title: "Redo",
description: "Redo the last undone message",
category: "Session",
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) {
// Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
{
id: "session.compact",
title: "Compact session",
description: "Summarize the session to reduce context size",
category: "Session",
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: "No model selected",
description: "Connect a provider to summarize this session",
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
},
{
id: "session.fork",
title: "Fork from message",
description: "Create a new session from a previous message",
category: "Session",
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
...(sync.data.config.share !== "disabled"
? [
{
id: "session.share",
title: "Share session",
description: "Share this session and copy the URL to clipboard",
category: "Session",
slash: "share",
disabled: !params.id || !!info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.share({ sessionID: params.id })
.then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({
title: "Failed to copy URL to clipboard",
variant: "error",
}),
)
})
.then(() =>
showToast({
title: "Session shared",
description: "Share URL copied to clipboard!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to share session",
description: "An error occurred while sharing the session",
variant: "error",
}),
)
},
},
{
id: "session.unshare",
title: "Unshare session",
description: "Stop sharing this session",
category: "Session",
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: "Session unshared",
description: "Session unshared successfully!",
variant: "success",
}),
)
.catch(() =>
showToast({
title: "Failed to unshare session",
description: "An error occurred while unsharing the session",
variant: "error",
}),
)
},
},
]
: []),
])
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
if (isProtected || isInput) return
}
if (dialog.active) return
if (activeElement === inputRef) {
if (event.key === "Escape") inputRef?.blur()
return
}
// Don't autofocus chat if terminal panel is open
if (view().terminal.opened()) return
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
markScrollGesture()
return
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
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 fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
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(() => {
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
// Find and focus the ghostty textarea (the actual input element)
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
// Fallback: focus container and dispatch pointer event
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context"),
)
const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review")
const showTabs = createMemo(() => view().reviewPanel.opened())
const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes")
const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined)
const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined)
createEffect(() => {
if (!layout.fileTree.opened()) return
setFileTreeTab("changes")
})
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
setFileTreeTab(value)
}
const reviewDiffId = (path: string) => {
const sum = checksum(path)
if (!sum) return
return `session-review-diff-${sum}`
}
const reviewDiffTop = (path: string) => {
const root = reviewScroll()
if (!root) return
const id = reviewDiffId(path)
if (!id) return
const el = document.getElementById(id)
if (!(el instanceof HTMLElement)) return
if (!root.contains(el)) return
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
return a.top - b.top + root.scrollTop
}
const scrollToReviewDiff = (path: string) => {
const root = reviewScroll()
if (!root) return false
const top = reviewDiffTop(path)
if (top === undefined) return false
view().setScroll("review", { x: root.scrollLeft, y: top })
root.scrollTo({ top, behavior: "auto" })
return true
}
const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setPendingDiff(path)
}
createEffect(() => {
const pending = pendingDiff()
if (!pending) return
if (!reviewScroll()) return
if (!diffsReady()) return
const attempt = (count: number) => {
if (pendingDiff() !== pending) return
if (count > 60) {
setPendingDiff(undefined)
return
}
const root = reviewScroll()
if (!root) {
requestAnimationFrame(() => attempt(count + 1))
return
}
if (!scrollToReviewDiff(pending)) {
requestAnimationFrame(() => attempt(count + 1))
return
}
const top = reviewDiffTop(pending)
if (top === undefined) {
requestAnimationFrame(() => attempt(count + 1))
return
}
if (Math.abs(root.scrollTop - top) <= 1) {
setPendingDiff(undefined)
return
}
requestAnimationFrame(() => attempt(count + 1))
}
requestAnimationFrame(() => attempt(0))
})
const activeTab = createMemo(() => {
const active = tabs().active()
if (layout.fileTree.opened() && fileTreeTab() === "all") {
if (active && active !== "review" && active !== "context") return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
return "review"
}
if (active) return normalizeTab(active)
if (hasReview()) return "review"
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
return "review"
})
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
tabs().setActive(activeTab())
})
createEffect(() => {
if (!layout.fileTree.opened()) return
if (fileTreeTab() !== "all") return
const first = openedTabs()[0]
if (!first) return
const active = tabs().active()
if (active && active !== "review" && active !== "context") return
tabs().setActive(first)
})
createEffect(() => {
const id = params.id
if (!id) return
if (!hasReview()) return
const wants = isDesktop()
? view().reviewPanel.opened() &&
(layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review")
: store.mobileTab === "review"
if (!wants) return
if (diffsReady()) return
sync.session.diff(id)
})
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "dynamic",
})
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
}
// When the user returns to the bottom, treat the active message as "latest".
createEffect(
on(
autoScroll.userScrolled,
(scrolled) => {
if (scrolled) return
setStore("messageId", undefined)
},
{ defer: true },
),
)
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
}
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
let turnIdle = false
function cancelTurnBackfill() {
const handle = turnHandle
if (handle === undefined) return
turnHandle = undefined
if (turnIdle && window.cancelIdleCallback) {
window.cancelIdleCallback(handle)
return
}
clearTimeout(handle)
}
function scheduleTurnBackfill() {
if (turnHandle !== undefined) return
if (store.turnStart <= 0) return
if (window.requestIdleCallback) {
turnIdle = true
turnHandle = window.requestIdleCallback(() => {
turnHandle = undefined
backfillTurns()
})
return
}
turnIdle = false
turnHandle = window.setTimeout(() => {
turnHandle = undefined
backfillTurns()
}, 0)
}
function backfillTurns() {
const start = store.turnStart
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
const el = scroller
if (!el) {
setStore("turnStart", nextStart)
scheduleTurnBackfill()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
setStore("turnStart", nextStart)
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (delta) el.scrollTop = beforeTop + delta
})
scheduleTurnBackfill()
}
createEffect(
on(
() => [params.id, messagesReady()] as const,
([id, ready]) => {
cancelTurnBackfill()
setStore("turnStart", 0)
if (!id || !ready) return
const len = visibleUserMessages().length
const start = len > turnInit ? len - turnInit : 0
setStore("turnStart", start)
scheduleTurnBackfill()
},
{ defer: true },
),
)
createResizeObserver(
() => promptDock,
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
},
)
const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setPendingMessage(messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) return false
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index)
scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
})
updateHash(message.id)
return
}
const el = document.getElementById(anchor(message.id))
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
}
if (scrollToElement(el, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, behavior)
return
}
// If we have a message hash but the message isn't loaded/rendered yet,
// don't fall back to "bottom". We'll retry once messages arrive.
return
}
const target = document.getElementById(hash)
if (target) {
scrollToElement(target, behavior)
return
}
autoScroll.forceScrollToBottom()
}
const closestMessage = (node: Element | null): HTMLElement | null => {
if (!node) return null
const match = node.closest?.("[data-message-id]") as HTMLElement | null
if (match) return match
const root = node.getRootNode?.()
if (root instanceof ShadowRoot) return closestMessage(root.host)
return null
}
const getActiveMessageId = (container: HTMLDivElement) => {
const rect = container.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
const hit = document.elementFromPoint(x, y)
const host = closestMessage(hit)
const id = host?.dataset.messageId
if (id) return id
// Fallback: DOM query (handles edge hit-testing cases)
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let last: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
last = next
}
return last
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
scrollSpyTarget = container
if (scrollSpyFrame !== undefined) return
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = undefined
const target = scrollSpyTarget
scrollSpyTarget = undefined
if (!target) return
const id = getActiveMessageId(target)
if (!id) return
if (id === store.messageId) return
setStore("messageId", id)
})
}
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
requestAnimationFrame(() => {
applyHash("auto")
})
})
// Retry message navigation once the target message is actually loaded.
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
// dependencies
visibleUserMessages().length
store.turnStart
const targetId =
pendingMessage() ??
(() => {
const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
if (!match) return undefined
return match[1]
})()
if (!targetId) return
if (store.messageId === targetId) return
const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
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
handoff.prompt = previewPrompt()
})
createEffect(() => {
if (!terminal.ready()) return
language.locale()
const label = (pty: LocalPTY) => {
const title = pty.title
const number = pty.titleNumber
const match = title.match(/^Terminal (\d+)$/)
const parsed = match ? Number(match[1]) : undefined
const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
if (title && !isDefaultTitle) return title
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
if (title) return title
return language.t("terminal.title")
}
handoff.terminals = terminal.all().map(label)
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
})
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
return (
<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">
{/* Mobile tab bar - only shown on mobile when user opened review */}
<Show when={!isDesktop() && view().reviewPanel.opened()}>
<Tabs class="h-auto">
<Tabs.List>
<Tabs.Trigger
value="session"
class="w-1/2"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")}
>
{language.t("session.tab.session")}
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")}
>
<Switch>
<Match when={hasReview()}>
{language.t("session.review.filesChanged", { count: reviewCount() })}
</Match>
<Match when={true}>{language.t("session.tab.review")}</Match>
</Switch>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</Show>
{/* Session panel */}
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none pt-6 md:pt-3": true,
}}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id}>
<Show when={activeMessage()}>
<Show
when={!mobileReview()}
fallback={
<div class="relative h-full overflow-hidden">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-4 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">
{language.t("session.review.empty")}
</div>
</div>
</Match>
</Switch>
</div>
}
>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
"opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
"opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
}}
>
<button
class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
onClick={() => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
}}
>
<Icon name="arrow-down-to-line" />
</button>
</div>
<div
ref={setScrollRef}
onWheel={(e) => markScrollGesture(e.target)}
onTouchMove={(e) => markScrollGesture(e.target)}
onPointerDown={(e) => {
if (e.target !== e.currentTarget) return
markScrollGesture(e.target)
}}
onScroll={(e) => {
if (!hasScrollGesture()) return
markScrollGesture(e.target)
autoScroll.handleScroll()
if (isDesktop()) scheduleScrollSpy(e.currentTarget)
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
>
<Show when={info()?.title || info()?.parentID}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"w-full": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<div class="h-10 flex items-center gap-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title}>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
</Show>
</div>
</div>
</Show>
<div
ref={autoScroll.contentRef}
role="log"
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{
"w-full": true,
"md:max-w-200 md:mx-auto": !showTabs(),
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
}}
>
<Show when={store.turnStart > 0}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
onClick={() => setStore("turnStart", 0)}
>
Render earlier messages
</Button>
</div>
</Show>
<Show when={historyMore()}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={historyLoading()}
onClick={() => {
const id = params.id
if (!id) return
setStore("turnStart", 0)
sync.session.history.loadMore(id)
}}
>
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
</Button>
</div>
</Show>
<For each={renderedUserMessages()}>
{(message) => {
if (import.meta.env.DEV) {
onMount(() => {
const id = params.id
if (!id) return
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
})
}
return (
<div
id={anchor(message.id)}
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": !showTabs(),
}}
>
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
</div>
)
}}
</For>
</div>
</div>
</div>
</Show>
</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 === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/>
</Match>
</Switch>
</div>
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div
classList={{
"w-full px-4 pointer-events-auto": true,
"md:max-w-200": !showTabs(),
}}
>
<Show when={request()} keyed>
{(perm) => (
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
<BasicTool
icon="checklist"
locked
defaultOpen
trigger={{
title: language.t("notification.permission.title"),
subtitle:
perm.permission === "doom_loop"
? language.t("settings.permissions.tool.doom_loop.title")
: perm.permission,
}}
>
<Show when={perm.patterns.length > 0}>
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</Show>
<Show when={perm.permission === "doom_loop"}>
<div class="text-12-regular text-text-weak pb-2 px-3">
{language.t("settings.permissions.tool.doom_loop.description")}
</div>
</Show>
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={responding()}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => decide("always")}
disabled={responding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => decide("once")} disabled={responding()}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</div>
</div>
)}
</Show>
<Show
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">
{handoff.prompt || "Loading prompt..."}
</div>
}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={resumeScroll}
/>
</Show>
</div>
</div>
<Show when={isDesktop() && showTabs()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
</Show>
</div>
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
<Show when={isDesktop() && showTabs()}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
>
<div class="flex-1 min-w-0 h-full">
<Show when={layout.fileTree.opened() && fileTreeTab() === "changes"}>
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
</div>
</Match>
</Switch>
</div>
</div>
</Show>
<Show when={!layout.fileTree.opened() || fileTreeTab() === "all"}>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={!layout.fileTree.opened()}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<Show when={!layout.fileTree.opened() && contextOpen()}>
<Tabs.Trigger
value="context"
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close"
variant="ghost"
onClick={() => tabs().close("context")}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile />)}
aria-label={language.t("command.file.open")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<Show when={!layout.fileTree.opened()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={
<div class="px-6 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</Match>
<Match when={true}>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">
No changes in this session yet
</div>
</div>
</Match>
</Switch>
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={layout.fileTree.opened() && fileTreeTab() === "all" && openedTabs().length === 0}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">Select a file to open</div>
</div>
</Tabs.Content>
</Show>
<Show when={!layout.fileTree.opened() && contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={messages}
visibleUserMessages={visibleUserMessages}
view={view}
info={info}
/>
</div>
</Show>
</Tabs.Content>
</Show>
<For each={openedTabs()}>
{(tab) => {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let focusToken = 0
const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => {
const p = path()
if (!p) return
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return (
c?.encoding === "base64" &&
c?.mimeType?.startsWith("image/") &&
c?.mimeType !== "image/svg+xml"
)
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return base64Decode(c.content)
return c.content
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
})
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [openedComment, setOpenedComment] = createSignal<string | null>(null)
const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
const [draft, setDraft] = createSignal("")
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const empty = {} as Record<string, number>
const commentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start === end) return `line ${start}`
return `lines ${start}-${end}`
}
const getRoot = () => {
const el = wrap
if (!el) return
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const root = host.shadowRoot
if (!root) return
return root
}
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const node = root.querySelector(`[data-line="${line}"]`)
if (!(node instanceof HTMLElement)) return
return node
}
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
const wrapperRect = wrapper.getBoundingClientRect()
const rect = marker.getBoundingClientRect()
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
const equal = (a: Record<string, number>, b: Record<string, number>) => {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) return false
for (const key of aKeys) {
if (a[key] !== b[key]) return false
}
return true
}
const updateComments = () => {
const el = wrap
const root = getRoot()
if (!el || !root) {
setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
setDraftTop((prev) => (prev === undefined ? prev : undefined))
return
}
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (!marker) continue
next[comment.id] = markerTop(el, marker)
}
setPositions((prev) => (equal(prev, next) ? prev : next))
const range = commenting()
if (!range) {
setDraftTop(undefined)
return
}
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
}
const nextTop = markerTop(el, marker)
setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
}
let commentFrame: number | undefined
const scheduleComments = () => {
if (commentFrame !== undefined) return
commentFrame = requestAnimationFrame(() => {
commentFrame = undefined
updateComments()
})
}
createEffect(() => {
fileComments()
scheduleComments()
})
createEffect(() => {
commenting()
scheduleComments()
})
createEffect(() => {
const range = commenting()
if (!range) return
setDraft("")
})
createEffect(() => {
const focus = comments.focus()
const p = path()
if (!focus || !p) return
if (focus.file !== p) return
if (activeTab() !== tab) return
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
focusToken++
const token = focusToken
setOpenedComment(target.id)
setCommenting(null)
file.setSelectedLines(p, target.selection)
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const root = scroll
if (!root) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
const ready =
anchor instanceof HTMLElement &&
anchor.style.pointerEvents !== "none" &&
anchor.style.opacity !== "0"
const shadow = getRoot()
const marker = shadow ? findMarker(shadow, target.selection) : undefined
const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
if (!node) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const rootRect = root.getBoundingClientRect()
const targetRect = node.getBoundingClientRect()
const offset = targetRect.top - rootRect.top
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
root.scrollTop = Math.max(0, next)
if (ready || marker) return
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
requestAnimationFrame(() => comments.clearFocus())
})
const renderCode = (source: string, wrapperClass: string) => (
<div
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
setCommenting(null)
return
}
setOpenedComment(null)
setCommenting(range)
}}
overflow="scroll"
class="select-text"
/>
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={positions()[comment.id]}
open={openedComment() === comment.id}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setOpenedComment((current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
comment={comment.comment}
selection={commentLabel(comment.selection)}
/>
)}
</For>
<Show when={commenting()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={commentLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
const p = path()
if (!p) return
addCommentToContext({
file: p,
selection: range(),
comment,
origin: "file",
})
setCommenting(null)
}}
onPopoverFocusOut={(e) => {
const target = e.relatedTarget as Node | null
if (target && e.currentTarget.contains(target)) return
// Delay to allow click handlers to fire first
setTimeout(() => {
if (
!document.activeElement ||
!e.currentTarget.contains(document.activeElement)
) {
setCommenting(null)
}
}, 0)
}}
/>
</Show>
)}
</Show>
</div>
)
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const next = pending
pending = undefined
if (!next) return
view().setScroll(tab, next)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view()?.scroll(tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
createEffect(
on(
() => state()?.loaded,
(loaded) => {
if (!loaded) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect(
on(
() => file.ready(),
(ready) => {
if (!ready) return
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
createEffect(
on(
() => tabs().active() === tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => {
if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
})
return (
<Tabs.Content
value={tab}
class="mt-3 relative"
ref={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<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>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(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>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
<Show when={layout.fileTree.opened()}>
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
<Tabs variant="pill" value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
Changes
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
All files
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-base p-2">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">Loading...</div>}
>
<FileTree
path=""
allowed={diffs().map((d) => d.file)}
draggable={false}
tooltip={false}
onFileClick={(node) => focusReviewDiff(node.path)}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-2 py-2 text-12-regular text-text-weak">No changes</div>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-base p-2">
<FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
</Tabs.Content>
</Tabs>
</div>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>
</aside>
</Show>
</div>
<Show when={isDesktop() && view().terminal.opened()}>
<div
id="terminal-panel"
role="region"
aria-label={language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle
direction="vertical"
size={layout.terminal.height()}
min={100}
max={window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={view().terminal.close}
/>
<Show
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={handoff.terminals}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">Loading...</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
</div>
}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => {
// Only switch tabs if not in the middle of starting edit mode
terminal.open(id)
}}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
view().terminal.close()
setAutoCreated(false)
}}
/>
)}
</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title={language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={terminal.new}
aria-label={language.t("command.terminal.new")}
/>
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={terminal.all()}>
{(pty) => (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Show when={pty.id} keyed>
<Terminal
pty={pty}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(pty.id)}
/>
</Show>
</div>
)}
</For>
</div>
</div>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{(() => {
const title = t().title
const number = t().titleNumber
const match = title.match(/^Terminal (\d+)$/)
const parsed = match ? Number(match[1]) : undefined
const isDefaultTitle =
Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
if (title && !isDefaultTitle) return title
if (Number.isFinite(number) && number > 0)
return language.t("terminal.title.numbered", { number })
if (title) return title
return language.t("terminal.title")
})()}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
</div>
</Show>
</div>
)
}