1681 lines
61 KiB
TypeScript
1681 lines
61 KiB
TypeScript
import {
|
|
For,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
Match,
|
|
Switch,
|
|
createMemo,
|
|
createResource,
|
|
createEffect,
|
|
on,
|
|
createRenderEffect,
|
|
batch,
|
|
} from "solid-js"
|
|
|
|
import { Dynamic } from "solid-js/web"
|
|
import { useLocal } from "@/context/local"
|
|
import { selectionFromLines, useFile, 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 { DateTime } from "luxon"
|
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
import { IconButton } from "@opencode-ai/ui/icon-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 { SessionTurn } from "@opencode-ai/ui/session-turn"
|
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
|
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
|
import { Markdown } from "@opencode-ai/ui/markdown"
|
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
|
import { Code } from "@opencode-ai/ui/code"
|
|
import {
|
|
DragDropProvider,
|
|
DragDropSensors,
|
|
DragOverlay,
|
|
SortableProvider,
|
|
closestCenter,
|
|
createSortable,
|
|
} from "@thisbeyond/solid-dnd"
|
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
|
import type { JSX } from "solid-js"
|
|
import { useSync } from "@/context/sync"
|
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
|
import { useLayout } from "@/context/layout"
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
import { Terminal } from "@/components/terminal"
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
|
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
|
import { useCommand } from "@/context/command"
|
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
|
import { useSDK } from "@/context/sdk"
|
|
import { usePrompt } from "@/context/prompt"
|
|
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 { useServer } from "@/context/server"
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
|
import { useGlobalSDK } from "@/context/global-sdk"
|
|
import { Popover } from "@opencode-ai/ui/popover"
|
|
import { Select } from "@opencode-ai/ui/select"
|
|
import { TextField } from "@opencode-ai/ui/text-field"
|
|
import { base64Encode } from "@opencode-ai/util/encode"
|
|
import { iife } from "@opencode-ai/util/iife"
|
|
import { AssistantMessage, Session, type Message, type Part } from "@opencode-ai/sdk/v2/client"
|
|
|
|
function same<T>(a: readonly T[], b: readonly T[]) {
|
|
if (a === b) return true
|
|
if (a.length !== b.length) return false
|
|
return a.every((x, i) => x === b[i])
|
|
}
|
|
|
|
function Header(props: { onMobileMenuToggle?: () => void }) {
|
|
const globalSDK = useGlobalSDK()
|
|
const layout = useLayout()
|
|
const params = useParams()
|
|
const navigate = useNavigate()
|
|
const command = useCommand()
|
|
const server = useServer()
|
|
const dialog = useDialog()
|
|
const sync = useSync()
|
|
|
|
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
|
const branch = createMemo(() => sync.data.vcs?.branch)
|
|
|
|
function navigateToProject(directory: string) {
|
|
navigate(`/${base64Encode(directory)}`)
|
|
}
|
|
|
|
function navigateToSession(session: Session | undefined) {
|
|
if (!session) return
|
|
navigate(`/${params.dir}/session/${session.id}`)
|
|
}
|
|
|
|
return (
|
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
|
|
<button
|
|
type="button"
|
|
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
|
|
onClick={props.onMobileMenuToggle}
|
|
>
|
|
<Icon name="menu" size="small" />
|
|
</button>
|
|
<div class="px-4 flex items-center justify-between gap-4 w-full">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<div class="hidden xl:flex items-center gap-2">
|
|
<Select
|
|
options={layout.projects.list().map((project) => project.worktree)}
|
|
current={sync.directory}
|
|
label={(x) => {
|
|
const name = getFilename(x)
|
|
const b = x === sync.directory ? branch() : undefined
|
|
return b ? `${name}:${b}` : name
|
|
}}
|
|
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
|
class="text-14-regular text-text-base"
|
|
variant="ghost"
|
|
>
|
|
{/* @ts-ignore */}
|
|
{(i) => (
|
|
<div class="flex items-center gap-2">
|
|
<Icon name="folder" size="small" />
|
|
<div class="text-text-strong">{getFilename(i)}</div>
|
|
</div>
|
|
)}
|
|
</Select>
|
|
<div class="text-text-weaker">/</div>
|
|
</div>
|
|
<Select
|
|
options={sessions()}
|
|
current={currentSession()}
|
|
placeholder="New session"
|
|
label={(x) => x.title}
|
|
value={(x) => x.id}
|
|
onSelect={navigateToSession}
|
|
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
|
|
variant="ghost"
|
|
/>
|
|
</div>
|
|
<Show when={currentSession()}>
|
|
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
|
|
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
|
|
</TooltipKeybind>
|
|
</Show>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<div class="hidden md:flex items-center gap-1">
|
|
<Button
|
|
size="small"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
dialog.show(() => <DialogSelectServer />)
|
|
}}
|
|
>
|
|
<div
|
|
classList={{
|
|
"size-1.5 rounded-full": true,
|
|
"bg-icon-success-base": server.healthy() === true,
|
|
"bg-icon-critical-base": server.healthy() === false,
|
|
"bg-border-weak-base": server.healthy() === undefined,
|
|
}}
|
|
/>
|
|
<Icon name="server" size="small" class="text-icon-weak" />
|
|
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
|
|
</Button>
|
|
<SessionLspIndicator />
|
|
<SessionMcpIndicator />
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<Show when={currentSession()?.summary?.files}>
|
|
<TooltipKeybind
|
|
class="hidden md:block shrink-0"
|
|
title="Toggle review"
|
|
keybind={command.keybind("review.toggle")}
|
|
>
|
|
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
<Icon
|
|
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
|
size="small"
|
|
class="group-hover/review-toggle:hidden"
|
|
/>
|
|
<Icon
|
|
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
|
size="small"
|
|
class="hidden group-hover/review-toggle:inline-block"
|
|
/>
|
|
<Icon
|
|
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
|
size="small"
|
|
class="hidden group-active/review-toggle:inline-block"
|
|
/>
|
|
</div>
|
|
</Button>
|
|
</TooltipKeybind>
|
|
</Show>
|
|
<TooltipKeybind
|
|
class="hidden md:block shrink-0"
|
|
title="Toggle terminal"
|
|
keybind={command.keybind("terminal.toggle")}
|
|
>
|
|
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
<Icon
|
|
size="small"
|
|
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
|
class="group-hover/terminal-toggle:hidden"
|
|
/>
|
|
<Icon
|
|
size="small"
|
|
name="layout-bottom-partial"
|
|
class="hidden group-hover/terminal-toggle:inline-block"
|
|
/>
|
|
<Icon
|
|
size="small"
|
|
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
|
class="hidden group-active/terminal-toggle:inline-block"
|
|
/>
|
|
</div>
|
|
</Button>
|
|
</TooltipKeybind>
|
|
</div>
|
|
<Show when={shareEnabled() && currentSession()}>
|
|
<Popover
|
|
title="Share session"
|
|
trigger={
|
|
<Tooltip class="shrink-0" value="Share session">
|
|
<IconButton icon="share" variant="ghost" class="" />
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{iife(() => {
|
|
const [url] = createResource(
|
|
() => currentSession(),
|
|
async (session) => {
|
|
if (!session) return
|
|
let shareURL = session.share?.url
|
|
if (!shareURL) {
|
|
shareURL = await globalSDK.client.session
|
|
.share({ sessionID: session.id, directory: sync.directory })
|
|
.then((r) => r.data?.share?.url)
|
|
.catch((e) => {
|
|
console.error("Failed to share session", e)
|
|
return undefined
|
|
})
|
|
}
|
|
return shareURL
|
|
},
|
|
)
|
|
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
|
|
})}
|
|
</Popover>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
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 params = useParams()
|
|
const navigate = useNavigate()
|
|
const sdk = useSDK()
|
|
const prompt = usePrompt()
|
|
|
|
const permission = usePermission()
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
|
|
|
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 revertMessageID = createMemo(() => info()?.revert?.messageID)
|
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.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,
|
|
userInteracted: false,
|
|
stepsExpanded: true,
|
|
mobileStepsExpanded: {} as Record<string, boolean>,
|
|
messageId: undefined as string | undefined,
|
|
})
|
|
|
|
const activeMessage = createMemo(() => {
|
|
if (!store.messageId) return lastUserMessage()
|
|
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
|
|
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
|
|
|
|
let targetIndex: number
|
|
if (currentIndex === -1) {
|
|
targetIndex = offset > 0 ? 0 : msgs.length - 1
|
|
} else {
|
|
targetIndex = currentIndex + offset
|
|
}
|
|
|
|
if (targetIndex < 0 || targetIndex >= msgs.length) return
|
|
|
|
setActiveMessage(msgs[targetIndex])
|
|
}
|
|
|
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
|
|
|
let inputRef!: HTMLDivElement
|
|
|
|
createEffect(() => {
|
|
if (!params.id) return
|
|
sync.session.sync(params.id)
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (layout.terminal.opened()) {
|
|
if (terminal.all().length === 0) {
|
|
terminal.new()
|
|
}
|
|
}
|
|
})
|
|
|
|
createEffect(
|
|
on(
|
|
() => visibleUserMessages().at(-1)?.id,
|
|
(lastId, prevLastId) => {
|
|
if (lastId && prevLastId && lastId > prevLastId) {
|
|
setStore("messageId", undefined)
|
|
}
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
const idle = { type: "idle" as const }
|
|
|
|
createEffect(
|
|
on(
|
|
() => params.id,
|
|
(id) => {
|
|
const status = sync.data.session_status[id ?? ""] ?? idle
|
|
batch(() => {
|
|
setStore("userInteracted", false)
|
|
setStore("stepsExpanded", status.type !== "idle")
|
|
})
|
|
},
|
|
),
|
|
)
|
|
|
|
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
|
|
|
|
createEffect(
|
|
on(
|
|
() => status().type,
|
|
(type) => {
|
|
if (type !== "idle") return
|
|
batch(() => {
|
|
setStore("userInteracted", false)
|
|
setStore("stepsExpanded", false)
|
|
})
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
|
|
|
|
createRenderEffect((prev) => {
|
|
const isWorking = working()
|
|
if (!prev && isWorking) {
|
|
setStore("stepsExpanded", true)
|
|
}
|
|
if (prev && !isWorking && !store.userInteracted) {
|
|
setStore("stepsExpanded", false)
|
|
}
|
|
return isWorking
|
|
}, working())
|
|
|
|
command.register(() => [
|
|
{
|
|
id: "session.new",
|
|
title: "New session",
|
|
description: "Create a new session",
|
|
category: "Session",
|
|
keybind: "mod+shift+s",
|
|
slash: "new",
|
|
onSelect: () => navigate(`/${params.dir}/session`),
|
|
},
|
|
{
|
|
id: "file.open",
|
|
title: "Open file",
|
|
description: "Search and open a file",
|
|
category: "File",
|
|
keybind: "mod+p",
|
|
slash: "open",
|
|
onSelect: () => dialog.show(() => <DialogSelectFile />),
|
|
},
|
|
{
|
|
id: "terminal.toggle",
|
|
title: "Toggle terminal",
|
|
description: "Show or hide the terminal",
|
|
category: "View",
|
|
keybind: "ctrl+`",
|
|
slash: "terminal",
|
|
onSelect: () => layout.terminal.toggle(),
|
|
},
|
|
{
|
|
id: "review.toggle",
|
|
title: "Toggle review",
|
|
description: "Show or hide the review panel",
|
|
category: "View",
|
|
keybind: "mod+shift+r",
|
|
onSelect: () => layout.review.toggle(),
|
|
},
|
|
{
|
|
id: "terminal.new",
|
|
title: "New terminal",
|
|
description: "Create a new terminal tab",
|
|
category: "Terminal",
|
|
keybind: "ctrl+shift+`",
|
|
onSelect: () => terminal.new(),
|
|
},
|
|
{
|
|
id: "steps.toggle",
|
|
title: "Toggle steps",
|
|
description: "Show or hide the steps",
|
|
category: "View",
|
|
keybind: "mod+e",
|
|
slash: "steps",
|
|
disabled: !params.id,
|
|
onSelect: () => setStore("stepsExpanded", (x) => !x),
|
|
},
|
|
{
|
|
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+t",
|
|
onSelect: () => {
|
|
local.model.variant.cycle()
|
|
showToast({
|
|
title: "Thinking effort changed",
|
|
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
|
|
})
|
|
},
|
|
},
|
|
{
|
|
id: "permissions.autoaccept",
|
|
title: params.id && permission.isAutoAccepting(params.id) ? "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) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
|
|
description: permission.isAutoAccepting(sessionID)
|
|
? "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 = userMessages().findLast((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)
|
|
prompt.set(restored)
|
|
}
|
|
// Navigate to the message before the reverted one (which will be the new last visible message)
|
|
const priorMessage = userMessages().findLast((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 = userMessages().findLast((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 = userMessages().findLast((x) => x.id < nextMessage.id)
|
|
setActiveMessage(priorMsg)
|
|
},
|
|
},
|
|
])
|
|
|
|
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)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
|
if (isProtected || isInput) return
|
|
}
|
|
if (dialog.active) return
|
|
|
|
if (activeElement === inputRef) {
|
|
if (event.key === "Escape") inputRef?.blur()
|
|
return
|
|
}
|
|
|
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
|
inputRef?.focus()
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
document.addEventListener("keydown", handleKeyDown)
|
|
})
|
|
|
|
onCleanup(() => {
|
|
document.removeEventListener("keydown", handleKeyDown)
|
|
})
|
|
|
|
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 SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
|
|
const sortable = createSortable(props.terminal.id)
|
|
return (
|
|
// @ts-ignore
|
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
|
<div class="relative h-full">
|
|
<Tabs.Trigger
|
|
value={props.terminal.id}
|
|
closeButton={
|
|
terminal.all().length > 1 && (
|
|
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
|
|
)
|
|
}
|
|
>
|
|
{props.terminal.title}
|
|
</Tabs.Trigger>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const FileVisual = (props: { path: string; active?: boolean }): JSX.Element => {
|
|
return (
|
|
<div class="flex items-center gap-x-1.5">
|
|
<FileIcon
|
|
node={{ path: props.path, type: "file" }}
|
|
classList={{
|
|
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
|
"grayscale-0": props.active,
|
|
}}
|
|
/>
|
|
<span class="text-14-medium">{getFilename(props.path)}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SortableTab = (props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element => {
|
|
const sortable = createSortable(props.tab)
|
|
const path = createMemo(() => file.pathFromTab(props.tab))
|
|
return (
|
|
// @ts-ignore
|
|
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
|
<div class="relative h-full">
|
|
<Tabs.Trigger
|
|
value={props.tab}
|
|
closeButton={
|
|
<Tooltip value="Close tab" placement="bottom">
|
|
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
|
|
</Tooltip>
|
|
}
|
|
hideCloseButton
|
|
>
|
|
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
|
|
</Tabs.Trigger>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
|
|
const openedTabs = createMemo(() =>
|
|
tabs()
|
|
.all()
|
|
.filter((tab) => tab !== "context"),
|
|
)
|
|
|
|
const showTabs = createMemo(
|
|
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
|
|
)
|
|
|
|
const activeTab = createMemo(() => {
|
|
const active = tabs().active()
|
|
if (active) return active
|
|
if (diffs().length > 0) return "review"
|
|
return tabs().all()[0] ?? "review"
|
|
})
|
|
|
|
const mobileWorking = createMemo(() => status().type !== "idle")
|
|
const mobileAutoScroll = createAutoScroll({
|
|
working: mobileWorking,
|
|
onUserInteracted: () => setStore("userInteracted", true),
|
|
})
|
|
|
|
const MobileTurns = () => (
|
|
<div
|
|
ref={mobileAutoScroll.scrollRef}
|
|
onScroll={mobileAutoScroll.handleScroll}
|
|
onClick={mobileAutoScroll.handleInteraction}
|
|
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
|
|
>
|
|
<div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
|
|
<For each={visibleUserMessages()}>
|
|
{(message) => (
|
|
<SessionTurn
|
|
sessionID={params.id!}
|
|
messageID={message.id}
|
|
lastUserMessageID={lastUserMessage()?.id}
|
|
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
|
|
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
|
|
onUserInteracted={() => setStore("userInteracted", true)}
|
|
classes={{
|
|
root: "min-w-0 w-full relative",
|
|
content:
|
|
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
|
container: "px-4",
|
|
}}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const NewSessionView = () => (
|
|
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
|
<div class="text-20-medium text-text-weaker">New session</div>
|
|
<div class="flex justify-center items-center gap-3">
|
|
<Icon name="folder" size="small" />
|
|
<div class="text-12-medium text-text-weak">
|
|
{getDirectory(sync.data.path.directory)}
|
|
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
|
</div>
|
|
</div>
|
|
<Show when={sync.project}>
|
|
{(project) => (
|
|
<div class="flex justify-center items-center gap-3">
|
|
<Icon name="pencil-line" size="small" />
|
|
<div class="text-12-medium text-text-weak">
|
|
Last modified
|
|
<span class="text-text-strong">
|
|
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
)
|
|
|
|
const DesktopSessionContent = () => (
|
|
<Switch>
|
|
<Match when={params.id}>
|
|
<div class="flex items-start justify-start h-full min-h-0">
|
|
<SessionMessageRail
|
|
messages={visibleUserMessages()}
|
|
current={activeMessage()}
|
|
onMessageSelect={setActiveMessage}
|
|
wide={!showTabs()}
|
|
/>
|
|
<Show when={activeMessage()}>
|
|
<SessionTurn
|
|
sessionID={params.id!}
|
|
messageID={activeMessage()!.id}
|
|
lastUserMessageID={lastUserMessage()?.id}
|
|
stepsExpanded={store.stepsExpanded}
|
|
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
|
|
onUserInteracted={() => setStore("userInteracted", true)}
|
|
classes={{
|
|
root: "pb-20 flex-1 min-w-0",
|
|
content: "pb-20",
|
|
container:
|
|
"w-full " +
|
|
(!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
|
|
}}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
</Match>
|
|
<Match when={true}>
|
|
<NewSessionView />
|
|
</Match>
|
|
</Switch>
|
|
)
|
|
|
|
const ContextTab = () => {
|
|
const ctx = createMemo(() => {
|
|
const last = messages().findLast((x) => {
|
|
if (x.role !== "assistant") return false
|
|
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
|
|
return total > 0
|
|
}) as AssistantMessage
|
|
if (!last) return
|
|
|
|
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
|
|
const model = provider?.models[last.modelID]
|
|
const limit = model?.limit.context
|
|
|
|
const input = last.tokens.input
|
|
const output = last.tokens.output
|
|
const reasoning = last.tokens.reasoning
|
|
const cacheRead = last.tokens.cache.read
|
|
const cacheWrite = last.tokens.cache.write
|
|
const total = input + output + reasoning + cacheRead + cacheWrite
|
|
const usage = limit ? Math.round((total / limit) * 100) : null
|
|
|
|
return {
|
|
message: last,
|
|
provider,
|
|
model,
|
|
limit,
|
|
input,
|
|
output,
|
|
reasoning,
|
|
cacheRead,
|
|
cacheWrite,
|
|
total,
|
|
usage,
|
|
}
|
|
})
|
|
|
|
const cost = createMemo(() => {
|
|
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
}).format(total)
|
|
})
|
|
|
|
const counts = createMemo(() => {
|
|
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 {
|
|
all: all.length,
|
|
user,
|
|
assistant,
|
|
}
|
|
})
|
|
|
|
const systemPrompt = createMemo(() => {
|
|
const msg = visibleUserMessages().findLast((m) => !!m.system)
|
|
const system = msg?.system
|
|
if (!system) return
|
|
const trimmed = system.trim()
|
|
if (!trimmed) return
|
|
return trimmed
|
|
})
|
|
|
|
const number = (value: number | null | undefined) => {
|
|
if (value === undefined) return "—"
|
|
if (value === null) return "—"
|
|
return value.toLocaleString()
|
|
}
|
|
|
|
const percent = (value: number | null | undefined) => {
|
|
if (value === undefined) return "—"
|
|
if (value === null) return "—"
|
|
return value.toString() + "%"
|
|
}
|
|
|
|
const time = (value: number | undefined) => {
|
|
if (!value) return "—"
|
|
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
|
|
}
|
|
|
|
const providerLabel = createMemo(() => {
|
|
const c = ctx()
|
|
if (!c) return "—"
|
|
return c.provider?.name ?? c.message.providerID
|
|
})
|
|
|
|
const modelLabel = createMemo(() => {
|
|
const c = ctx()
|
|
if (!c) return "—"
|
|
if (c.model?.name) return c.model.name
|
|
return c.message.modelID
|
|
})
|
|
|
|
const breakdown = createMemo(
|
|
on(
|
|
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
|
() => {
|
|
const c = ctx()
|
|
if (!c) return []
|
|
const input = c.input
|
|
if (!input) return []
|
|
|
|
const out = {
|
|
system: systemPrompt()?.length ?? 0,
|
|
user: 0,
|
|
assistant: 0,
|
|
tool: 0,
|
|
}
|
|
|
|
for (const msg of messages()) {
|
|
const parts = (sync.data.part[msg.id] ?? []) as Part[]
|
|
|
|
if (msg.role === "user") {
|
|
for (const part of parts) {
|
|
if (part.type === "text") out.user += part.text.length
|
|
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
|
|
if (part.type === "agent") out.user += part.source?.value.length ?? 0
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (msg.role === "assistant") {
|
|
for (const part of parts) {
|
|
if (part.type === "text") out.assistant += part.text.length
|
|
if (part.type === "reasoning") out.assistant += part.text.length
|
|
if (part.type === "tool") {
|
|
out.tool += Object.keys(part.state.input).length * 16
|
|
if (part.state.status === "pending") out.tool += part.state.raw.length
|
|
if (part.state.status === "completed") out.tool += part.state.output.length
|
|
if (part.state.status === "error") out.tool += part.state.error.length
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
|
|
const system = estimateTokens(out.system)
|
|
const user = estimateTokens(out.user)
|
|
const assistant = estimateTokens(out.assistant)
|
|
const tool = estimateTokens(out.tool)
|
|
const estimated = system + user + assistant + tool
|
|
|
|
const pct = (tokens: number) => (tokens / input) * 100
|
|
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
|
|
|
|
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
|
|
return [
|
|
{
|
|
key: "system",
|
|
label: "System",
|
|
tokens: tokens.system,
|
|
width: pct(tokens.system),
|
|
percent: pctLabel(tokens.system),
|
|
color: "var(--syntax-info)",
|
|
},
|
|
{
|
|
key: "user",
|
|
label: "User",
|
|
tokens: tokens.user,
|
|
width: pct(tokens.user),
|
|
percent: pctLabel(tokens.user),
|
|
color: "var(--syntax-success)",
|
|
},
|
|
{
|
|
key: "assistant",
|
|
label: "Assistant",
|
|
tokens: tokens.assistant,
|
|
width: pct(tokens.assistant),
|
|
percent: pctLabel(tokens.assistant),
|
|
color: "var(--syntax-property)",
|
|
},
|
|
{
|
|
key: "tool",
|
|
label: "Tool Calls",
|
|
tokens: tokens.tool,
|
|
width: pct(tokens.tool),
|
|
percent: pctLabel(tokens.tool),
|
|
color: "var(--syntax-warning)",
|
|
},
|
|
{
|
|
key: "other",
|
|
label: "Other",
|
|
tokens: tokens.other,
|
|
width: pct(tokens.other),
|
|
percent: pctLabel(tokens.other),
|
|
color: "var(--syntax-comment)",
|
|
},
|
|
].filter((x) => x.tokens > 0)
|
|
}
|
|
|
|
if (estimated <= input) {
|
|
return build({ system, user, assistant, tool, other: input - estimated })
|
|
}
|
|
|
|
const scale = input / estimated
|
|
const scaled = {
|
|
system: Math.floor(system * scale),
|
|
user: Math.floor(user * scale),
|
|
assistant: Math.floor(assistant * scale),
|
|
tool: Math.floor(tool * scale),
|
|
}
|
|
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
|
|
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
|
|
},
|
|
),
|
|
)
|
|
|
|
function Stat(props: { label: string; value: JSX.Element }) {
|
|
return (
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-12-regular text-text-weak">{props.label}</div>
|
|
<div class="text-12-medium text-text-strong">{props.value}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const stats = createMemo(() => {
|
|
const c = ctx()
|
|
const count = counts()
|
|
return [
|
|
{ label: "Session", value: info()?.title ?? params.id ?? "—" },
|
|
{ label: "Messages", value: count.all.toLocaleString() },
|
|
{ label: "Provider", value: providerLabel() },
|
|
{ label: "Model", value: modelLabel() },
|
|
{ label: "Context Limit", value: number(c?.limit) },
|
|
{ label: "Total Tokens", value: number(c?.total) },
|
|
{ label: "Usage", value: percent(c?.usage) },
|
|
{ label: "Input Tokens", value: number(c?.input) },
|
|
{ label: "Output Tokens", value: number(c?.output) },
|
|
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
|
|
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
|
|
{ label: "User Messages", value: count.user.toLocaleString() },
|
|
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
|
|
{ label: "Total Cost", value: cost() },
|
|
{ label: "Session Created", value: time(info()?.time.created) },
|
|
{ label: "Last Activity", value: time(c?.message.time.created) },
|
|
] satisfies { label: string; value: JSX.Element }[]
|
|
})
|
|
|
|
function RawMessageContent(props: { message: Message }) {
|
|
const file = createMemo(() => {
|
|
const parts = (sync.data.part[props.message.id] ?? []) as Part[]
|
|
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
|
return {
|
|
name: `${props.message.role}-${props.message.id}.json`,
|
|
contents,
|
|
cacheKey: checksum(contents),
|
|
}
|
|
})
|
|
|
|
return <Code file={file()} overflow="wrap" class="select-text" />
|
|
}
|
|
|
|
function RawMessage(props: { message: Message }) {
|
|
return (
|
|
<Accordion.Item value={props.message.id}>
|
|
<StickyAccordionHeader>
|
|
<Accordion.Trigger>
|
|
<div class="flex items-center justify-between gap-2 w-full">
|
|
<div class="min-w-0 truncate">
|
|
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<div class="shrink-0 text-12-regular text-text-weak">{time(props.message.time.created)}</div>
|
|
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
|
|
</div>
|
|
</div>
|
|
</Accordion.Trigger>
|
|
</StickyAccordionHeader>
|
|
<Accordion.Content class="bg-background-base">
|
|
<div class="p-3">
|
|
<RawMessageContent message={props.message} />
|
|
</div>
|
|
</Accordion.Content>
|
|
</Accordion.Item>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div class="@container h-full overflow-y-auto no-scrollbar pb-10">
|
|
<div class="px-6 pt-4 flex flex-col gap-10">
|
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
|
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
|
|
</div>
|
|
|
|
<Show when={breakdown().length > 0}>
|
|
<div class="flex flex-col gap-2">
|
|
<div class="text-12-regular text-text-weak">Context Breakdown</div>
|
|
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
|
<For each={breakdown()}>
|
|
{(segment) => (
|
|
<div
|
|
class="h-full"
|
|
style={{
|
|
width: `${segment.width}%`,
|
|
"background-color": segment.color,
|
|
}}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
|
<For each={breakdown()}>
|
|
{(segment) => (
|
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
|
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
|
|
<div>{segment.label}</div>
|
|
<div class="text-text-weaker">{segment.percent}</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
<div class="hidden text-11-regular text-text-weaker">
|
|
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={systemPrompt()}>
|
|
{(prompt) => (
|
|
<div class="flex flex-col gap-2">
|
|
<div class="text-12-regular text-text-weak">System Prompt</div>
|
|
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
|
|
<Markdown text={prompt()} class="text-12-regular" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<div class="text-12-regular text-text-weak">Raw messages</div>
|
|
<Accordion multiple>
|
|
<For each={messages()}>{(message) => <RawMessage message={message} />}</For>
|
|
</Accordion>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
|
<Header />
|
|
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
|
|
<Switch>
|
|
<Match when={!params.id}>
|
|
<div class="flex-1 min-h-0 overflow-hidden">
|
|
<NewSessionView />
|
|
</div>
|
|
</Match>
|
|
<Match when={diffs().length > 0}>
|
|
<Tabs class="flex-1 min-h-0 flex flex-col pb-28">
|
|
<Tabs.List>
|
|
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
|
Session
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
|
{diffs().length} Files Changed
|
|
</Tabs.Trigger>
|
|
</Tabs.List>
|
|
<Tabs.Content value="session" class="flex-1 !overflow-hidden">
|
|
<MobileTurns />
|
|
</Tabs.Content>
|
|
<Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
|
|
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
|
|
<SessionReview
|
|
diffs={diffs()}
|
|
diffStyle={layout.review.diffStyle()}
|
|
onDiffStyleChange={layout.review.setDiffStyle}
|
|
classes={{
|
|
root: "pb-32",
|
|
header: "px-4",
|
|
container: "px-4",
|
|
}}
|
|
/>
|
|
</div>
|
|
</Tabs.Content>
|
|
</Tabs>
|
|
</Match>
|
|
<Match when={true}>
|
|
<div class="flex-1 min-h-0 overflow-hidden">
|
|
<MobileTurns />
|
|
</div>
|
|
</Match>
|
|
</Switch>
|
|
<div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
|
|
<div class="w-full">
|
|
<PromptInput
|
|
ref={(el) => {
|
|
inputRef = el
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden md:flex min-h-0 grow w-full">
|
|
<div
|
|
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
|
|
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
|
|
>
|
|
<div class="flex-1 min-h-0 overflow-hidden">
|
|
<DesktopSessionContent />
|
|
</div>
|
|
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
|
|
<div
|
|
classList={{
|
|
"w-full px-6": true,
|
|
"max-w-200": !showTabs(),
|
|
}}
|
|
>
|
|
<PromptInput
|
|
ref={(el) => {
|
|
inputRef = el
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Show when={showTabs()}>
|
|
<ResizeHandle
|
|
direction="horizontal"
|
|
size={layout.session.width()}
|
|
min={450}
|
|
max={window.innerWidth * 0.45}
|
|
onResize={layout.session.resize}
|
|
/>
|
|
</Show>
|
|
</div>
|
|
|
|
<Show when={showTabs()}>
|
|
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
|
|
<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={diffs().length}>
|
|
<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>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={contextOpen()}>
|
|
<Tabs.Trigger
|
|
value="context"
|
|
closeButton={
|
|
<Tooltip value="Close tab" placement="bottom">
|
|
<IconButton icon="close" variant="ghost" onClick={() => tabs().close("context")} />
|
|
</Tooltip>
|
|
}
|
|
hideCloseButton
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<SessionContextUsage variant="indicator" />
|
|
<div>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="Open file"
|
|
keybind={command.keybind("file.open")}
|
|
class="flex items-center"
|
|
>
|
|
<IconButton
|
|
icon="plus-small"
|
|
variant="ghost"
|
|
iconSize="large"
|
|
onClick={() => dialog.show(() => <DialogSelectFile />)}
|
|
/>
|
|
</TooltipKeybind>
|
|
</div>
|
|
</Tabs.List>
|
|
</div>
|
|
<Show when={diffs().length}>
|
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
<SessionReview
|
|
classes={{
|
|
root: "pb-40",
|
|
header: "px-6",
|
|
container: "px-6",
|
|
}}
|
|
diffs={diffs()}
|
|
diffStyle={layout.review.diffStyle()}
|
|
onDiffStyleChange={layout.review.setDiffStyle}
|
|
/>
|
|
</div>
|
|
</Tabs.Content>
|
|
</Show>
|
|
<Show when={contextOpen()}>
|
|
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
<ContextTab />
|
|
</div>
|
|
</Tabs.Content>
|
|
</Show>
|
|
<For each={openedTabs()}>
|
|
{(tab) => {
|
|
let scroll: HTMLDivElement | undefined
|
|
let scrollFrame: number | undefined
|
|
let pendingTop: number | undefined
|
|
|
|
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 selectedLines = createMemo(() => {
|
|
const p = path()
|
|
if (!p) return null
|
|
return file.selectedLines(p) ?? null
|
|
})
|
|
const selection = createMemo(() => {
|
|
const range = selectedLines()
|
|
if (!range) return
|
|
return selectionFromLines(range)
|
|
})
|
|
const selectionLabel = createMemo(() => {
|
|
const sel = selection()
|
|
if (!sel) return
|
|
if (sel.startLine === sel.endLine) return `L${sel.startLine}`
|
|
return `L${sel.startLine}-${sel.endLine}`
|
|
})
|
|
|
|
const restoreScroll = () => {
|
|
const el = scroll
|
|
const p = path()
|
|
if (!el || !p) return
|
|
|
|
const top = file.scrollTop(p)
|
|
if (top === undefined) return
|
|
if (el.scrollTop === top) return
|
|
el.scrollTop = top
|
|
}
|
|
|
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
const p = path()
|
|
if (!p) return
|
|
|
|
pendingTop = event.currentTarget.scrollTop
|
|
if (scrollFrame !== undefined) return
|
|
|
|
scrollFrame = requestAnimationFrame(() => {
|
|
scrollFrame = undefined
|
|
|
|
const top = pendingTop
|
|
pendingTop = undefined
|
|
if (top === undefined) return
|
|
|
|
file.setScrollTop(p, top)
|
|
})
|
|
}
|
|
|
|
createEffect(
|
|
on(
|
|
() => state()?.loaded,
|
|
(loaded) => {
|
|
if (!loaded) return
|
|
requestAnimationFrame(restoreScroll)
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
createEffect(
|
|
on(
|
|
() => file.ready(),
|
|
(ready) => {
|
|
if (!ready) return
|
|
requestAnimationFrame(restoreScroll)
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
onCleanup(() => {
|
|
if (scrollFrame === undefined) return
|
|
cancelAnimationFrame(scrollFrame)
|
|
})
|
|
|
|
return (
|
|
<Tabs.Content
|
|
value={tab}
|
|
class="mt-3"
|
|
ref={(el: HTMLDivElement) => {
|
|
scroll = el
|
|
restoreScroll()
|
|
}}
|
|
onScroll={handleScroll}
|
|
>
|
|
<Show when={selection()}>
|
|
{(sel) => (
|
|
<div class="sticky top-0 z-10 px-6 py-2 flex justify-end bg-background-base border-b border-border-weak-base">
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
|
|
onClick={() => {
|
|
const p = path()
|
|
if (!p) return
|
|
prompt.context.add({ type: "file", path: p, selection: sel() })
|
|
}}
|
|
>
|
|
<Icon name="plus-small" size="small" />
|
|
<span>Add {selectionLabel()} to context</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<Switch>
|
|
<Match when={state()?.loaded}>
|
|
<Dynamic
|
|
component={codeComponent}
|
|
file={{
|
|
name: path() ?? "",
|
|
contents: contents(),
|
|
cacheKey: checksum(contents()),
|
|
}}
|
|
enableLineSelection
|
|
selectedLines={selectedLines()}
|
|
onLineSelected={(range: SelectedLineRange | null) => {
|
|
const p = path()
|
|
if (!p) return
|
|
file.setSelectedLines(p, range)
|
|
}}
|
|
overflow="scroll"
|
|
class="select-text pb-40"
|
|
/>
|
|
</Match>
|
|
<Match when={state()?.loading}>
|
|
<div class="px-6 py-4 text-text-weak">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>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<Show when={layout.terminal.opened()}>
|
|
<div
|
|
class="hidden md:flex relative w-full 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={layout.terminal.close}
|
|
/>
|
|
<DragDropProvider
|
|
onDragStart={handleTerminalDragStart}
|
|
onDragEnd={handleTerminalDragEnd}
|
|
onDragOver={handleTerminalDragOver}
|
|
collisionDetector={closestCenter}
|
|
>
|
|
<DragDropSensors />
|
|
<ConstrainDragYAxis />
|
|
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
|
<Tabs.List class="h-10">
|
|
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
|
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
|
</SortableProvider>
|
|
<div class="h-full flex items-center justify-center">
|
|
<TooltipKeybind
|
|
title="New terminal"
|
|
keybind={command.keybind("terminal.new")}
|
|
class="flex items-center"
|
|
>
|
|
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
|
|
</TooltipKeybind>
|
|
</div>
|
|
</Tabs.List>
|
|
<For each={terminal.all()}>
|
|
{(pty) => (
|
|
<Tabs.Content value={pty.id}>
|
|
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
|
</Tabs.Content>
|
|
)}
|
|
</For>
|
|
</Tabs>
|
|
<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">
|
|
{t().title}
|
|
</div>
|
|
)}
|
|
</Show>
|
|
)
|
|
}}
|
|
</Show>
|
|
</DragOverlay>
|
|
</DragDropProvider>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
)
|
|
}
|