summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/session
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 09:49:14 -0600
committerGitHub <[email protected]>2026-02-12 09:49:14 -0600
commitff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch)
tree78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/components/session
parent56ad2db02055955f926fda0e4a89055b22ead6f9 (diff)
downloadopencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz
opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <[email protected]>
Diffstat (limited to 'packages/app/src/components/session')
-rw-r--r--packages/app/src/components/session/session-context-breakdown.test.ts61
-rw-r--r--packages/app/src/components/session/session-context-breakdown.ts132
-rw-r--r--packages/app/src/components/session/session-context-format.ts20
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx288
-rw-r--r--packages/app/src/components/session/session-header.tsx348
-rw-r--r--packages/app/src/components/session/session-new-view.tsx4
-rw-r--r--packages/app/src/components/session/session-sortable-tab.tsx8
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx31
8 files changed, 531 insertions, 361 deletions
diff --git a/packages/app/src/components/session/session-context-breakdown.test.ts b/packages/app/src/components/session/session-context-breakdown.test.ts
new file mode 100644
index 000000000..f38aecb55
--- /dev/null
+++ b/packages/app/src/components/session/session-context-breakdown.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { estimateSessionContextBreakdown } from "./session-context-breakdown"
+
+const user = (id: string) => {
+ return {
+ id,
+ role: "user",
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+const assistant = (id: string) => {
+ return {
+ id,
+ role: "assistant",
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+describe("estimateSessionContextBreakdown", () => {
+ test("estimates tokens and keeps remaining tokens as other", () => {
+ const messages = [user("u1"), assistant("a1")]
+ const parts = {
+ u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
+ a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
+ }
+
+ const output = estimateSessionContextBreakdown({
+ messages,
+ parts,
+ input: 20,
+ systemPrompt: "system prompt",
+ })
+
+ const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
+ expect(map.system).toBe(4)
+ expect(map.user).toBe(3)
+ expect(map.assistant).toBe(5)
+ expect(map.other).toBe(8)
+ })
+
+ test("scales segments when estimates exceed input", () => {
+ const messages = [user("u1"), assistant("a1")]
+ const parts = {
+ u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
+ a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
+ }
+
+ const output = estimateSessionContextBreakdown({
+ messages,
+ parts,
+ input: 10,
+ systemPrompt: "z".repeat(200),
+ })
+
+ const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
+ expect(total).toBeLessThanOrEqual(10)
+ expect(output.every((segment) => segment.width <= 100)).toBeTrue()
+ })
+})
diff --git a/packages/app/src/components/session/session-context-breakdown.ts b/packages/app/src/components/session/session-context-breakdown.ts
new file mode 100644
index 000000000..e263b2957
--- /dev/null
+++ b/packages/app/src/components/session/session-context-breakdown.ts
@@ -0,0 +1,132 @@
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+
+export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
+
+export type SessionContextBreakdownSegment = {
+ key: SessionContextBreakdownKey
+ tokens: number
+ width: number
+ percent: number
+}
+
+const estimateTokens = (chars: number) => Math.ceil(chars / 4)
+const toPercent = (tokens: number, input: number) => (tokens / input) * 100
+const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
+
+const charsFromUserPart = (part: Part) => {
+ if (part.type === "text") return part.text.length
+ if (part.type === "file") return part.source?.text.value.length ?? 0
+ if (part.type === "agent") return part.source?.value.length ?? 0
+ return 0
+}
+
+const charsFromAssistantPart = (part: Part) => {
+ if (part.type === "text") return { assistant: part.text.length, tool: 0 }
+ if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
+ if (part.type !== "tool") return { assistant: 0, tool: 0 }
+
+ const input = Object.keys(part.state.input).length * 16
+ if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
+ if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
+ if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
+ return { assistant: 0, tool: input }
+}
+
+const build = (
+ tokens: { system: number; user: number; assistant: number; tool: number; other: number },
+ input: number,
+) => {
+ return [
+ {
+ key: "system",
+ tokens: tokens.system,
+ },
+ {
+ key: "user",
+ tokens: tokens.user,
+ },
+ {
+ key: "assistant",
+ tokens: tokens.assistant,
+ },
+ {
+ key: "tool",
+ tokens: tokens.tool,
+ },
+ {
+ key: "other",
+ tokens: tokens.other,
+ },
+ ]
+ .filter((x) => x.tokens > 0)
+ .map((x) => ({
+ key: x.key,
+ tokens: x.tokens,
+ width: toPercent(x.tokens, input),
+ percent: toPercentLabel(x.tokens, input),
+ })) as SessionContextBreakdownSegment[]
+}
+
+export function estimateSessionContextBreakdown(args: {
+ messages: Message[]
+ parts: Record<string, Part[] | undefined>
+ input: number
+ systemPrompt?: string
+}) {
+ if (!args.input) return []
+
+ const counts = args.messages.reduce(
+ (acc, msg) => {
+ const parts = args.parts[msg.id] ?? []
+ if (msg.role === "user") {
+ const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
+ return { ...acc, user: acc.user + user }
+ }
+
+ if (msg.role !== "assistant") return acc
+ const assistant = parts.reduce(
+ (sum, part) => {
+ const next = charsFromAssistantPart(part)
+ return {
+ assistant: sum.assistant + next.assistant,
+ tool: sum.tool + next.tool,
+ }
+ },
+ { assistant: 0, tool: 0 },
+ )
+ return {
+ ...acc,
+ assistant: acc.assistant + assistant.assistant,
+ tool: acc.tool + assistant.tool,
+ }
+ },
+ {
+ system: args.systemPrompt?.length ?? 0,
+ user: 0,
+ assistant: 0,
+ tool: 0,
+ },
+ )
+
+ const tokens = {
+ system: estimateTokens(counts.system),
+ user: estimateTokens(counts.user),
+ assistant: estimateTokens(counts.assistant),
+ tool: estimateTokens(counts.tool),
+ }
+ const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
+
+ if (estimated <= args.input) {
+ return build({ ...tokens, other: args.input - estimated }, args.input)
+ }
+
+ const scale = args.input / estimated
+ const scaled = {
+ system: Math.floor(tokens.system * scale),
+ user: Math.floor(tokens.user * scale),
+ assistant: Math.floor(tokens.assistant * scale),
+ tool: Math.floor(tokens.tool * scale),
+ }
+ const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
+ return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
+}
diff --git a/packages/app/src/components/session/session-context-format.ts b/packages/app/src/components/session/session-context-format.ts
new file mode 100644
index 000000000..e7c536d58
--- /dev/null
+++ b/packages/app/src/components/session/session-context-format.ts
@@ -0,0 +1,20 @@
+import { DateTime } from "luxon"
+
+export function createSessionContextFormatter(locale: string) {
+ return {
+ number(value: number | null | undefined) {
+ if (value === undefined) return "—"
+ if (value === null) return "—"
+ return value.toLocaleString(locale)
+ },
+ percent(value: number | null | undefined) {
+ if (value === undefined) return "—"
+ if (value === null) return "—"
+ return value.toLocaleString(locale) + "%"
+ },
+ time(value: number | undefined) {
+ if (!value) return "—"
+ return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
+ },
+ }
+}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 8aae44863..eb5b4197d 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -1,7 +1,6 @@
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"
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
+import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
+import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
+const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
+ system: "var(--syntax-info)",
+ user: "var(--syntax-success)",
+ assistant: "var(--syntax-property)",
+ tool: "var(--syntax-warning)",
+ other: "var(--syntax-comment)",
+}
+
+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>
+ )
+}
+
+function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
+ const file = createMemo(() => {
+ const parts = props.getParts(props.message.id)
+ 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"
+ onRendered={() => requestAnimationFrame(props.onRendered)}
+ />
+ )
+}
+
+function RawMessage(props: {
+ message: Message
+ getParts: (id: string) => Part[]
+ onRendered: () => void
+ time: (value: number | undefined) => string
+}) {
+ 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">{props.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} getParts={props.getParts} onRendered={props.onRendered} />
+ </div>
+ </Accordion.Content>
+ </Accordion.Item>
+ )
+}
+
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
+ const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
return trimmed
})
- const number = (value: number | null | undefined) => {
- if (value === undefined) return "—"
- if (value === null) return "—"
- return value.toLocaleString(language.locale())
- }
-
- const percent = (value: number | null | undefined) => {
- if (value === undefined) return "—"
- if (value === null) return "—"
- return value.toLocaleString(language.locale()) + "%"
- }
-
- const time = (value: number | undefined) => {
- if (!value) return "—"
- return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
- }
-
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
@@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) {
() => [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: language.t("context.breakdown.system"),
- tokens: tokens.system,
- width: pct(tokens.system),
- percent: pctLabel(tokens.system),
- color: "var(--syntax-info)",
- },
- {
- key: "user",
- label: language.t("context.breakdown.user"),
- tokens: tokens.user,
- width: pct(tokens.user),
- percent: pctLabel(tokens.user),
- color: "var(--syntax-success)",
- },
- {
- key: "assistant",
- label: language.t("context.breakdown.assistant"),
- tokens: tokens.assistant,
- width: pct(tokens.assistant),
- percent: pctLabel(tokens.assistant),
- color: "var(--syntax-property)",
- },
- {
- key: "tool",
- label: language.t("context.breakdown.tool"),
- tokens: tokens.tool,
- width: pct(tokens.tool),
- percent: pctLabel(tokens.tool),
- color: "var(--syntax-warning)",
- },
- {
- key: "other",
- label: language.t("context.breakdown.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) })
+ if (!c?.input) return []
+ return estimateSessionContextBreakdown({
+ messages: props.messages(),
+ parts: sync.data.part as Record<string, Part[] | undefined>,
+ input: c.input,
+ systemPrompt: systemPrompt(),
+ })
},
),
)
- 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 breakdownLabel = (key: SessionContextBreakdownKey) => {
+ if (key === "system") return language.t("context.breakdown.system")
+ if (key === "user") return language.t("context.breakdown.user")
+ if (key === "assistant") return language.t("context.breakdown.assistant")
+ if (key === "tool") return language.t("context.breakdown.tool")
+ return language.t("context.breakdown.other")
}
const stats = createMemo(() => {
@@ -222,15 +176,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
{ label: language.t("context.stats.provider"), value: providerLabel() },
{ label: language.t("context.stats.model"), value: modelLabel() },
- { label: language.t("context.stats.limit"), value: number(c?.limit) },
- { label: language.t("context.stats.totalTokens"), value: number(c?.total) },
- { label: language.t("context.stats.usage"), value: percent(c?.usage) },
- { label: language.t("context.stats.inputTokens"), value: number(c?.input) },
- { label: language.t("context.stats.outputTokens"), value: number(c?.output) },
- { label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
+ { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) },
+ { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) },
+ { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) },
+ { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) },
+ { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) },
+ { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) },
{
label: language.t("context.stats.cacheTokens"),
- value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
+ value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`,
},
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
{
@@ -238,55 +192,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
value: count.assistant.toLocaleString(language.locale()),
},
{ label: language.t("context.stats.totalCost"), value: cost() },
- { label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
- { label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
+ { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) },
+ { label: language.t("context.stats.lastActivity"), value: formatter().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" onRendered={() => requestAnimationFrame(restoreScroll)} />
- )
- }
-
- 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 getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll
@@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
class="h-full"
style={{
width: `${segment.width}%`,
- "background-color": segment.color,
+ "background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
@@ -366,9 +280,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
<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 class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
+ <div>{breakdownLabel(segment.key)}</div>
+ <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div>
)}
</For>
@@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
- <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
+ <For each={props.messages()}>
+ {(message) => (
+ <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
+ )}
+ </For>
</Accordion>
</div>
</div>
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 54e24a6fb..c1468ce37 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
+const OPEN_APPS = [
+ "vscode",
+ "cursor",
+ "zed",
+ "textmate",
+ "antigravity",
+ "finder",
+ "terminal",
+ "iterm2",
+ "ghostty",
+ "xcode",
+ "android-studio",
+ "powershell",
+ "sublime-text",
+] as const
+
+type OpenApp = (typeof OPEN_APPS)[number]
+type OS = "macos" | "windows" | "linux" | "unknown"
+
+const MAC_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
+ { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
+ { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
+ { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
+ { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
+ { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
+ { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
+ { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+const WINDOWS_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+ { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+const LINUX_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
+type OpenIcon = OpenApp | "file-explorer"
+const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
+
+const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
+
+const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
+ if (platform.platform === "desktop" && platform.os) return platform.os
+ if (typeof navigator !== "object") return "unknown"
+ const value = navigator.platform || navigator.userAgent
+ if (/Mac/i.test(value)) return "macos"
+ if (/Win/i.test(value)) return "windows"
+ if (/Linux/i.test(value)) return "linux"
+ return "unknown"
+}
+
+const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+}
+
+function useSessionShare(args: {
+ globalSDK: ReturnType<typeof useGlobalSDK>
+ currentSession: () =>
+ | {
+ id: string
+ share?: {
+ url?: string
+ }
+ }
+ | undefined
+ projectDirectory: () => string
+ platform: ReturnType<typeof usePlatform>
+}) {
+ const [state, setState] = createStore({
+ share: false,
+ unshare: false,
+ copied: false,
+ timer: undefined as number | undefined,
+ })
+ const shareUrl = createMemo(() => args.currentSession()?.share?.url)
+
+ createEffect(() => {
+ const url = shareUrl()
+ if (url) return
+ if (state.timer) window.clearTimeout(state.timer)
+ setState({ copied: false, timer: undefined })
+ })
+
+ onCleanup(() => {
+ if (state.timer) window.clearTimeout(state.timer)
+ })
+
+ const shareSession = () => {
+ const session = args.currentSession()
+ if (!session || state.share) return
+ setState("share", true)
+ args.globalSDK.client.session
+ .share({ sessionID: session.id, directory: args.projectDirectory() })
+ .catch((error) => {
+ console.error("Failed to share session", error)
+ })
+ .finally(() => {
+ setState("share", false)
+ })
+ }
+
+ const unshareSession = () => {
+ const session = args.currentSession()
+ if (!session || state.unshare) return
+ setState("unshare", true)
+ args.globalSDK.client.session
+ .unshare({ sessionID: session.id, directory: args.projectDirectory() })
+ .catch((error) => {
+ console.error("Failed to unshare session", error)
+ })
+ .finally(() => {
+ setState("unshare", false)
+ })
+ }
+
+ const copyLink = (onError: (error: unknown) => void) => {
+ const url = shareUrl()
+ if (!url) return
+ navigator.clipboard
+ .writeText(url)
+ .then(() => {
+ if (state.timer) window.clearTimeout(state.timer)
+ setState("copied", true)
+ const timer = window.setTimeout(() => {
+ setState("copied", false)
+ setState("timer", undefined)
+ }, 3000)
+ setState("timer", timer)
+ })
+ .catch(onError)
+ }
+
+ const viewShare = () => {
+ const url = shareUrl()
+ if (!url) return
+ args.platform.openLink(url)
+ }
+
+ return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
+}
+
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
@@ -53,62 +211,7 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
-
- const OPEN_APPS = [
- "vscode",
- "cursor",
- "zed",
- "textmate",
- "antigravity",
- "finder",
- "terminal",
- "iterm2",
- "ghostty",
- "xcode",
- "android-studio",
- "powershell",
- "sublime-text",
- ] as const
- type OpenApp = (typeof OPEN_APPS)[number]
-
- const MAC_APPS = [
- { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
- { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
- { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
- { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
- { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
- { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
- { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
- { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
- { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
- { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
- { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
- ] as const
-
- const WINDOWS_APPS = [
- { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
- { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
- { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
- { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
- { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
- ] as const
-
- const LINUX_APPS = [
- { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
- { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
- { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
- { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
- ] as const
-
- const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
- if (platform.platform === "desktop" && platform.os) return platform.os
- if (typeof navigator !== "object") return "unknown"
- const value = navigator.platform || navigator.userAgent
- if (/Mac/i.test(value)) return "macos"
- if (/Win/i.test(value)) return "windows"
- if (/Linux/i.test(value)) return "linux"
- return "unknown"
- })
+ const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
@@ -154,10 +257,6 @@ export function SessionHeader() {
] as const
})
- type OpenIcon = OpenApp | "file-explorer"
- const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
- const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
-
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
@@ -186,13 +285,7 @@ export function SessionHeader() {
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
- Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- })
+ Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
}
const copyPath = () => {
@@ -208,87 +301,16 @@ export function SessionHeader() {
description: directory,
})
})
- .catch((err: unknown) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- })
+ .catch((err: unknown) => showRequestError(language, err))
}
- const [state, setState] = createStore({
- share: false,
- unshare: false,
- copied: false,
- timer: undefined as number | undefined,
- })
- const shareUrl = createMemo(() => currentSession()?.share?.url)
-
- createEffect(() => {
- const url = shareUrl()
- if (url) return
- if (state.timer) window.clearTimeout(state.timer)
- setState({ copied: false, timer: undefined })
- })
-
- onCleanup(() => {
- if (state.timer) window.clearTimeout(state.timer)
+ const share = useSessionShare({
+ globalSDK,
+ currentSession,
+ projectDirectory,
+ platform,
})
- function shareSession() {
- const session = currentSession()
- if (!session || state.share) return
- setState("share", true)
- globalSDK.client.session
- .share({ sessionID: session.id, directory: projectDirectory() })
- .catch((error) => {
- console.error("Failed to share session", error)
- })
- .finally(() => {
- setState("share", false)
- })
- }
-
- function unshareSession() {
- const session = currentSession()
- if (!session || state.unshare) return
- setState("unshare", true)
- globalSDK.client.session
- .unshare({ sessionID: session.id, directory: projectDirectory() })
- .catch((error) => {
- console.error("Failed to unshare session", error)
- })
- .finally(() => {
- setState("unshare", false)
- })
- }
-
- function copyLink() {
- const url = shareUrl()
- if (!url) return
- navigator.clipboard
- .writeText(url)
- .then(() => {
- if (state.timer) window.clearTimeout(state.timer)
- setState("copied", true)
- const timer = window.setTimeout(() => {
- setState("copied", false)
- setState("timer", undefined)
- }, 3000)
- setState("timer", timer)
- })
- .catch((error) => {
- console.error("Failed to copy share link", error)
- })
- }
-
- function viewShare() {
- const url = shareUrl()
- if (!url) return
- platform.openLink(url)
- }
-
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@@ -391,7 +413,7 @@ export function SessionHeader() {
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
- <AppIcon id={o.icon} class={size(o.icon)} />
+ <AppIcon id={o.icon} class={openIconSize(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
@@ -428,7 +450,7 @@ export function SessionHeader() {
<Popover
title={language.t("session.share.popover.title")}
description={
- shareUrl()
+ share.shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
@@ -441,24 +463,24 @@ export function SessionHeader() {
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
- classList: { "rounded-r-none": shareUrl() !== undefined },
+ classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
>
<div class="flex flex-col gap-2">
<Show
- when={shareUrl()}
+ when={share.shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
- onClick={shareSession}
- disabled={state.share}
+ onClick={share.shareSession}
+ disabled={share.state.share}
>
- {state.share
+ {share.state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@@ -467,7 +489,7 @@ export function SessionHeader() {
>
<div class="flex flex-col gap-2">
<TextField
- value={shareUrl() ?? ""}
+ value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
@@ -479,10 +501,10 @@ export function SessionHeader() {
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
- onClick={unshareSession}
- disabled={state.unshare}
+ onClick={share.unshareSession}
+ disabled={share.state.unshare}
>
- {state.unshare
+ {share.state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@@ -490,8 +512,8 @@ export function SessionHeader() {
size="large"
variant="primary"
class="w-full"
- onClick={viewShare}
- disabled={state.unshare}
+ onClick={share.viewShare}
+ disabled={share.state.unshare}
>
{language.t("session.share.action.view")}
</Button>
@@ -500,10 +522,10 @@ export function SessionHeader() {
</Show>
</div>
</Popover>
- <Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
+ <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
- state.copied
+ share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
@@ -511,13 +533,13 @@ export function SessionHeader() {
gutter={8}
>
<IconButton
- icon={state.copied ? "check" : "link"}
+ icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
- onClick={copyLink}
- disabled={state.unshare}
+ onClick={() => share.copyLink((error) => showRequestError(language, error))}
+ disabled={share.state.unshare}
aria-label={
- state.copied
+ share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index 480cd58c1..ab96652d4 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
+const ROOT_CLASS =
+ "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
interface NewSessionViewProps {
worktree: string
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
}
return (
- <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
+ <div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx
index 516f3c8ed..b94e7a8e9 100644
--- a/packages/app/src/components/session/session-sortable-tab.tsx
+++ b/packages/app/src/components/session/session-sortable-tab.tsx
@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
+ const content = createMemo(() => {
+ const value = path()
+ if (!value) return
+ return <FileVisual path={value} />
+ })
return (
- // @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
- <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
+ <Show when={content()}>{(value) => value()}</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
index aedf67876..6fe6186d5 100644
--- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx
+++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
-import { Show } from "solid-js"
+import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
+ let input: HTMLInputElement | undefined
+ let blurFrame: number | undefined
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
- setTimeout(() => {
- const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
- if (!input) return
- input.focus()
- input.select()
- setTimeout(() => setStore("blurEnabled", true), 100)
- }, 10)
}
const save = () => {
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("menuOpen", true)
}
+ createEffect(() => {
+ if (!store.editing) return
+ if (!input) return
+ input.focus()
+ input.select()
+ if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
+ blurFrame = requestAnimationFrame(() => {
+ blurFrame = undefined
+ setStore("blurEnabled", true)
+ })
+ })
+
+ onCleanup(() => {
+ if (blurFrame === undefined) return
+ cancelAnimationFrame(blurFrame)
+ })
+
return (
<div
- // @ts-ignore
use:sortable
class="outline-none focus:outline-none focus-visible:outline-none"
classList={{
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
- id={`terminal-title-input-${props.terminal.id}`}
+ ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}