summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/session
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components/session')
-rw-r--r--packages/app/src/components/session/index.ts6
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx419
-rw-r--r--packages/app/src/components/session/session-header.tsx212
-rw-r--r--packages/app/src/components/session/session-new-view.tsx35
-rw-r--r--packages/app/src/components/session/session-review-tab.tsx81
-rw-r--r--packages/app/src/components/session/session-sortable-tab.tsx48
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx27
7 files changed, 828 insertions, 0 deletions
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
new file mode 100644
index 000000000..5e5c349d2
--- /dev/null
+++ b/packages/app/src/components/session/index.ts
@@ -0,0 +1,6 @@
+export { SessionHeader } from "./session-header"
+export { SessionContextTab } from "./session-context-tab"
+export { SessionReviewTab } from "./session-review-tab"
+export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTerminalTab } from "./session-sortable-terminal-tab"
+export { NewSessionView } from "./session-new-view"
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
new file mode 100644
index 000000000..b157eb228
--- /dev/null
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -0,0 +1,419 @@
+import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { useLayout } from "@/context/layout"
+import { checksum } from "@opencode-ai/util/encode"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Accordion } from "@opencode-ai/ui/accordion"
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
+import { Code } from "@opencode-ai/ui/code"
+import { Markdown } from "@opencode-ai/ui/markdown"
+import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+
+interface SessionContextTabProps {
+ messages: () => Message[]
+ visibleUserMessages: () => UserMessage[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
+}
+
+export function SessionContextTab(props: SessionContextTabProps) {
+ const params = useParams()
+ const sync = useSync()
+
+ const ctx = createMemo(() => {
+ const last = props.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 = props.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 = props.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 = props.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, props.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 props.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(statProps: { label: string; value: JSX.Element }) {
+ return (
+ <div class="flex flex-col gap-1">
+ <div class="text-12-regular text-text-weak">{statProps.label}</div>
+ <div class="text-12-medium text-text-strong">{statProps.value}</div>
+ </div>
+ )
+ }
+
+ const stats = createMemo(() => {
+ const c = ctx()
+ const count = counts()
+ return [
+ { label: "Session", value: props.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(props.info()?.time.created) },
+ { label: "Last Activity", value: time(c?.message.time.created) },
+ ] satisfies { label: string; value: JSX.Element }[]
+ })
+
+ function RawMessageContent(msgProps: { message: Message }) {
+ const file = createMemo(() => {
+ const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
+ const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
+ return {
+ name: `${msgProps.message.role}-${msgProps.message.id}.json`,
+ contents,
+ cacheKey: checksum(contents),
+ }
+ })
+
+ return <Code file={file()} overflow="wrap" class="select-text" />
+ }
+
+ function RawMessage(msgProps: { message: Message }) {
+ return (
+ <Accordion.Item value={msgProps.message.id}>
+ <StickyAccordionHeader>
+ <Accordion.Trigger>
+ <div class="flex items-center justify-between gap-2 w-full">
+ <div class="min-w-0 truncate">
+ {msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
+ </div>
+ <div class="flex items-center gap-3">
+ <div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.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={msgProps.message} />
+ </div>
+ </Accordion.Content>
+ </Accordion.Item>
+ )
+ }
+
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
+
+ const s = props.view()?.scroll("context")
+ 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("context", next)
+ })
+ }
+
+ createEffect(
+ on(
+ () => props.messages().length,
+ () => {
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ onCleanup(() => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ })
+
+ return (
+ <div
+ class="@container h-full overflow-y-auto no-scrollbar pb-10"
+ ref={(el) => {
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+ <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={props.messages()}>{(message) => <RawMessage message={message} />}</For>
+ </Accordion>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
new file mode 100644
index 000000000..0a0b53607
--- /dev/null
+++ b/packages/app/src/components/session/session-header.tsx
@@ -0,0 +1,212 @@
+import { createMemo, createResource, Show } from "solid-js"
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useLayout } from "@/context/layout"
+import { useCommand } from "@/context/command"
+import { useServer } from "@/context/server"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSync } from "@/context/sync"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { getFilename } from "@opencode-ai/util/path"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { iife } from "@opencode-ai/util/iife"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Button } from "@opencode-ai/ui/button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Select } from "@opencode-ai/ui/select"
+import { Popover } from "@opencode-ai/ui/popover"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { SessionLspIndicator } from "@/components/session-lsp-indicator"
+import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import type { Session } from "@opencode-ai/sdk/v2/client"
+
+export function SessionHeader() {
+ 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={layout.mobileSidebar.toggle}
+ >
+ <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>
+ )
+}
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
new file mode 100644
index 000000000..90a280612
--- /dev/null
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -0,0 +1,35 @@
+import { Show } from "solid-js"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export function NewSessionView() {
+ const sync = useSync()
+
+ return (
+ <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&nbsp;
+ <span class="text-text-strong">
+ {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+ </span>
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/app/src/components/session/session-review-tab.tsx b/packages/app/src/components/session/session-review-tab.tsx
new file mode 100644
index 000000000..078527eca
--- /dev/null
+++ b/packages/app/src/components/session/session-review-tab.tsx
@@ -0,0 +1,81 @@
+import { createEffect, on, onCleanup } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import type { FileDiff } from "@opencode-ai/sdk/v2/client"
+
+interface SessionReviewTabProps {
+ diffs: () => FileDiff[]
+ view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+}
+
+export function SessionReviewTab(props: SessionReviewTabProps) {
+ const layout = useLayout()
+
+ let scroll: HTMLDivElement | undefined
+ let frame: number | undefined
+ let pending: { x: number; y: number } | 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
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ open={props.view().review.open()}
+ onOpenChange={props.view().review.setOpen}
+ classes={{
+ root: "pb-40",
+ header: "px-6",
+ container: "px-6",
+ }}
+ diffs={props.diffs()}
+ diffStyle={layout.review.diffStyle()}
+ onDiffStyleChange={layout.review.setDiffStyle}
+ />
+ )
+}
diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx
new file mode 100644
index 000000000..1e3f83546
--- /dev/null
+++ b/packages/app/src/components/session/session-sortable-tab.tsx
@@ -0,0 +1,48 @@
+import { createMemo, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { getFilename } from "@opencode-ai/util/path"
+import { useFile } from "@/context/file"
+
+export function 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>
+ )
+}
+
+export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
+ const file = useFile()
+ 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>
+ )
+}
diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx
new file mode 100644
index 000000000..d20f587f4
--- /dev/null
+++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx
@@ -0,0 +1,27 @@
+import type { JSX } from "solid-js"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+
+export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
+ const terminal = useTerminal()
+ 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>
+ )
+}