diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/components/session | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-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')
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)} |
