Compare commits

..

10 Commits

Author SHA1 Message Date
Luke Parker b683551b11 fix(desktop): connect wildcard service through loopback 2026-08-18 03:46:30 +00:00
opencode-agent[bot] 401a154ec3 fix(app): keep server details editable (#43170)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 13:32:43 +10:00
Luke Parker 18c25ae406 refactor(app): use one server event model (#43168) 2026-08-18 13:28:47 +10:00
opencode-agent[bot] cd156f9f1c test(tui): update web search label expectation (#43166)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-18 03:22:35 +00:00
Dax Raad b968a314d3 fix(tui): hide empty tab bar 2026-08-17 23:15:09 -04:00
Luke Parker 93cdfafa78 refactor(desktop): extract renderer platform (#43164) 2026-08-18 13:12:39 +10:00
Dax Raad b6a8d99da5 fix(cli): align automatic update methods 2026-08-17 23:09:49 -04:00
Dax Raad 8cacf150db chore: skip node cli publish build 2026-08-17 22:49:48 -04:00
Luke Parker 76e7b556b6 refactor(desktop): modularize main process (#43159) 2026-08-18 12:23:05 +10:00
Luke Parker a888ba4da7 refactor(app): use current session messages (#42766) 2026-08-18 12:17:39 +10:00
140 changed files with 3183 additions and 3666 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

+2 -1
View File
@@ -196,7 +196,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
strategy:
fail-fast: false
matrix:
@@ -594,6 +594,7 @@ jobs:
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
@@ -441,6 +441,15 @@ export function status(type: SessionStatus["type"], attempt = 1) {
})
}
export function stepStarted(message: SessionMessageAssistant) {
return makeEvent("session.step.started", {
sessionID,
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
})
}
export function userMessage(
parts?: PartSeed<"user">[],
input: { id?: string; summary?: unknown; created?: number } = {},
@@ -9,6 +9,7 @@ import {
setupTimeline,
shell,
status,
stepStarted,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
@@ -96,8 +97,10 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(stepStarted(assistant), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
+19 -10
View File
@@ -9,8 +9,7 @@ import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { extractPromptFromParts } from "@/utils/prompt"
import { normalizeSessionMessages } from "@/utils/session-message"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
@@ -61,13 +60,12 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const restored = extractPromptFromParts(
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
{
directory: location().directory,
attachmentName: language.t("common.attachment"),
},
)
const message = data.session.message.get(sessionID, item.id)
if (message?.type !== "user") return
const restored = extractPromptFromMessage(message, {
directory: location().directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(location().directory)
serverSDK.api.session
@@ -75,7 +73,18 @@ export const DialogFork: Component = () => {
.then((forked) => {
data.session.remember(forked)
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
const target = prompt.capture({ dir, id: forked.id })
target.set(restored)
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
@@ -82,7 +82,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.name")}
placeholder={language.t("dialog.server.add.namePlaceholder")}
value={props.name}
defaultValue={props.name}
disabled={props.busy}
onChange={props.onNameChange}
onKeyDown={keyDown}
@@ -92,7 +92,7 @@ function ServerForm(props: ServerFormProps) {
type="text"
label={language.t("dialog.server.add.username")}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
value={props.username}
defaultValue={props.username}
disabled={props.busy}
onChange={props.onUsernameChange}
onKeyDown={keyDown}
@@ -101,7 +101,7 @@ function ServerForm(props: ServerFormProps) {
type="password"
label={language.t("dialog.server.add.password")}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
value={props.password}
defaultValue={props.password}
disabled={props.busy}
onChange={props.onPasswordChange}
onKeyDown={keyDown}
@@ -407,6 +407,12 @@ describe("prompt submit worktree selection", () => {
text: "ls",
files: [],
agents: [],
metadata: {
displayText: "ls",
comments: [],
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
@@ -138,6 +138,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
metadata: {
displayText: request.displayText,
comments: request.comments,
agent: input.draft.agent,
model: {
...input.draft.model,
...(input.draft.variant ? { variant: input.draft.variant } : {}),
},
},
})
return true
} catch (err) {
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
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()
})
})
@@ -1,132 +0,0 @@
import type { Message, Part } from "@/types"
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)
}
@@ -1,99 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@/types"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
) => {
return {
id,
role: "assistant",
providerID,
modelID,
cost,
tokens: {
input: tokens.input,
output: tokens.output,
reasoning: tokens.reasoning,
cache: {
read: tokens.read,
write: tokens.write,
},
},
time: { created: 1 },
} as unknown as Message
}
const user = (id: string) => {
return {
id,
role: "user",
cost: 0,
time: { created: 1 },
} as unknown as Message
}
describe("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
{
id: "openai",
name: "OpenAI",
models: {
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1000 },
},
},
},
]
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
expect(ctx?.total).toBe(500)
expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
})
test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }]
const ctx = getSessionContext(messages, providers)
expect(ctx?.providerLabel).toBe("p-1")
expect(ctx?.modelLabel).toBe("m-1")
expect(ctx?.limit).toBeUndefined()
expect(ctx?.usage).toBeNull()
})
test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }]
const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContext(messages, providers)
expect(one?.message.id).toBe("a1")
expect(two?.message.id).toBe("a2")
})
test("returns undefined when inputs are undefined", () => {
const ctx = getSessionContext(undefined, undefined)
expect(ctx).toBeUndefined()
})
})
@@ -1,65 +0,0 @@
import type { AssistantMessage, Message } from "@/types"
type Provider = {
id: string
name?: string
models: Record<string, Model | undefined>
}
type Model = {
name?: string
limit: {
context: number
}
}
type Context = {
message: AssistantMessage
provider?: Provider
model?: Model
providerLabel: string
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
}
}
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)
return {
message,
provider,
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
+2 -2
View File
@@ -225,8 +225,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
},
)
const stop = sdk().event.listen((e) => {
invalidateFromWatcher(e.details, {
const stop = sdk().event.on("filesystem.changed", (event) => {
invalidateFromWatcher(event, {
normalize: path.normalize,
hasFile: (file) => Boolean(store.file[file]),
isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file),
+64 -114
View File
@@ -1,27 +1,28 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { invalidateFromWatcher } from "./watcher"
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = []
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
},
},
{
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/new.ts", "add"), {
normalize: (input) => input,
hasFile: (path) => path === "src/new.ts",
loadFile: (path) => loads.push(path),
node: () => undefined,
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"])
@@ -30,30 +31,21 @@ describe("file watcher invalidation", () => {
test("reloads files that are open in tabs", () => {
const loads: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
},
)
invalidateFromWatcher(filesystemEvent("src/open.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
isOpen: (path) => path === "src/open.ts",
loadFile: (path) => loads.push(path),
node: () => ({
path: "src/open.ts",
type: "file",
name: "open.ts",
absolute: "/repo/src/open.ts",
ignored: false,
}),
isDirLoaded: () => false,
refreshDir: () => {},
})
expect(loads).toEqual(["src/open.ts"])
})
@@ -61,47 +53,29 @@ describe("file watcher invalidation", () => {
test("refreshes only changed loaded directory nodes", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
isDirLoaded: (path) => path === "src",
refreshDir: (path) => refresh.push(path),
})
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(filesystemEvent("src/file.ts", "change"), {
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => ({
path: "src/file.ts",
type: "file",
name: "file.ts",
absolute: "/repo/src/file.ts",
ignored: false,
}),
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual(["src"])
})
@@ -109,40 +83,16 @@ describe("file watcher invalidation", () => {
test("ignores invalid or git watcher updates", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
},
invalidateFromWatcher(filesystemEvent(".git/index.lock", "change"), {
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
{
normalize: (input) => input,
hasFile: () => true,
loadFile: () => {
throw new Error("should not load")
},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
invalidateFromWatcher(
{
type: "project.updated",
properties: {},
},
{
normalize: (input) => input,
hasFile: () => false,
loadFile: () => {},
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
},
)
node: () => undefined,
isDirLoaded: () => true,
refreshDir: (path) => refresh.push(path),
})
expect(refresh).toEqual([])
})
+7 -25
View File
@@ -1,9 +1,7 @@
import type { FileNode } from "@/types"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
type WatcherEvent = {
type: string
properties: unknown
}
type WatcherEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
type WatcherOps = {
normalize: (input: string) => string
@@ -16,15 +14,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
const path = ops.normalize(rawPath)
const path = ops.normalize(event.data.file)
if (!path) return
if (path.startsWith(".git/")) return
@@ -32,20 +22,12 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
ops.loadFile(path)
}
if (kind === "change") {
const dir = (() => {
if (path === "") return ""
const node = ops.node(path)
if (node?.type !== "directory") return
return path
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
if (event.data.event === "change") {
if (ops.node(path)?.type !== "directory") return
if (!ops.isDirLoaded(path)) return
ops.refreshDir(path)
return
}
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Project } from "@/types"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
describe("applyGlobalEvent", () => {
test("upserts project.updated in sorted position", () => {
const projects = [{ id: "b", worktree: "/b" }] as Project[]
let next = projects
applyGlobalEvent({
event: { type: "project.updated", properties: { id: "a", worktree: "/a" } },
project: projects,
setGlobalProject: (value) => {
next = typeof value === "function" ? value(next) : value
},
refresh() {},
})
expect(next.map((project) => project.id)).toEqual(["a", "b"])
})
test("refreshes on global disposal", () => {
let refreshed = false
applyGlobalEvent({
event: { type: "global.disposed" },
project: [],
setGlobalProject() {},
refresh: () => (refreshed = true),
})
expect(refreshed).toBe(true)
})
})
describe("applyDirectoryEvent", () => {
test("updates vcs and routes refresh events", () => {
const [store, setStore] = createStore({ vcs: { branch: "old" } } as State)
const pushed: string[] = []
let lsp = 0
let references = 0
const apply = (type: string, properties?: unknown) =>
applyDirectoryEvent({
event: { type, properties },
store,
setStore,
directory: "/repo",
push: (directory) => pushed.push(directory),
loadLsp: () => lsp++,
loadReferences: () => references++,
})
apply("vcs.branch.updated", { branch: "main" })
apply("server.instance.disposed")
apply("lsp.updated")
apply("reference.updated")
expect(store.vcs?.branch).toBe("main")
expect(pushed).toEqual(["/repo"])
expect(lsp).toBe(1)
expect(references).toBe(1)
})
})
@@ -1,63 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, type SetStoreFunction, type Store } from "solid-js/store"
import type { Project } from "@/types"
import type { State, VcsCache } from "./types"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (input.event.type === "global.disposed") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (project) => project.id)
if (result.found) {
input.setGlobalProject(
produce((draft) => {
draft[result.index] = { ...draft[result.index], ...properties }
}),
)
return
}
input.setGlobalProject(
produce((draft) => {
draft.splice(result.index, 0, properties)
}),
)
}
export function applyDirectoryEvent(input: {
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
directory: string
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
}) {
switch (input.event.type) {
case "server.instance.disposed":
input.push(input.directory)
break
case "vcs.branch.updated": {
const properties = input.event.properties as { branch?: string }
if (input.store.vcs?.branch === properties.branch) break
const next = { ...input.store.vcs, branch: properties.branch }
input.setStore("vcs", next)
input.vcsCache?.setStore("value", next)
break
}
case "lsp.updated":
input.loadLsp()
break
case "reference.updated":
input.loadReferences?.()
break
}
}
+4 -1
View File
@@ -102,7 +102,10 @@ function createServerController(
const sdk = createServerSdkContext(conn, scope)
const data = createData({
api: () => sdk.api,
event: sdk.event,
event: {
on: sdk.event.on,
listen: (handler) => sdk.event.listen((event) => handler({ name: event.type, details: event })),
},
connection: sdk.connection,
directory: "",
})
+11 -14
View File
@@ -3,12 +3,12 @@ import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, o
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk"
import type { Data } from "@opencode-ai/client/solid"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -32,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error: EventSessionError["properties"]["error"]
error: Extract<OpenCodeEvent, { type: "session.execution.failed" }>["data"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -216,8 +216,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -246,10 +245,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const handleSessionError = (
directory: string,
event: { properties: EventSessionError["properties"] },
sessionID: string,
error: ErrorNotification["error"],
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
@@ -258,27 +257,25 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
void playSoundById(settings.sounds.errors())
}
const error = event.properties.error
append({
directory,
time,
viewed: viewedInCurrentSession(sessionID),
type: "error",
session: sessionID ?? "global",
session: sessionID,
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID ?? "global")
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
}
})
}
const unsub = input.sdk.eventByDir.listen((e) => {
const event = e.details
const unsub = input.sdk.event.listen((event) => {
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.interrupted" &&
@@ -286,14 +283,14 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
)
return
const directory = event.current?.location?.directory
const directory = event.location?.directory
if (!directory) return
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
handleSessionError(directory, event.data.sessionID, event.data.error, time)
return
}
handleSessionIdle(directory, event, time)
handleSessionIdle(directory, event.data.sessionID, time)
})
onCleanup(() => {
meta.disposed = true
+3 -11
View File
@@ -54,8 +54,6 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
const [store, setStore, _, ready] = persisted(
{
@@ -204,20 +202,14 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
return next
}
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, event.current?.location?.directory)
}
const unsubscribe = input.sdk.eventByDir.listen((event) => {
const unsubscribe = input.sdk.event.on("permission.asked", (event) => {
if (ready()) {
handlePermission(event)
void respondPending(event.data, event.location?.directory)
return
}
void ready.promise?.then(() => {
if (meta.disposed) return
handlePermission(event)
void respondPending(event.data, event.location?.directory)
})
})
onCleanup(() => {
+13 -1
View File
@@ -1,5 +1,4 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@/types"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -14,6 +13,19 @@ interface PartBase {
end: number
}
type FilePartSourceText = { value: string; start: number; end: number }
type FilePartSource =
| { text: FilePartSourceText; type: "file"; path: string }
| {
text: FilePartSourceText
type: "symbol"
path: string
range: { start: { line: number; character: number }; end: { line: number; character: number } }
name: string
kind: number
}
| { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export interface TextPart extends PartBase {
type: "text"
}
+79 -26
View File
@@ -1,33 +1,86 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent } from "./server-sdk"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./server-sdk"
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
const current = {
id: "evt_1",
created: 1,
type: "permission.asked",
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} as OpenCodeEvent
const permission = {
id: "evt_permission",
created: 1,
type: "permission.asked",
location: { directory: "/repo", workspaceID: "workspace_1" },
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
expect(adaptServerEvent(current)).toMatchObject({
id: "evt_1",
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
current,
function setup() {
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
}
describe("server event stream", () => {
test("publishes the original current event with exact data", () => {
const server = setup()
const received: OpenCodeEvent[] = []
let requestID: string | undefined
server.event.on("permission.asked", (event) => {
requestID = event.data.id
})
server.event.listen((event) => received.push(event))
server.publish(permission)
expect(requestID).toBe("perm_1")
expect(received).toEqual([permission])
expect(received[0]).toBe(permission)
server.dispose()
})
test("filters locations without changing workspace identity", () => {
const server = setup()
const repo: OpenCodeEvent[] = []
const other: OpenCodeEvent[] = []
const all: OpenCodeEvent[] = []
let workspaceID: string | undefined
const global = {
id: "evt_connected",
type: "server.connected",
data: {},
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
const repoEvents = server.event.location("/repo")
repoEvents.on("permission.asked", (event) => {
workspaceID = event.location?.workspaceID
})
repoEvents.listen((event) => repo.push(event))
server.event.location("/other").listen((event) => other.push(event))
server.event.listen((event) => all.push(event))
server.publish(permission)
server.publish(global)
expect(repo).toEqual([permission])
expect(workspaceID).toBe("workspace_1")
expect(other).toEqual([])
expect(all).toEqual([permission, global])
server.dispose()
})
test("isolates servers and clears subscriptions with their owner", () => {
const first = setup()
const second = setup()
const received = { first: 0, second: 0 }
first.event.listen(() => received.first++)
second.event.listen(() => received.second++)
first.publish(permission)
first.dispose()
first.publish(permission)
second.publish(permission)
expect(received).toEqual({ first: 1, second: 1 })
second.dispose()
})
})
+49 -45
View File
@@ -1,6 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import type { Event } from "@/types"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
@@ -10,15 +9,52 @@ import { createRefCountMap } from "@/utils/refcount"
import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
export type OpenCodeEventStream = {
on<Type extends OpenCodeEvent["type"]>(type: Type, handler: (event: OpenCodeEventMap[Type]) => void): VoidFunction
listen(handler: (event: OpenCodeEvent) => void): VoidFunction
}
type OpenCodeEventSource = OpenCodeEventStream & {
location(directory: string): OpenCodeEventStream
}
export function createOpenCodeEventSource() {
const emitter = createGlobalEmitter<OpenCodeEventMap>()
function stream(directory?: string): OpenCodeEventStream {
return {
on(type, handler) {
return emitter.on(type, (event) => {
if (directory !== undefined && event.location?.directory !== directory) return
handler(event)
})
},
listen(handler) {
return emitter.listen((event) => {
if (directory !== undefined && event.details.location?.directory !== directory) return
handler(event.details)
})
},
}
}
const event: OpenCodeEventSource = {
...stream(),
location: (directory) => stream(directory),
}
onCleanup(() => emitter.clear())
return {
event,
publish(event: OpenCodeEvent) {
emitter.emit(event.type, event)
},
}
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
type ServerSDKBase = {
server: ServerConnection.Any
@@ -30,28 +66,19 @@ type ServerSDKBase = {
attempt: Accessor<number>
error: Accessor<string | undefined>
}
eventByDir: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
}
event: {
on: CurrentEventEmitter["on"]
listen: CurrentEventEmitter["listen"]
}
event: OpenCodeEventSource
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const events = createOpenCodeEventSource()
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
emitter.emit(event.type, event)
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
events.publish(event)
},
log: {
info(message, data) {
@@ -61,25 +88,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
},
})
onCleanup(() => {
dirEmitter.clear()
emitter.clear()
})
return {
server,
scope,
url: server.http.url,
api,
connection,
eventByDir: {
on: dirEmitter.on.bind(dirEmitter),
listen: dirEmitter.listen.bind(dirEmitter),
},
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
},
event: events.event,
}
}
@@ -99,25 +114,14 @@ export const useServerSDK = () => {
return server.ctx.sdk
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type LocationContext = {
directory: string
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
event: OpenCodeEventStream
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): LocationContext {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.eventByDir.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
event: emitter,
event: serverSDK.event.location(directory),
}
}
+11 -40
View File
@@ -7,7 +7,6 @@ import { useLanguage } from "@/context/language"
import { type ServerSDK } from "./server-sdk"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@@ -219,51 +218,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
const unsub = serverSDK.eventByDir.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
connection.handleEvent({ type: eventType })
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
refresh: () => void bootstrap.refetch(),
setGlobalProject: setProjects,
})
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
return
}
const existing = children.children[key]
if (!existing) return
const directory = event.location.directory
const key = directoryKey(directory)
if (!children.children[key]) return
children.mark(key)
if (eventType === "config.updated" || eventType === "agent.updated") queue.push(key)
const [store, setStore] = existing
if (eventType === "worktree.updated") void bootstrap.refetch()
if (eventType !== "vcs.branch.updated")
applyDirectoryEvent({
event,
directory,
store,
setStore,
push: (directory) => {
if (children.active(directory)) queue.push(directory)
},
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
if (!children.active(key)) return
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
},
loadReferences: () => {
if (!children.active(key)) return
void data.location.reference.sync({ directory: key }).catch(() => undefined)
},
})
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
if (event.type === "reference.updated" && children.active(key))
void data.location.reference.sync({ directory: key }).catch(() => undefined)
})
onCleanup(unsub)
-108
View File
@@ -1,108 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
+2 -2
View File
@@ -215,8 +215,8 @@ function createWorkspaceTerminalSession(
})
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
const unsub = sdk.event.on("pty.exited", (event) => {
removeExited(event.data.id)
})
onCleanup(unsub)
+2 -4
View File
@@ -21,7 +21,7 @@ export function SessionUIProvider(
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}
const legacyData = createMemo(() => ({
const sessionUIData = createMemo(() => ({
session: data.session.list(),
session_status: Object.fromEntries(
data.session
@@ -32,13 +32,11 @@ export function SessionUIProvider(
]),
),
session_diff: {},
message: {},
part: {},
}))
return (
<DataProvider
data={legacyData()}
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
onNavigateToSession={navigateToSession}
+28 -13
View File
@@ -1,5 +1,5 @@
import type { FilePart, UserMessage } from "@/types"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { FilePart } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
@@ -92,7 +92,6 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
@@ -439,11 +438,29 @@ export default function Page() {
createEffect(
on(
() => lastUserMessage()?.id,
() => [lastUserMessage(), controller.data.info()] as const,
() => {
const msg = lastUserMessage()
if (!msg) return
syncSessionModel(local, msg)
const message = lastUserMessage()
const info = controller.data.info()
const metadata = message?.metadata
const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent
const model = metadata?.model
const selected =
model &&
typeof model === "object" &&
!Array.isArray(model) &&
typeof model.providerID === "string" &&
typeof model.modelID === "string"
? {
providerID: model.providerID,
modelID: model.modelID,
variant: typeof model.variant === "string" ? model.variant : undefined,
}
: info?.model
? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant }
: undefined
if (!info || !agent || !selected) return
syncSessionModel(local, { sessionID: info.id, agent, model: selected })
},
),
)
@@ -520,8 +537,6 @@ export default function Page() {
return open
}, desktopReviewOpen())
// TODO: Restore turn diffs when current transcript projections expose message summaries.
const turnDiffs = createMemo(() => list(undefined))
const nogit = createMemo(() => {
const current = project()
return !!current && current.vcs !== "git"
@@ -539,7 +554,6 @@ export default function Page() {
) {
list.push("branch")
}
list.push("turn")
return list
})
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
@@ -602,7 +616,7 @@ export default function Page() {
}, 100)
onCleanup(
sdk().event.listen((event) => {
if (event.details.type === "filesystem.changed") refreshVcs()
if (event.type === "filesystem.changed") refreshVcs()
}),
)
createEffect(
@@ -619,7 +633,8 @@ export default function Page() {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
// TODO: Restore turn diffs when the V2 transcript exposes snapshot diffs.
return []
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
@@ -685,7 +700,7 @@ export default function Page() {
return "main"
})
const setActiveMessage = (message: UserMessage | undefined) => {
const setActiveMessage = (message: SessionMessageUser | undefined) => {
messageMark = scrollMark
setStore("messageId", message?.id)
}
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createRoot, createSignal } from "solid-js"
import {
normalizeSessionTab,
@@ -9,26 +9,20 @@ import {
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
const user = (id: string): UserMessage => ({
const user = (id: string): SessionMessageUser => ({
id,
sessionID: "session",
role: "user",
type: "user",
text: id,
time: { created: 0 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
})
const assistant: AssistantMessage = {
const assistant: SessionMessageAssistant = {
id: "msg_2",
sessionID: "session",
role: "assistant",
type: "assistant",
time: { created: 0 },
parentID: "msg_1",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "build",
path: { cwd: "/workspace", root: "/workspace" },
model: { id: "model", providerID: "provider" },
content: [],
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
@@ -45,11 +39,12 @@ describe("session controller invariants", () => {
})
test("selects user history strictly before the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant, user("msg_b"), user("msg_c")]
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
@@ -1,9 +1,8 @@
import type { Message, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { createMemo, type Accessor } from "solid-js"
import { useFile } from "@/context/file"
import { useData } from "@/context/server"
import { same } from "@/utils/same"
import { normalizeSessionMessages } from "@/utils/session-message"
import { createSessionTabs } from "./helpers"
import {
normalizeSessionTab,
@@ -14,8 +13,8 @@ import {
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
const idle = { type: "idle" as const }
export function createSessionController(input: {
@@ -40,12 +39,10 @@ export function createSessionController(input: {
const id = sessionID()
return id && data.session.status(id) === "running" ? { type: "busy" as const } : idle
})
const transcript = createMemo(() => {
const messages = createMemo(() => {
const id = sessionID()
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
return id ? data.session.message.list(id) : emptyMessages
})
const messages = createMemo(() => transcript()?.messages ?? emptyMessages)
const parts = (messageID: string) => transcript()?.parts.get(messageID) ?? []
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const visibleUserMessages = createMemo(
@@ -84,7 +81,6 @@ export function createSessionController(input: {
},
history: {
messages,
parts,
userMessages,
visibleUserMessages,
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
@@ -1,4 +1,4 @@
import type { Message, UserMessage } from "@/types"
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
export function normalizeSessionTab(tab: string, normalizeFileTab: (tab: string) => string) {
if (!tab.startsWith("file://")) return tab
@@ -9,12 +9,11 @@ export function normalizeSessionTabs(tabs: string[], normalize: (tab: string) =>
return [...new Set(tabs.map(normalize))]
}
export function selectSessionUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
export function selectSessionUserMessages(messages: SessionMessageInfo[]) {
return messages.filter((message): message is SessionMessageUser => message.type === "user")
}
export function selectVisibleSessionUserMessages(messages: UserMessage[], revertMessageID?: string) {
export function selectVisibleSessionUserMessages(messages: SessionMessageUser[], revertMessageID?: string) {
if (!revertMessageID) return messages
const boundary = messages.findIndex((message) => message.id === revertMessageID)
return boundary < 0 ? messages : messages.slice(0, boundary)
return messages.filter((message) => message.id < revertMessageID)
}
@@ -1,16 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@/types"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({
id: "msg",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
}) as UserMessage
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
agent: input?.agent ?? "build",
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
})
describe("syncSessionModel", () => {
test("restores the last message through session state", () => {
@@ -1,9 +1,11 @@
import type { UserMessage } from "@/types"
type Local = {
session: {
reset(): void
restore(msg: UserMessage): void
restore(msg: {
sessionID: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
}): void
}
}
@@ -29,7 +31,10 @@ export const resetSessionModel = (local: Local) => {
local.session.reset()
}
export const syncSessionModel = (local: Local, msg: UserMessage) => {
export const syncSessionModel = (
local: Local,
msg: { sessionID: string; agent: string; model: { providerID: string; modelID: string; variant?: string } },
) => {
local.session.restore(msg)
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { visibleTimelineMessages } from "./controller-projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 2 },
},
{ id: "msg_3", type: "user", text: "queued", time: { created: 3 } },
{ id: "msg_4", type: "user", text: "reverted", time: { created: 4 } },
] satisfies SessionMessageInfo[]
describe("visibleTimelineMessages", () => {
test("hides queued inputs until delivery", () => {
const pending = [
{
id: "msg_3",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
delivery: "queue",
payload: { text: "queued" },
},
] satisfies SessionInboxInfo[]
expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"])
})
test("hides the staged revert boundary and later messages", () => {
expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
})
})
@@ -1,3 +1,17 @@
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
export function visibleTimelineMessages(
messages: SessionMessageInfo[],
pending: SessionInboxInfo[],
revertMessageID?: string,
) {
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
if (queued.size === 0 && !revertMessageID) return messages
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
}
export function timelineChildTitle(input: {
parentID?: string
taskDescription?: string
@@ -1,8 +1,8 @@
import type { Message, Part, UserMessage } from "@/types"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, on, type Accessor } from "solid-js"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -17,17 +17,22 @@ import { sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { timelineChildTitle, timelineRemovedSessionIDs, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
import { normalizeSessionMessages } from "@/utils/session-message"
const emptyMessages: Message[] = []
const taskDescription = (part: Part, sessionID: string): string | undefined => {
if (part.type !== "tool" || part.tool !== "task") return undefined
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return undefined
const value = part.state.input?.description
const emptyMessages: SessionMessageInfo[] = []
const taskDescription = (message: SessionMessageInfo, sessionID: string): string | undefined => {
if (message.type !== "assistant") return
const tool = message.content.findLast((item) => {
if (item.type !== "tool" || (item.name !== "task" && item.name !== "subagent")) return false
const metadata =
item.state.status === "running" || item.state.status === "completed" ? item.state.metadata : undefined
return metadata?.sessionId === sessionID || metadata?.sessionID === sessionID
})
if (tool?.type !== "tool") return
const input = typeof tool.state.input === "string" ? undefined : tool.state.input
const value = input?.description
if (typeof value === "string" && value) return value
return undefined
}
@@ -35,13 +40,10 @@ const taskDescription = (part: Part, sessionID: string): string | undefined => {
export type TimelineSessionSource = {
identity: Pick<SessionController["identity"], "params" | "sessionID" | "sessionKey">
data: Pick<SessionController["data"], "info" | "parent" | "parentID" | "status">
history: Pick<SessionController["history"], "messages" | "parts">
history: Pick<SessionController["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
userMessages: Accessor<UserMessage[]>
}) {
export function createTimelineController(input: { session: TimelineSessionSource }) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -54,36 +56,28 @@ export function createTimelineController(input: {
const platform = usePlatform()
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return []
const visible = new Set(input.userMessages().map((message) => message.id))
const boundary = input.session.history
.messages()
.find((message) => message.role === "user" && !visible.has(message.id))?.id
const projected = data.session.message.list(id)
if (!boundary) return projected
const index = projected.findIndex((message) => message.id === boundary)
return index < 0 ? projected : projected.slice(0, index)
return visibleTimelineMessages(
input.session.history.messages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const shareUrl = (): string | undefined => undefined
const shareEnabled = () => false
const parentTranscript = createMemo(() => {
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? normalizeSessionMessages(id, data.session.message.list(id)) : undefined
return id ? data.session.message.list(id) : emptyMessages
})
const parentMessages = createMemo(() => parentTranscript()?.messages ?? emptyMessages)
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const parts = input.session.history.parts
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id) return undefined
return parentMessages()
.flatMap((message) => parentTranscript()?.parts.get(message.id) ?? [])
.map((item) => taskDescription(item, id))
.map((message) => taskDescription(message, id))
.findLast((value): value is string => !!value)
})
const childTitle = createMemo(() => {
@@ -96,10 +90,7 @@ export function createTimelineController(input: {
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const projection = createTimelineProjection({
messages: input.session.history.messages,
userMessages: input.userMessages,
sessionMessages: projectedMessages,
parts,
status: input.session.data.status,
showReasoningSummaries: settings.general.showReasoningSummaries,
})
@@ -238,8 +229,6 @@ export function createTimelineController(input: {
parentTitle,
childTitle,
showHeader,
parts,
part,
projection,
showReasoningSummaries: settings.general.showReasoningSummaries,
shellToolPartsExpanded: settings.general.shellToolPartsExpanded,
@@ -0,0 +1,121 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import {
ContextToolGroup,
Message,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import {
presentAssistantMessage,
presentAssistantContent,
presentUserMessage,
presentUserParts,
} from "@/utils/session-message"
import { createMemo, Show } from "solid-js"
export function CurrentUserMessage(props: {
sessionID: string
message: SessionMessageUser
agent: string
model: { id: string; providerID: string; variant?: string }
actions?: UserActions
useV2Actions?: boolean
comments?: { path: string; comment: string; selection?: { startLine: number; endLine: number } }[]
}) {
const message = createMemo(() => presentUserMessage(props.sessionID, props.message, props.agent, props.model))
const parts = createMemo(() => presentUserParts(props.sessionID, props.message))
return (
<Message
message={message()}
parts={parts()}
actions={props.actions}
useV2Actions={props.useV2Actions}
comments={props.comments}
/>
)
}
export function CurrentAssistantContent(props: {
sessionID: string
parentID: string
message: SessionMessageAssistant
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
useV2Actions?: boolean
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
onContentRendered?: () => void
}) {
const message = createMemo(() => presentAssistantMessage(props.sessionID, props.parentID, props.message))
const part = createMemo(() =>
presentAssistantContent(props.sessionID, props.message, props.contentID, props.content),
)
return (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
useV2Actions={props.useV2Actions}
defaultOpen={props.defaultOpen}
toolOpen={props.toolOpen}
onToolOpenChange={props.onToolOpenChange}
deferToolContent
virtualizeDiff={false}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
export function CurrentContextToolGroup(props: {
sessionID: string
tools: { message: SessionMessageAssistant; content: SessionMessageAssistantTool; contentID: string }[]
open: boolean
busy: boolean
onOpenChange: (open: boolean) => void
onSizeChange?: () => void
}) {
const parts = createMemo(() =>
props.tools.flatMap(({ message, content, contentID }): ToolPart[] => {
const part = presentAssistantContent(props.sessionID, message, contentID, content)
return part?.type === "tool" ? [part] : []
}),
)
return (
<ContextToolGroup
parts={parts()}
open={props.open}
onOpenChange={props.onOpenChange}
busy={props.busy}
onSizeChange={props.onSizeChange}
/>
)
}
export function currentPartDefaultOpen(
sessionID: string,
message: SessionMessageAssistant,
content: SessionMessageAssistant["content"][number],
contentID: string,
shellExpanded: boolean,
editExpanded: boolean,
) {
return partDefaultOpen(
presentAssistantContent(sessionID, message, contentID, content),
shellExpanded,
editExpanded,
)
}
@@ -11,20 +11,10 @@ import {
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Card } from "@opencode-ai/ui/card"
import {
ContextToolGroup,
Message,
MessageDivider,
Part as MessagePart,
partDefaultOpen,
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -34,14 +24,11 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@/types"
import { getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language"
@@ -49,17 +36,22 @@ import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { MessageComment, Timeline, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/utils/workspace"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import {
CurrentAssistantContent,
CurrentContextToolGroup,
CurrentUserMessage,
currentPartDefaultOpen,
} from "./current-message"
const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const emptyAssistantMessages: SessionMessageAssistant[] = []
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
@@ -111,89 +103,6 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
const language = useLanguage()
const maxFiles = 10
const [state, setState] = createStore({
showAll: false,
expanded: [] as string[],
})
const showAll = () => state.showAll
const expanded = () => state.expanded
const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
return (
<div
data-slot="session-turn-diffs"
data-component="session-turn-diffs-group"
data-show-all={showAll() || undefined}
>
<div data-slot="session-turn-diffs-header">
<span data-slot="session-turn-diffs-label">
{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}
</span>
<DiffChanges changes={props.diffs} />
<Show when={overflow() > 0}>
<span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
</span>
</Show>
{props.action}
</div>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={visible()}>
{(diff) => {
const opened = createMemo(() => expanded().includes(diff.file))
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={opened()}>
<TimelineDiffView diff={diff} />
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
<Show when={!showAll() && overflow() > 0}>
<div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
{language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
</div>
</Show>
</div>
</div>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
@@ -353,17 +262,6 @@ function SessionSummaryPanel(props: {
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
return (
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
</div>
)
}
type MessageTimelineProps = {
session: TimelineSessionSource
actions?: UserActions
@@ -380,7 +278,7 @@ type MessageTimelineProps = {
shouldAnchorBottom: boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
userMessages: SessionMessageUser[]
diffs: Accessor<{ additions: number; deletions: number }[] | undefined>
onReview: () => void
workspaceMoveEligible: boolean
@@ -392,7 +290,7 @@ type MessageTimelineProps = {
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session, userMessages: () => props.userMessages })
const controller = createTimelineController({ session: props.session })
return (
<MessageTimelineView {...props} data={controller.data} action={controller.action} pending={controller.pending} />
)
@@ -425,8 +323,6 @@ function MessageTimelineView(
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const project = createMemo(() => {
@@ -845,7 +741,7 @@ function MessageTimelineView(
const turnDurationMs = (userMessageID: string) => {
const message = messageByID().get(userMessageID)
if (!message || message.role !== "user") return
if (message?.type !== "user") return
const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce<number | undefined>(
(max, item) => {
const completed = item.time.completed
@@ -868,23 +764,27 @@ function MessageTimelineView(
const message = messages[i]
if (!message) continue
const parts = getMsgParts(message.id)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
const contents = Timeline.contentEntries(message)
for (let j = contents.length - 1; j >= 0; j--) {
const entry = contents[j]
if (entry?.content.type !== "text" || !entry.content.text.trim()) continue
return entry.id
}
}
}
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
if (group.type !== "context") return []
return group.refs.flatMap((ref) => {
const message = messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool"
? [{ message, content, contentID: ref.partID }]
: []
})
})
const contextOpenKey = () => `context:${row().group.key}`
const open = createMemo(() => {
@@ -892,8 +792,9 @@ function MessageTimelineView(
})
return (
<ContextToolGroup
parts={parts()}
<CurrentContextToolGroup
sessionID={sessionID()!}
tools={tools()}
open={open()}
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
busy={
@@ -909,33 +810,52 @@ function MessageTimelineView(
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const contentID = createMemo(() => {
const group = row().group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
return group.ref.partID
})
const content = createMemo(() => {
const current = message()
const id = contentID()
if (current?.type !== "assistant" || !id) return
return Timeline.resolveContent(current, id)
})
const defaultOpen = createMemo(() => {
const item = part()
const group = row().group
const current = message()
if (group.type !== "part" || current?.type !== "assistant") return
const item = content()
if (!item) return
return partDefaultOpen(item, props.data.shellToolPartsExpanded(), props.data.editToolPartsExpanded())
return currentPartDefaultOpen(
sessionID()!,
current,
item,
group.ref.partID,
props.data.shellToolPartsExpanded(),
props.data.editToolPartsExpanded(),
)
})
const id = contentID()
if (!id) return
return (
<Show when={message()}>
<Show when={message()?.type === "assistant" ? (message() as SessionMessageAssistant) : undefined}>
{(message) => (
<Show when={part()}>
{(part) => (
<MessagePart
part={part()}
<Show when={content()}>
{(content) => (
<CurrentAssistantContent
sessionID={sessionID()!}
parentID={row().userMessageID}
message={message()}
content={content()}
contentID={id}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent
virtualizeDiff={false}
toolOpen={toolOpen[row().group.key] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(row().group.key, open)}
onContentRendered={onSizeChange}
/>
)}
@@ -978,20 +898,24 @@ function MessageTimelineView(
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
if (m?.type === "user") return m
})
const messageComments = createMemo(() => {
return getMsgParts(userMessageRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? [])
const current = message()
return current ? MessageComment.fromMessage(current) : []
})
const context = createMemo(() => projection.userContextByID().get(userMessageRow().userMessageID))
return (
<TimelineRowFrame row={userMessageRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-content" aria-live="off">
<Message
<CurrentUserMessage
sessionID={sessionID()!}
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
agent={context()?.agent ?? ""}
model={context()?.model ?? { id: "", providerID: "" }}
actions={props.actions}
useV2Actions
comments={messageComments()}
@@ -1003,6 +927,29 @@ function MessageTimelineView(
</TimelineRowFrame>
)
}
case "Shell": {
const shellRow = row as Accessor<TimelineRowByTag<"Shell">>
const message = createMemo(() => {
const current = sessionMessageByID().get(shellRow().messageID)
return current?.type === "shell" ? current : undefined
})
return (
<TimelineRowFrame row={shellRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionShellMessage
message={message()}
defaultOpen={props.data.shellToolPartsExpanded()}
open={toolOpen[message().id]}
onOpenChange={(open) => setToolOpen(message().id, open)}
/>
</div>
)}
</Show>
</TimelineRowFrame>
)
}
case "Notice": {
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
const content = createMemo(() => {
@@ -1031,11 +978,7 @@ function MessageTimelineView(
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
<MessageDivider label={language.t("ui.message.interrupted")} />
</div>
</div>
</TimelineRowFrame>
@@ -1071,43 +1014,17 @@ function MessageTimelineView(
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
const status = createMemo(() => {
const retry = (assistantMessagesByParent().get(retryRow().userMessageID) ?? emptyAssistantMessages).at(
-1,
)?.retry
if (!retry) return sessionStatus()
return { type: "retry" as const, attempt: retry.attempt, message: retry.error.message, next: retry.at }
})
return (
<TimelineRowFrame row={retryRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
!workspaceSession() &&
props.workspaceMoveEligible &&
project()?.vcs === "git" &&
sessionStatus().type === "idle"
return (
<TimelineRowFrame row={diffSummaryRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineDiffSummaryRow
diffs={diffSummaryRow().diffs}
action={
<Show when={canMove() && project()}>
{(project) => (
<WorkspaceMoveAction
variant="inline"
eligible={props.workspaceMoveEligible}
sessionID={sessionID()!}
project={project()}
directory={sessionDirectory()}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
<SessionRetry status={status()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
@@ -1140,10 +1057,10 @@ function MessageTimelineView(
const tool = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
if (part?.type === "tool") return part
const content = Timeline.resolveContent(messageByID().get(value.group.ref.messageID), value.group.ref.partID)
if (content?.type === "tool") return content
}
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.name ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1,23 +1,40 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@/types"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string): SessionMessageAssistant => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
})
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_z"), assistant("msg_a"), user("msg_b"), user("msg_c")]
const messages: SessionMessageInfo[] = [user("msg_a"), assistant("msg_ab"), user("msg_b"), user("msg_c")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_z", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_z"])
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("waits for an assistant-only load to hydrate its user root", () => {
expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
const currentAssistant = {
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 2 },
} satisfies SessionMessageInfo
const currentUser = { id: "msg_1", type: "user", text: "hello", time: { created: 1 } } satisfies SessionMessageInfo
expect(isTimelineReady([currentAssistant], true)).toBe(false)
expect(isTimelineReady([currentUser, currentAssistant], true)).toBe(true)
expect(isTimelineReady([], false)).toBe(true)
})
@@ -1,4 +1,4 @@
import type { Message } from "@/types"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo, createResource, type Accessor } from "solid-js"
import { useData } from "@/context/server"
import type { SessionController } from "../session-controller"
@@ -46,8 +46,11 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
}
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
export function isTimelineReady(messages: SessionMessageInfo[] | undefined, loading: boolean) {
return (
messages !== undefined &&
(messages.some((message) => message.type === "user" || message.type === "shell") || !loading)
)
}
export async function loadOlderTimeline(input: {
@@ -1,5 +1,4 @@
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, UserMessage } from "@/types"
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { createMemo, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"
@@ -7,39 +6,76 @@ import { Timeline, TimelineRow } from "./rows"
export { reuseTimelineRows } from "./row-reconciliation"
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
sessionMessages: Accessor<SessionMessageInfo[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
)
const userContextByID = createMemo(() => {
const result = new Map<string, { agent: string; model: ModelRef }>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "agent-switched") agent = message.agent
if (message.type === "model-switched") model = message.model
if (message.type === "user") {
userID = message.id
const metadata = message.metadata
const localAgent = typeof metadata?.agent === "string" ? metadata.agent : agent
const localModel = metadata?.model
const localModelID =
localModel && typeof localModel === "object" && !Array.isArray(localModel)
? typeof localModel.id === "string"
? localModel.id
: typeof localModel.modelID === "string"
? localModel.modelID
: undefined
: undefined
result.set(message.id, {
agent: localAgent,
model:
localModel &&
typeof localModel === "object" &&
!Array.isArray(localModel) &&
localModelID &&
typeof localModel.providerID === "string"
? {
id: localModelID,
providerID: localModel.providerID,
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
}
: model,
})
}
if (message.type === "shell") userID = undefined
if (message.type !== "assistant") return
agent = message.agent
model = message.model
if (userID) result.set(userID, { agent, model })
})
return result
})
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
input.messages().forEach((message) => {
if (message.role !== "assistant") return
const messages = result.get(message.parentID)
const result = new Map<string, Extract<SessionMessageInfo, { type: "assistant" }>[]>()
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
const messages = result.get(userID)
if (messages) {
messages.push(message)
return
}
result.set(message.parentID, [message])
result.set(userID, [message])
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
input.showReasoningSummaries(),
input.status().type,
input.userMessages(),
),
Timeline.constructSessionMessageRows(input.sessionMessages(), input.showReasoningSummaries(), input.status().type),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
@@ -73,11 +109,12 @@ export function createTimelineProjection(input: {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
messageByID,
messageByID: sessionMessageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
rows,
sessionMessageByID,
userContextByID,
}
}
@@ -1,16 +1,5 @@
import { describe, expect, mock, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "@/utils/session-message"
mock.module("@opencode-ai/session-ui/message-part", () => ({
renderable: () => true,
groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) =>
refs.map((ref) => ({
type: "part" as const,
key: ref.part.id,
ref: { messageID: ref.messageID, partID: ref.part.id },
})),
}))
const { Timeline, TimelineRow } = await import("./rows")
@@ -36,25 +25,15 @@ describe("current session timeline rows", () => {
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:msg_2:text:0",
"assistant-part:msg_1:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
"assistant-part:msg_3:part:msg_4:msg_4:reasoning:0",
])
})
@@ -71,22 +50,37 @@ describe("current session timeline rows", () => {
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_shell")
expect(result.rows.map(TimelineRow.key)).toEqual(["shell:msg_shell"])
})
test("keeps assistant content when no user root is available", () => {
const source = [
{
id: "msg_notice",
type: "synthetic",
text: "done",
description: "Background work completed",
time: { created: 1 },
},
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "result" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_shell",
"assistant-part:msg_shell:msg_shell:tool",
"notice:msg_notice",
"assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0",
])
})
@@ -142,97 +136,29 @@ describe("current session timeline rows", () => {
time: { created: 11 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "idle")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:msg_user:msg_assistant_1:text:0",
"assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:msg_user:msg_assistant_2:text:0",
"assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
])
})
test("keeps a projected parent missing from the source page before newer turns", () => {
const source = [
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
{
id: "msg_assistant_1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "first answer" }],
time: { created: 2, completed: 3 },
},
{ id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } },
{
id: "msg_assistant_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "second answer" }],
time: { created: 5, completed: 6 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source.slice(1),
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
normalized.messages.filter((message) => message.role === "user"),
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user_1",
"assistant-part:msg_user_1:msg_assistant_1:text:0",
"turn-gap:msg_user_2",
"user-message:msg_user_2",
"assistant-part:msg_user_2:msg_assistant_2:text:0",
])
})
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const optimistic = {
id: "msg_a",
sessionID: "ses_1",
role: "user" as const,
time: { created: 2 },
agent: "build",
model: { modelID: "model", providerID: "provider" },
}
const result = Timeline.constructSessionMessageRows(
source,
(messageID) =>
messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID),
() => [],
true,
"busy",
[...normalized.messages.filter((message) => message.role === "user"), optimistic],
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.activeMessageID).toBe(optimistic.id)
expect(result.activeMessageID).toBe("msg_a")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_z",
"turn-gap:msg_a",
@@ -241,6 +167,25 @@ describe("current session timeline rows", () => {
])
})
test("renders retry state from the current assistant message", () => {
const source = [
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } },
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Retry"])
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
@@ -262,17 +207,7 @@ describe("current session timeline rows", () => {
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
normalized.messages.filter((message) => message.role === "user"),
)
const result = Timeline.constructSessionMessageRows(source, true, "busy")
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
+149 -123
View File
@@ -1,22 +1,26 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import type { SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Part, UserMessage } from "@/types"
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { compareMessages } from "@/utils/session-message"
import { parseCommentNote, readPromptPresentation } from "@/utils/comment-note"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow } from "./timeline-row"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
export { TimelineRow } from "./timeline-row"
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
UserMessage: {
userMessageID: string
}
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
@@ -25,22 +29,31 @@ export type TimelineRowMap = {
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
}
type Assistant = SessionMessageAssistant
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: Assistant } | { type: "notice"; message: Notice }
type Content = Assistant["content"][number]
type ContentRef = { messageID: string; partID: string }
const contextTools = new Set(["read", "glob", "grep", "list"])
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined,
getMessageParts: (messageID: string) => Part[],
showReasoning: boolean,
status: SessionStatus["type"],
projectedUserMessages: UserMessage[],
) {
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice }
const turns: { user: UserMessage; entries: Entry[] }[] = []
type Turn = {
id: string
time: { created: number }
user?: SessionMessageUser
shell?: SessionMessageShell
entries: Entry[]
}
const turns: Turn[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
const leading: Notice[] = []
let current: (typeof turns)[number] | undefined
@@ -50,80 +63,71 @@ export namespace Timeline {
if (!current) leading.push(message)
return
}
const projected = getMessage(message.id)
if (message.type === "shell" && projected?.role === "user") {
const assistant = getMessage(`${message.id}:assistant`)
const turn = {
user: projected,
entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [],
}
if (message.type === "shell") {
const turn: Turn = { id: message.id, time: message.time, shell: message, entries: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
current = turn
return
}
if (projected?.role === "user") {
if (turnByUserID.has(projected.id)) return
const turn = { user: projected, entries: [] }
if (message.type === "user") {
if (turnByUserID.has(message.id)) return
const turn: Turn = { id: message.id, time: message.time, user: message, entries: [] }
turns.push(turn)
turnByUserID.set(projected.id, turn)
turnByUserID.set(message.id, turn)
current = turn
return
}
if (projected?.role !== "assistant") return
const existing = current ?? turnByUserID.get(projected.parentID)
if (existing) {
existing.entries.push({ type: "assistant", message: projected })
if (message.type !== "assistant") return
const existing = current?.user ? current : undefined
if (existing?.user) {
existing.entries.push({ type: "assistant", message })
current = existing
return
}
const user = getMessage(projected.parentID)
if (user?.role !== "user") return
const turn = { user, entries: [{ type: "assistant" as const, message: projected }] }
if (current && !current.user && !current.shell) {
current.entries.push({ type: "assistant", message })
return
}
const turn: Turn = { id: message.id, time: message.time, entries: [{ type: "assistant", message }] }
turns.push(turn)
turnByUserID.set(user.id, turn)
current = turn
})
const notices = new Set(messages.filter(isNotice).map((message) => message.id))
projectedUserMessages.forEach((user) => {
if (notices.has(user.id)) return
if (turnByUserID.has(user.id)) return
const turn = { user, entries: [] }
const index = turns.findIndex((item) => compareMessages(user, item.user) < 0)
if (index < 0) turns.push(turn)
if (index >= 0) turns.splice(index, 0, turn)
turnByUserID.set(user.id, turn)
})
const activeMessageID = turns.at(-1)?.user.id
const activeMessageID = turns.at(-1)?.id
return {
activeMessageID,
rows: [
...leading.map(
(message) =>
new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }),
(message) => new TimelineRow.Notice({ userMessageID: turns[0]?.id ?? message.id, messageID: message.id }),
),
...turns.flatMap((turn, index) =>
constructMessageRows(
...turns.flatMap((turn, index) => {
if (turn.shell)
return [
...(index > 0 ? [new TimelineRow.TurnGap({ userMessageID: turn.id })] : []),
new TimelineRow.Shell({ userMessageID: turn.id, messageID: turn.shell.id }),
...turn.entries.flatMap((entry) =>
entry.type === "notice"
? [new TimelineRow.Notice({ userMessageID: turn.id, messageID: entry.message.id })]
: [],
),
]
return constructMessageRows(
turn.user,
getMessageParts,
turn.id,
turn.entries,
index,
showReasoning,
status,
turn.user.id === activeMessageID,
),
),
turn.id === activeMessageID,
)
}),
],
}
}
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
entries: Array<
| { type: "assistant"; message: AssistantMessage }
| { type: "notice"; message: Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }> }
>,
userMessage: SessionMessageUser | undefined,
turnID: string,
entries: Entry[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
@@ -133,45 +137,38 @@ export namespace Timeline {
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const compaction =
userParts.some((p) => p.type === "compaction") &&
!entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const latestError = assistantMessages.at(-1)?.error
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
const compaction = entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction")
const error = assistantMessages.at(-1)?.error
const retry = assistantMessages.at(-1)?.retry
const interrupted = error?.type.toLowerCase().includes("abort") || error?.type.toLowerCase().includes("interrupt")
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: turnID }))
rows.push(new TimelineRow.UserMessage({ userMessageID: userMessage.id }))
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
if (userMessage) rows.push(new TimelineRow.UserMessage({ userMessageID: turnID }))
let assistantGroupIndex = 0
const appendAssistants = (messages: AssistantMessage[]) => {
const appendAssistants = (messages: Assistant[]) => {
const ids = new Set(messages.map((message) => message.id))
const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID))
const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError")
const interruptedAt = messages.findIndex(
(message) =>
message.error?.type.toLowerCase().includes("abort") ||
message.error?.type.toLowerCase().includes("interrupt"),
)
const interruptedID = messages[interruptedAt]?.id
const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID)
const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs
const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : []
const appendGroups = (items: typeof refs) =>
groupParts(items).forEach((group) => {
groupContent(items).forEach((group) => {
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
userMessageID: turnID,
group,
previousAssistantPart: assistantGroupIndex > 0,
}),
@@ -179,11 +176,10 @@ export namespace Timeline {
assistantGroupIndex += 1
})
appendGroups(before)
if (interruptedAt >= 0 && !compaction)
rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" }))
if (interruptedAt >= 0 && !compaction) rows.push(new TimelineRow.TurnDivider({ userMessageID: turnID }))
appendGroups(after)
}
let assistantSegment: AssistantMessage[] = []
let assistantSegment: Assistant[] = []
entries.forEach((entry) => {
if (entry.type === "assistant") {
assistantSegment.push(entry.message)
@@ -191,44 +187,31 @@ export namespace Timeline {
}
appendAssistants(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id }))
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
})
appendAssistants(assistantSegment)
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
if (isActive && status === "busy" && !error && !retry && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.flatMap((message) => message.content)
.map((content) => (content.type === "reasoning" && content.text ? reasoningHeading(content.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
userMessageID: turnID,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
if (isActive && retry) rows.push(new TimelineRow.Retry({ userMessageID: turnID }))
const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs)
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data && "message" in error.data ? error.data.message : undefined
if (error && !interrupted) {
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
userMessageID: turnID,
text: unwrapErrorMessage(error.message),
}),
)
}
@@ -236,6 +219,52 @@ export namespace Timeline {
return rows
}
export function resolveContent(message: SessionMessageInfo | undefined, partID: string) {
if (message?.type !== "assistant") return
return contentEntries(message).find((entry) => entry.id === partID)?.content
}
export function contentEntries(message: Assistant) {
const ordinals = { text: 0, reasoning: 0 }
return message.content.map((content) => ({
id: content.type === "tool" ? content.id : `${message.id}:${content.type}:${ordinals[content.type]++}`,
content,
}))
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
if (content.name === "todowrite") return false
if (content.name === "question") return content.state.status !== "streaming" && content.state.status !== "running"
return true
}
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: ContentRef[] = []
const flush = () => {
const first = context[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
groups.push({
type: "part",
key: `part:${item.messageID}:${item.partID}`,
ref: { messageID: item.messageID, partID: item.partID },
})
})
flush()
return groups
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
@@ -341,19 +370,16 @@ export namespace MessageComment {
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
export const fromMessage = (message: SessionMessageUser): MessageComment[] => {
const presentation = readPromptPresentation(message.metadata)
const parsed = presentation ? undefined : parseCommentNote(message.text)
const comments = presentation?.comments ?? (parsed ? [parsed] : [])
return comments.map((comment) => ({
path: comment.path,
comment: comment.comment,
selection: comment.selection
? { startLine: comment.selection.startLine, endLine: comment.selection.endLine }
: undefined,
}
}))
}
}
@@ -1,41 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { uniqueSummaryDiffs } from "./summary-diffs"
const diff = (file: string, additions: number) =>
({
file,
patch: "",
additions,
deletions: 0,
status: "modified",
}) satisfies FileDiffInfo
describe("uniqueSummaryDiffs", () => {
test("drops entries without files and preserves unique input", () => {
const alpha = diff("alpha.ts", 1)
const beta = diff("beta.ts", 1)
expect(uniqueSummaryDiffs(undefined)).toEqual([])
expect(uniqueSummaryDiffs([])).toEqual([])
const result = uniqueSummaryDiffs([alpha, beta])
expect(result).toEqual([alpha, beta])
expect(result[0]).toBe(alpha)
expect(result[1]).toBe(beta)
})
test("keeps the last diff per file in display order", () => {
const oldAlpha = diff("alpha.ts", 1)
const oldBeta = diff("beta.ts", 1)
const newAlpha = diff("alpha.ts", 2)
const charlie = diff("charlie.ts", 1)
const newBeta = diff("beta.ts", 2)
const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta])
expect(result).toEqual([newAlpha, charlie, newBeta])
expect(result[0]).toBe(newAlpha)
expect(result[1]).toBe(charlie)
expect(result[2]).toBe(newBeta)
})
})
@@ -1,20 +0,0 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SummaryDiff } from "./timeline-row"
export function uniqueSummaryDiffs(diffs: FileDiffInfo[] | undefined) {
const files = new Set<string>()
return (diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
const file = diff.file
if (files.has(file)) return result
files.add(file)
result.push(diff)
return result
}, [])
.reverse()
}
function isSummaryDiff(diff: FileDiffInfo): diff is SummaryDiff {
return typeof diff.file === "string"
}
@@ -1,9 +1,6 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = FileDiffInfo
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
@@ -11,13 +8,16 @@ export namespace TimelineRow {
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
}> {}
export class Shell extends Data.TaggedClass("Shell")<{
userMessageID: string
messageID: string
}> {}
export class Notice extends Data.TaggedClass("Notice")<{
userMessageID: string
messageID: string
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
@@ -28,10 +28,6 @@ export namespace TimelineRow {
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
@@ -43,11 +39,11 @@ export namespace TimelineRow {
export type TimelineRow =
| TurnGap
| UserMessage
| Shell
| Notice
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
@@ -57,16 +53,16 @@ export namespace TimelineRow {
return `turn-gap:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "Shell":
return `shell:${row.messageID}`
case "Notice":
return `notice:${row.messageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
return `turn-divider:${row.userMessageID}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
@@ -52,13 +52,13 @@ export function useUsageExceededDialogs() {
onCleanup(
sdk().event.on("session.status", (evt) => {
if (evt.properties.sessionID !== params.id) return
if (evt.properties.status.type !== "retry") return
const { action } = evt.properties.status
if (evt.data.sessionID !== params.id) return
if (evt.data.status.type !== "retry") return
const { action } = evt.data.status
if (!action) return
if (dialog.active) return
const keys = goUpsellKeys(evt.properties.status)
const keys = goUpsellKeys(evt.data.status)
if (!keys) return
const seen = goUpsellState[keys.lastSeenAt]
@@ -7,12 +7,14 @@ import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import type { UserMessage } from "@/types"
import { extractPromptComments, extractPromptFromMessage } from "@/utils/prompt"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
@@ -31,7 +33,7 @@ export type SessionCommandContext = {
move: () => Promise<void>
}
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
focusInput: () => void
}
@@ -51,6 +53,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const prompt = usePrompt()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const terminal = useTerminal()
const layout = useLayout()
@@ -59,6 +62,17 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const value = await load()
owner.run(() => show(value))
}
const runCommand = async <T,>(input: {
owner: ReturnType<SessionController["ownership"]["capture"]>
prompt: T
request: () => Promise<unknown>
updatePrompt: (prompt: T) => void
updateViewport: () => void
}) => {
await input.request()
input.updatePrompt(input.prompt)
input.owner.run(input.updateViewport)
}
const shown = settings.visibility.fileTree
const showAllFiles = () => {
@@ -86,6 +100,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
@@ -273,6 +288,75 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
}
const undo = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const session = serverSDK.api.session
const promptSession = prompt.capture()
const revert = actions.session.data.revertMessageID()
const messages = actions.session.history.userMessages()
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
if (boundary < 0) return
const message = messages[boundary - 1]
if (!message) return
if (data.session.status(sessionID) === "running") await session.interrupt({ sessionID }).catch(() => {})
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: message.id }),
updatePrompt: (target) => {
target.set(extractPromptFromMessage(message, { directory: sdk().directory }))
target.context.replaceComments(
extractPromptComments(message).map((comment) => ({
type: "file",
path: comment.path,
selection: comment.selection,
comment: comment.comment,
preview: comment.preview,
commentOrigin: comment.origin,
})),
)
},
updateViewport: () => setActiveMessage(messages[boundary - 2]),
})
}
const redo = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
const owner = actions.session.ownership.capture()
const session = serverSDK.api.session
const messages = actions.session.history.userMessages()
const promptSession = prompt.capture()
const revertMessageID = actions.session.data.revertMessageID()
if (!revertMessageID) return
const boundary = messages.findIndex((message) => message.id === revertMessageID)
if (boundary < 0) return
const next = messages[boundary + 1]
if (!next) {
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.clear({ sessionID }),
updatePrompt: (target) => target.reset(),
updateViewport: () => setActiveMessage(messages.at(-1)),
})
return
}
await runCommand({
owner,
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: next.id }),
updatePrompt: () => undefined,
updateViewport: () => setActiveMessage(messages[boundary]),
})
}
const compact = async () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
@@ -332,18 +416,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
// TODO: Restore undo when current transcript parts can reconstruct the prompt draft.
disabled: true,
onSelect: () => undefined,
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
// TODO: Restore redo with the current transcript projection.
disabled: true,
onSelect: () => undefined,
disabled: !actions.session.identity.params.id || !actions.session.data.revertMessageID(),
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
@@ -1,4 +1,4 @@
import type { UserMessage } from "@/types"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
@@ -7,14 +7,14 @@ export const useSessionHashScroll = (input: {
sessionKey: () => string
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
visibleUserMessages: () => SessionMessageUser[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setActiveMessage: (message: SessionMessageUser | undefined) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
@@ -85,7 +85,7 @@ export const useSessionHashScroll = (input: {
return false
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
const scrollToMessage = (message: SessionMessageUser, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
+1 -16
View File
@@ -1,9 +1,4 @@
import type {
EventSubscribeOutput,
FileDiffInfo,
ProjectListOutput,
WorktreeDirectory,
} from "@opencode-ai/client/promise"
import type { FileDiffInfo, ProjectListOutput, WorktreeDirectory } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type Project = Omit<ProjectListOutput[number], "canonical"> & {
@@ -11,16 +6,6 @@ export type Project = Omit<ProjectListOutput[number], "canonical"> & {
worktrees: WorktreeDirectory[]
}
type CurrentEvent = EventSubscribeOutput extends infer Item
? Item extends { type: infer Type extends string; data: infer Data }
? { type: Type; properties: Data }
: never
: never
export type Event = CurrentEvent
export type EventSessionError = Extract<Event, { type: "session.execution.failed" }>
type MessageError =
| { name: "ProviderAuthError"; data: { providerID: string; message: string } }
| { name: "UnknownError"; data: { message: string; ref?: string } }
+27
View File
@@ -53,6 +53,33 @@ export function readCommentMetadata(value: unknown) {
} satisfies PromptComment
}
export function readPromptPresentation(value: unknown) {
if (!value || typeof value !== "object") return
const displayText = (value as { displayText?: unknown }).displayText
const comments = (value as { comments?: unknown }).comments
if (typeof displayText !== "string" || !Array.isArray(comments)) return
return {
displayText,
comments: comments.flatMap((item): PromptComment[] => {
if (!item || typeof item !== "object") return []
const path = (item as { path?: unknown }).path
const comment = (item as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return []
const preview = (item as { preview?: unknown }).preview
const origin = (item as { origin?: unknown }).origin
return [
{
path,
comment,
selection: selection((item as { selection?: unknown }).selection),
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
},
]
}),
}
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+1 -40
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
import { diffs, message } from "./diffs"
import { diffs } from "./diffs"
const item = {
file: "src/app.ts",
@@ -34,41 +33,3 @@ describe("diffs", () => {
).toEqual([item])
})
})
describe("message", () => {
test("normalizes user summaries with object diffs", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: {
title: "Edit",
diffs: { a: item },
},
} as unknown as Message
expect(message(input)).toMatchObject({
summary: {
title: "Edit",
diffs: [item],
},
})
})
test("drops invalid user summaries", () => {
const input = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
summary: true,
} as unknown as Message
expect(message(input)).toMatchObject({ summary: undefined })
})
})
-24
View File
@@ -1,5 +1,4 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { Message } from "@/types"
type Diff = FileDiffInfo
@@ -24,26 +23,3 @@ export function diffs(value: unknown): Diff[] {
if (!object(value)) return []
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
if (value.role !== "user") return value
const raw = value.summary as unknown
if (raw === undefined) return value
if (!object(raw)) return { ...value, summary: undefined }
const title = typeof raw.title === "string" ? raw.title : undefined
const body = typeof raw.body === "string" ? raw.body : undefined
const next = diffs(raw.diffs)
if (title === raw.title && body === raw.body && next === raw.diffs) return value
return {
...value,
summary: {
...(title === undefined ? {} : { title }),
...(body === undefined ? {} : { body }),
diffs: next,
},
}
}
+85 -31
View File
@@ -1,38 +1,21 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@/types"
import { extractPromptFromParts } from "./prompt"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
describe("extractPromptFromParts", () => {
describe("extractPromptFromMessage", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const message = {
id: "msg_1",
type: "user",
text: "check these",
files: [
{ data: "AAA", mime: "image/png", source: { type: "inline" }, name: "a.png" },
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
],
time: { created: 1 },
} satisfies SessionMessageUser
const result = extractPromptFromParts(parts)
const result = extractPromptFromMessage(message)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
@@ -51,4 +34,75 @@ describe("extractPromptFromParts", () => {
},
])
})
test("restores optimistic data URLs and review comments", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: {
displayText: "visible text",
comments: [
{
path: "src/app.ts",
comment: "check this",
selection: { startLine: 2, startChar: 0, endLine: 2, endChar: 4 },
origin: "review",
},
],
},
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,AAA" },
name: "a.png",
},
],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "visible text" },
{ type: "image", filename: "a.png", mime: "image/png" },
])
expect(extractPromptComments(message)).toMatchObject([
{ path: "src/app.ts", comment: "check this", origin: "review" },
])
})
test("keeps the directory of a file mention without an at-sign", () => {
const message = {
id: "msg_1",
type: "user",
text: "inspect src/client.ts",
files: [
{
data: "",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/src/client.ts" },
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "src/client.ts", path: "src/client.ts" },
])
})
test("uses model text when presentation metadata is incomplete", () => {
const message = {
id: "msg_1",
type: "user",
text: "model text",
metadata: { displayText: "partial display text" },
time: { created: 1 },
} satisfies SessionMessageUser
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
})
+50 -74
View File
@@ -1,6 +1,7 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { createLegacyBlobReference } from "@/utils/draft-store"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { readPromptPresentation } from "./comment-note"
type Inline =
| {
@@ -39,94 +40,69 @@ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["s
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
export function extractPromptFromMessage(
message: SessionMessageUser,
opts?: { directory?: string; attachmentName?: string },
): Prompt {
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "file") {
const filePart = part as FilePart
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? attachmentName,
mime: filePart.mime,
blob: createLegacyBlobReference(filePart.url),
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
for (const file of message.files ?? []) {
const mention = file.mention
const uri = file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`
if (mention) {
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
type: "file",
start: mention.start,
end: mention.end,
value: mention.text,
path: toRelative(mention.text.startsWith("@") ? mention.text.slice(1) : mention.text),
selection: selectionFromFileUrl(uri),
})
continue
}
const dataUrl =
file.source.type === "uri" && file.source.uri.startsWith("data:")
? file.source.uri
: file.data
? `data:${file.mime};base64,${file.data}`
: undefined
if (!dataUrl) continue
images.push({
type: "image",
id: `${message.id}:file:${images.length}`,
filename: file.name ?? attachmentName,
mime: file.mime,
blob: createLegacyBlobReference(dataUrl),
})
}
for (const agent of message.agents ?? []) {
const mention = agent.mention
if (!mention) continue
inline.push({
type: "agent",
start: mention.start,
end: mention.end,
value: mention.text,
name: agent.name,
})
}
return buildPrompt(text, inline, images)
}
export function extractPromptComments(message: SessionMessageUser) {
return readPromptPresentation(message.metadata)?.comments ?? []
}
function buildPrompt(text: string, inline: Inline[], images: ImageAttachmentPart[]): Prompt {
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
+94 -180
View File
@@ -1,93 +1,30 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { normalizeSessionMessages } from "./session-message"
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
import { presentAssistantParts, presentUserParts } from "./session-message"
describe("normalizeSessionMessages", () => {
test("projects current turns into stable timeline rendering records", () => {
const source = [
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_2",
type: "model-switched",
model: { id: "claude", providerID: "anthropic", variant: "high" },
time: { created: 2 },
},
{
id: "msg_3",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "aGVsbG8=",
mime: "text/plain",
name: "note.txt",
source: { type: "inline" },
},
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 3 },
},
{
id: "msg_4",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 5, ran: 6, completed: 7 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 4, completed: 7 },
},
{
id: "msg_5",
type: "compaction",
status: "completed",
reason: "auto",
summary: "summary",
recent: "recent",
time: { created: 8 },
},
] satisfies SessionMessageInfo[]
describe("session message presentation", () => {
test("projects current user content for the DOM renderer", () => {
const message = {
id: "msg_user",
type: "user",
text: "inspect @src/client.ts",
files: [
{
data: "ZXhwb3J0IHt9",
mime: "text/plain",
name: "client.ts",
source: { type: "inline" },
mention: { text: "@src/client.ts", start: 8, end: 22 },
},
],
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
time: { created: 1 },
} satisfies SessionMessageUser
const result = normalizeSessionMessages("ses_1", source)
const parts = presentUserParts("ses_1", message)
expect(result.messages).toHaveLength(2)
expect(result.messages[0]).toMatchObject({
id: "msg_3",
role: "user",
agent: "build",
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
})
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
"msg_3:text:0",
"msg_3:file:0",
"msg_3:file:1",
"msg_3:agent:0",
"msg_5:compaction",
])
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
expect(parts.map((part) => part.id)).toEqual(["msg_user:text:0", "msg_user:file:0", "msg_user:agent:0"])
expect(parts[1]).toMatchObject({
type: "file",
source: {
type: "file",
@@ -95,109 +32,86 @@ describe("normalizeSessionMessages", () => {
text: { value: "@src/client.ts", start: 8, end: 22 },
},
})
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
type: "tool",
tool: "read",
state: { status: "completed", output: "hello" },
const plainMention = {
...message,
text: "inspect src/client.ts",
files: [
{
...message.files[0],
name: "client.ts",
mention: { text: "src/client.ts", start: 8, end: 21 },
},
],
} satisfies SessionMessageUser
expect(presentUserParts("ses_1", plainMention)[1]).toMatchObject({
type: "file",
source: { type: "file", path: "src/client.ts" },
})
})
test("does not invent a parent for an assistant-only page", () => {
const source = [
{
id: "msg_2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "orphan" }],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
})
test("projects a current shell message into a renderable standalone turn", () => {
const source = [
{
id: "msg_shell",
type: "shell",
shellID: "shell_1",
command: "printf hello",
status: "exited",
exit: 0,
output: { output: "hello", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
const result = normalizeSessionMessages("ses_1", source)
expect(result.messages).toEqual([
expect.objectContaining({ id: "msg_shell", role: "user" }),
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
])
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
expect(result.parts.get("msg_shell:assistant")).toEqual([
expect.objectContaining({
type: "tool",
tool: "bash",
state: expect.objectContaining({
status: "completed",
input: { command: "printf hello" },
output: "hello",
title: "Shell",
}),
}),
])
})
test("adapts current edit fields for the edit renderer", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [
{
file: "README.md",
patch: "@@ -1 +1 @@\n-old\n+new",
additions: 1,
deletions: 1,
status: "modified",
},
],
replacements: 1,
},
},
time: { created: 2, ran: 3, completed: 4 },
test("projects current assistant content for existing DOM tools", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "claude", providerID: "anthropic", variant: "high" },
content: [
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{ type: "text", text: "Result" },
{
type: "tool",
id: "call_1",
name: "read",
state: {
status: "completed",
input: { filePath: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
],
time: { created: 2, completed: 4 },
},
] satisfies SessionMessageInfo[]
time: { created: 3, ran: 4, completed: 5 },
},
],
cost: 0.1,
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
time: { created: 2, completed: 5 },
} satisfies SessionMessageAssistant
const result = normalizeSessionMessages("ses_1", source)
const parts = presentAssistantParts("ses_1", message)
expect(result.parts.get("msg_assistant")).toEqual([
expect(parts.map((part) => part.id)).toEqual(["msg_assistant:reasoning:0", "msg_assistant:text:0", "call_1"])
expect(parts[2]).toMatchObject({ type: "tool", tool: "read", state: { status: "completed", output: "hello" } })
})
test("adapts current edit fields only at the renderer boundary", () => {
const message = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_edit",
name: "edit",
state: {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
metadata: {
files: [{ file: "README.md", patch: "@@ -1 +1 @@\n-old\n+new", additions: 1, deletions: 1 }],
},
},
time: { created: 2, ran: 3, completed: 4 },
},
],
time: { created: 2, completed: 4 },
} satisfies SessionMessageAssistant
expect(presentAssistantParts("ses_1", message)).toEqual([
expect.objectContaining({
type: "tool",
tool: "edit",
state: expect.objectContaining({
status: "completed",
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
metadata: expect.objectContaining({
filediff: {
+53 -178
View File
@@ -1,25 +1,15 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
SessionMessageShell,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@/types"
import type { AssistantMessage, FilePart, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
import { Option, Schema } from "effect"
import { createCommentMetadata, formatCommentNote, readPromptPresentation } from "./comment-note"
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
const decodeToolInput = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
export function compareMessages(a: Pick<Message, "id" | "time">, b: Pick<Message, "id" | "time">) {
const left = messageKey(a)
const right = messageKey(b)
return left < right ? -1 : left > right ? 1 : 0
}
export const messageKey = (message: Pick<Message, "id" | "time">) => message.time.created + message.id
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -45,148 +35,11 @@ function normalizeToolMetadata(name: string, metadata: Record<string, unknown>)
}
}
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
const messages: Message[] = []
const parts = new Map<string, Part[]>()
let agent = ""
let model = emptyModel
let parentID: string | undefined
source.forEach((message) => {
if (message.type === "agent-switched") {
agent = message.agent
return
}
if (message.type === "model-switched") {
model = message.model
return
}
if (message.type === "user") {
parentID = message.id
messages.push(userMessage(sessionID, message, agent, model))
parts.set(message.id, userParts(sessionID, message))
return
}
if (message.type === "synthetic" && message.description?.trim()) {
parentID = message.id
messages.push({
id: message.id,
sessionID,
role: "user",
time: message.time,
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
})
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
return
}
if (message.type === "shell") {
messages.push(...shellMessages(sessionID, message, agent, model))
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
parentID = undefined
return
}
if (message.type === "assistant") {
agent = message.agent
model = message.model
if (!parentID) return
const parent = messages.findLast((item) => item.id === parentID)
if (parent?.role === "user") {
parent.agent = message.agent
parent.model = {
providerID: message.model.providerID,
modelID: message.model.id,
variant: message.model.variant,
}
}
messages.push(assistantMessage(sessionID, parentID, message))
parts.set(message.id, assistantParts(sessionID, message))
return
}
if (message.type !== "compaction" || !parentID) return
parts.set(parentID, [
...(parts.get(parentID) ?? []),
{
id: `${message.id}:compaction`,
sessionID,
messageID: parentID,
type: "compaction",
auto: message.reason === "auto",
},
])
})
return { messages, parts }
}
function shellMessages(
sessionID: string,
message: SessionMessageShell,
agent: string,
model: { id: string; providerID: string; variant?: string },
): [UserMessage, AssistantMessage] {
return [
{
id: message.id,
sessionID,
role: "user",
time: { created: message.time.created },
agent,
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
},
{
id: `${message.id}:assistant`,
sessionID,
role: "assistant",
time: message.time,
parentID: message.id,
modelID: model.id,
providerID: model.providerID,
variant: model.variant,
mode: agent,
agent,
path: { cwd: "", root: "" },
cost: 0,
tokens: emptyTokens,
},
]
}
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
const input = { command: message.command }
const start = message.time.created
const state: ToolPart["state"] =
message.status === "running"
? { status: "running", input, time: { start } }
: {
status: "completed",
input,
output: message.output?.output ?? "",
title: "Shell",
metadata: {
status: message.status,
exit: message.exit,
truncated: message.output?.truncated,
},
time: { start, end: message.time.completed ?? start },
}
return {
id: `${message.id}:tool`,
sessionID,
messageID: `${message.id}:assistant`,
type: "tool",
callID: message.shellID,
tool: "bash",
state,
}
}
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
return `${messageID}:${type}:${ordinal}`
}
function userMessage(
export function presentUserMessage(
sessionID: string,
message: SessionMessageUser,
agent: string,
@@ -202,9 +55,11 @@ function userMessage(
}
}
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
const presentation = readPromptPresentation(message.metadata)
const text = presentation?.displayText ?? message.text
return [
...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
...(message.files ?? []).map(
(file, index): FilePart => ({
id: `${message.id}:file:${index}`,
@@ -218,7 +73,7 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
? {
type: "file",
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : file.mention.text,
}
: undefined,
}),
@@ -235,10 +90,25 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
: undefined,
}),
),
...(presentation?.comments ?? []).map(
(comment, index): Part => ({
id: `${message.id}:comment:${index}`,
sessionID,
messageID: message.id,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}),
),
]
}
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
export function presentAssistantMessage(
sessionID: string,
parentID: string,
message: SessionMessageAssistant,
): AssistantMessage {
const error = message.error
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
@@ -263,32 +133,40 @@ function assistantMessage(sessionID: string, parentID: string, message: SessionM
}
}
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
export function presentAssistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((content): Part[] => {
if (content.type === "text") {
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
return content.text.trim() ? [part] : []
}
if (content.type === "reasoning") {
const part: Part = {
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return content.text.trim() ? [part] : []
}
return [toolPart(sessionID, message.id, content)]
const id =
content.type === "tool" ? content.id : sessionMessagePartID(message.id, content.type, ordinals[content.type]++)
const part = presentAssistantContent(sessionID, message, id, content)
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return []
return [part]
})
}
export function presentAssistantContent(
sessionID: string,
message: SessionMessageAssistant,
id: string,
content: SessionMessageAssistant["content"][number],
): Part {
if (content.type === "text") return { id, sessionID, messageID: message.id, type: "text", text: content.text }
if (content.type === "reasoning")
return {
id,
sessionID,
messageID: message.id,
type: "reasoning",
text: content.text,
metadata: content.state,
time: {
start: content.time?.created ?? message.time.created,
end: content.time?.completed,
},
}
return toolPart(sessionID, message.id, content)
}
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
return {
id: sessionMessagePartID(messageID, "text", ordinal),
@@ -312,7 +190,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
@@ -322,7 +199,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
@@ -347,7 +223,6 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
+33 -13
View File
@@ -9,7 +9,7 @@ import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
type Method = "npm" | "pnpm" | "bun" | "yarn"
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
@@ -68,6 +68,14 @@ export const layer = Layer.effect(
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
@@ -104,21 +112,33 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* method === "bun"
? Effect.scoped(
Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--cache-dir", cache, target], "5 minutes")
}),
)
: run(commands[method], "5 minutes")
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
)
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
import { nativeT } from "./native-translations"
import { nativeT } from "../native/translations"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+99
View File
@@ -0,0 +1,99 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { clipboard, dialog, shell } from "electron"
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
import { writeLog } from "../native/logging"
import { nativeT } from "../native/translations"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
export function createFileCapabilities() {
const pickedFiles = createPickedFileAuthorizations()
return {
async openDirectoryPicker(options?: DirectoryPickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: options?.defaultPath,
})
if (result.canceled) return null
return options?.multiple ? result.filePaths : result.filePaths[0]
},
async openFilePicker(sender: number, options?: FilePickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: options?.defaultPath,
filters: pickerFilters(options?.extensions),
})
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
)
assertAttachmentBudget(files)
return { token: pickedFiles.add(sender, result.filePaths), files }
},
readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path),
releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token),
async saveFilePicker(options?: SaveFilePickerOptions) {
const result = await dialog.showSaveDialog({
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: options?.defaultPath,
})
if (result.canceled) return null
return result.filePath ?? null
},
async openPath(path: string, application?: string) {
if (!application) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const command =
process.platform === "darwin"
? { file: "open", arguments: ["-a", application, path] }
: { file: application, arguments: [path] }
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
})
},
async revealPath(path: string) {
const exists = await stat(path).then(
() => true,
() => false,
)
if (!exists) return false
shell.showItemInFolder(path)
return true
},
readClipboardImage() {
const image = clipboard.readImage()
if (image.isEmpty()) return null
const size = image.getSize()
return { buffer: new Uint8Array(image.toPNG()).buffer, width: size.width, height: size.height }
},
}
}
export function openExternalURL(value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
return
}
void shell.openExternal(url)
}
export function openLocalFileURL(value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
function pickerFilters(extensions?: string[]) {
if (!extensions?.length) return undefined
return [{ name: nativeT("desktop.dialog.files"), extensions }]
}
+48 -312
View File
@@ -1,358 +1,94 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import * as http from "node:http"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow } from "electron"
import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL, VERSION } from "./constants"
import { checkAppExists, resolveAppPath } from "./files/apps"
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
import {
registerIpcHandlers,
registerUpdaterIpcHandlers,
registerWslIpcHandlers,
sendDeepLinks,
sendMenuCommand,
} from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { createMenu } from "./menu"
import {
finishFirstLaunchOnboarding,
initializeFirstLaunchOnboarding,
isFirstLaunchOnboardingPending,
} from "./onboarding"
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
import { safeWebContentsURL } from "./window-state"
import {
getLastFocusedWindow,
registerRendererProtocol,
setRelaunchHandler,
setAppQuitting,
setBackgroundColor,
setDockIcon,
restoreMainWindows,
} from "./windows"
import { createWslIpc } from "./wsl/ipc"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
import { setNativeTranslations } from "./native-translations"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const APP_IDS: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
const pendingDeepLinks: string[] = []
function useEnvProxy() {
try {
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
;(http as any).setGlobalProxyFromEnv()
} catch (error) {
logger.warn("failed to load proxy environment", error)
}
}
function emitDeepLinks(urls: string[]) {
if (urls.length === 0) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
if (win) sendDeepLinks(win, urls)
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value: string) => value.trim())
.filter((value: string) => Boolean(value))
for (const host of loopback) {
if (items.some((value: string) => value.toLowerCase() === host)) continue
items.push(host)
}
process.env[key] = items.join(",")
}
upsert("NO_PROXY")
upsert("no_proxy")
}
acquireApplicationLock,
configureApplication,
loadProxyEnvironment,
preferApplicationEnvironment,
prepareDesktop,
} from "./lifecycle/environment"
import { createApplicationLifecycle } from "./lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding"
import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging"
import { createMenu, sendMenuCommand } from "./native/menu"
import { setNativeTranslations } from "./native/translations"
import { startBackgroundCli } from "./service/background-service"
import { forwardInitializationFailure } from "./service/initialization"
import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings"
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
import { startWsl } from "./wsl/start"
const main = Effect.gen(function* () {
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
// on macOS apps run in `/` which can cause issues with ripgrep
try {
process.chdir(homedir())
} catch {}
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
const onboardingTestRoot = ((): string | undefined => {
if (!TEST_ONBOARDING) return
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
)
process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.XDG_CACHE_HOME = join(root, "cache")
process.env.XDG_STATE_HOME = join(root, "state")
return root
})()
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appId)
app.setPath(
"userData",
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
)
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
logger = initLogging()
initCrashReporter()
let stopWslServers = async () => {}
const relaunch = () => {
setAppQuitting()
void stopWslServers().finally(() => {
app.relaunch()
app.quit()
})
}
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingTestRoot),
})
ensureLoopbackNoProxy()
useEnvProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
app.quit()
return
}
preferAppEnv()
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
const win = getLastFocusedWindow()
if (win) {
win.show()
win.focus()
}
})
app.on("open-url", (event: Event, url: string) => {
event.preventDefault()
logger.log("deep link received via open-url", { url })
emitDeepLinks([url])
})
app.on("before-quit", () => {
setAppQuitting()
void stopWslServers()
})
app.on("will-quit", () => {
setAppQuitting()
void stopWslServers()
})
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error")
})
setRelaunchHandler(() => {
relaunch()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
setAppQuitting()
void stopWslServers().finally(() => app.quit())
})
}
const logger = configureApplication()
if (!acquireApplicationLock()) return
preferApplicationEnvironment(logger)
const lifecycle = createApplicationLifecycle(logger)
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
yield* Effect.promise(() => app.whenReady())
yield* prepareDesktop(logger)
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to clean scoped store files", error)
}),
),
)
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
const updater = setupAutoUpdater(() => stopWslServers())
const menuDeps = {
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch,
relaunch: lifecycle.relaunch,
}
registerIpcHandlers({
relaunch,
relaunch: lifecycle.relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
logger.log("awaiting server ready")
const res = yield* Deferred.await(serverReady)
logger.log("server ready", { url: res.url })
return res
const result = yield* Deferred.await(serverReady)
logger.log("server ready", { url: result.url })
return result
},
(e) => Effect.runPromise(e),
(effect) => Effect.runPromise(effect),
),
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks,
getDefaultServerUrl,
setDefaultServerUrl,
isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding,
checkAppExists: (appName) => checkAppExists(appName),
checkAppExists,
resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
setBackgroundColor,
exportDebugLogs,
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menuDeps)
if (setNativeTranslations(bundle)) createMenu(menu)
},
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
void updater.start()
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
updateTimer.unref()
app.once("will-quit", () => clearInterval(updateTimer))
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to start net log", error)
}),
),
)
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
const loadingTask = yield* Effect.gen(function* () {
ensureLoopbackNoProxy()
useEnvProxy()
loadProxyEnvironment(logger)
logger.log("starting v2 background service")
const background = yield* Effect.promise(() => startBackgroundCli(logger))
stopWslServers = yield* Effect.promise(() => startWslServers(background))
const wsl = yield* Effect.promise(() => startWsl(background, logger))
registerWslIpcHandlers(wsl.ipc)
wsl.start()
lifecycle.setWslShutdown(wsl.stop)
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
yield* Fiber.await(loadingTask)
app.on("window-all-closed", () => {
if (process.platform === "darwin") return
app.quit()
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length > 0) return
restoreMainWindows()
})
const windows = restoreMainWindows()
if (windows.length) createMenu(menuDeps)
if (lifecycle.restoreWindows().length) createMenu(menu)
})
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
if (process.platform !== "win32") {
registerWslIpcHandlers(createWslIpc())
return async () => {}
}
const { createWslServersController } = await import("./wsl/servers")
const { spawnWslSidecar } = await import("./wsl/sidecar")
const local = cli.wslBuild
const controller = createWslServersController({
cli: { version: cli.version },
installCli: local
? async (distro) => {
const { buildLocalWslCli } = await import("./wsl/local")
const { installWslCli } = await import("./wsl/runtime")
await installWslCli(distro, {
version: cli.version,
binary: await buildLocalWslCli({ ...local, version: cli.version }),
})
}
: undefined,
spawnSidecar: async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
})
registerWslIpcHandlers(createWslIpc(controller))
controller.startConfiguredServers()
return async () => controller.stopServers()
}
Effect.runFork(main)
+29 -155
View File
@@ -1,13 +1,9 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename, join } from "node:path"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import { BrowserWindow, ipcMain } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import {
Ipc,
sendIpcEvent,
type FatalRendererError,
type IpcInvoke,
type IpcInvokeArgs,
@@ -15,21 +11,11 @@ import {
type IpcSend,
type ServerReadyData,
} from "../shared/ipc-contract"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore, removeStoreFileIfEmpty } from "./store"
import {
getPinchZoomEnabled,
getWindowID,
openExternalURL,
openLocalFileURL,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
} from "./windows"
import { createDesktopDraftStore } from "./draft-store"
import { nativeT } from "./native-translations"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { createDesktopStorage } from "./storage"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
@@ -49,13 +35,6 @@ function on<Channel extends keyof IpcSend>(
ipcMain.on(channel, listener)
}
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
return [{ name: nativeT("desktop.dialog.files"), extensions: ext }]
}
const pickedFiles = createPickedFileAuthorizations()
type Deps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
@@ -74,10 +53,8 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
app.on("before-quit", () => drafts.flush())
app.once("will-quit", () => drafts.close())
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
const files = createFileCapabilities()
const storage = createDesktopStorage()
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
@@ -103,124 +80,29 @@ export function registerIpcHandlers(deps: Deps) {
deps.setNativeTranslations(bundle)
})
handle(Ipc.storage.get, (_event, name, key) => {
try {
const store = getStore(name)
const value = store.get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
})
handle(Ipc.storage.set, (_event, name, key, value) => {
getStore(name).set(key, value)
})
handle(Ipc.storage.delete, (_event, name, key) => {
getStore(name).delete(key)
void removeStoreFileIfEmpty(name)
})
handle(Ipc.storage.clear, (_event, name) => {
getStore(name).clear()
void removeStoreFileIfEmpty(name)
})
handle(Ipc.storage.keys, (_event, name) => {
const store = getStore(name)
return Object.keys(store.store)
})
handle(Ipc.storage.length, (_event, name) => {
const store = getStore(name)
return Object.keys(store.store).length
})
handle(Ipc.drafts.get, (_event, key) => drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => drafts.putBlob(new Uint8Array(data)))
handle(Ipc.drafts.getBlob, (_event, id) => {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
return storage.get(name, key)
})
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
handle(Ipc.storage.length, (_event, name) => storage.length(name))
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
handle(Ipc.files.openDirectoryPicker, async (_event, opts) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0]
})
handle(Ipc.files.openFilePicker, async (event, opts) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
title: opts?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: opts?.defaultPath,
filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (filePath) => ({
path: filePath,
name: basename(filePath),
size: (await stat(filePath)).size,
})),
)
assertAttachmentBudget(files)
const token = pickedFiles.add(event.sender.id, result.filePaths)
return { token, files }
})
handle(Ipc.files.readPickedFile, async (event, token, filePath) => {
return pickedFiles.read(event.sender.id, token, filePath)
})
handle(Ipc.files.releasePickedFiles, (event, token) => {
pickedFiles.release(event.sender.id, token)
})
handle(Ipc.files.saveFilePicker, async (_event, opts) => {
const result = await dialog.showSaveDialog({
title: opts?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
if (result.canceled) return null
return result.filePath ?? null
})
on(Ipc.files.openExternal, (_event, url) => {
openExternalURL(url)
})
on(Ipc.files.openLocalFile, (_event, url) => {
openLocalFileURL(url)
})
handle(Ipc.files.openPath, async (_event, path, app) => {
if (!app) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const [cmd, args] =
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
})
})
handle(Ipc.files.revealPath, async (_event, path) => {
const exists = await stat(path).then(
() => true,
() => false,
)
if (!exists) return false
shell.showItemInFolder(path)
return true
})
handle(Ipc.files.readClipboardImage, () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
const buffer = new Uint8Array(image.toPNG()).buffer
const size = image.getSize()
return { buffer, width: size.width, height: size.height }
})
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
handle(Ipc.window.getId, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
@@ -300,11 +182,3 @@ export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
sendIpcEvent(win.webContents, Ipc.menu.command, id)
}
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
}
@@ -0,0 +1,143 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import http from "node:http"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect } from "effect"
import { CHANNEL, VERSION } from "../constants"
import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
import { initializeFirstLaunchOnboarding } from "./onboarding"
const appNames: Record<string, string> = {
dev: "OpenCode Dev",
beta: "OpenCode Beta",
prod: "OpenCode",
}
const appIDs: Record<string, string> = {
dev: "ai.opencode.desktop.dev",
beta: "ai.opencode.desktop.beta",
prod: "ai.opencode.desktop",
}
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
export function configureApplication() {
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
try {
process.chdir(homedir())
} catch {}
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
const onboardingRoot = createOnboardingTestRoot()
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appID)
app.setPath("userData", onboardingRoot ? join(onboardingRoot, "desktop") : join(app.getPath("appData"), appID))
if (onboardingRoot) app.setPath("sessionData", join(onboardingRoot, "session"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
const logger = initLogging()
initCrashReporter()
loadSystemCertificates(logger)
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: Boolean(onboardingRoot),
})
loadProxyEnvironment(logger)
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
return logger
}
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
app.quit()
return false
}
export function preferApplicationEnvironment(logger: DesktopLogger) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, logger) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
})
}
export function prepareDesktop(logger: DesktopLogger) {
return Effect.gen(function* () {
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))),
)
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
})
}
export function loadProxyEnvironment(logger: DesktopLogger) {
ensureLoopbackNoProxy()
try {
// Electron 41.2 has a newer Node API than the current @types/node package.
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
proxyAwareHttp.setGlobalProxyFromEnv()
} catch (error) {
logger.warn("failed to load proxy environment", error)
}
}
function createOnboardingTestRoot() {
if (!testOnboarding) return undefined
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
)
process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.XDG_CACHE_HOME = join(root, "cache")
process.env.XDG_STATE_HOME = join(root, "state")
return root
}
function loadSystemCertificates(logger: DesktopLogger) {
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
;["NO_PROXY", "no_proxy"].forEach((key) => {
const items = (process.env[key] ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
loopback.forEach((host) => {
if (!items.some((value) => value.toLowerCase() === host)) items.push(host)
})
process.env[key] = items.join(",")
})
}
@@ -0,0 +1,80 @@
import { app, BrowserWindow } from "electron"
import type { Event } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { writeLog, type DesktopLogger } from "../native/logging"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
export function createApplicationLifecycle(logger: DesktopLogger) {
const pendingDeepLinks: string[] = []
const wsl = { stop: async () => {} }
const emitDeepLinks = (urls: string[]) => {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
}
const relaunch = () => {
setAppQuitting()
void wsl.stop().finally(() => {
app.relaunch()
app.quit()
})
}
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
})
app.on("open-url", (event: Event, url: string) => {
event.preventDefault()
logger.log("deep link received via open-url", { url })
emitDeepLinks([url])
})
app.on("before-quit", () => {
setAppQuitting()
void wsl.stop()
})
app.on("will-quit", () => {
setAppQuitting()
void wsl.stop()
})
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error")
})
setRelaunchHandler(relaunch)
;(["SIGINT", "SIGTERM"] as const).forEach((signal) => {
process.on(signal, () => {
setAppQuitting()
void wsl.stop().finally(() => app.quit())
})
})
return {
relaunch,
prepareToRestart: () => wsl.stop(),
setWslShutdown(stop: () => Promise<void>) {
wsl.stop = stop
},
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
restoreWindows() {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit()
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) restoreMainWindows()
})
return restoreMainWindows()
},
}
}
@@ -2,10 +2,10 @@ import { existsSync, readdirSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { app } from "electron"
import { getStore } from "./store"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
import { write as writeLog } from "./logging"
import { hasExistingAppState } from "./install-state"
import { writeLog } from "../native/logging"
import { hasExistingAppState } from "../storage/install-state"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
import { VERSION } from "./constants"
import { VERSION } from "../constants"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
let logger: MainLogger
export const getLogger = () => logger
export type DesktopLogger = ReturnType<typeof initLogging>
export function initLogging() {
initRunDirectory()
@@ -39,25 +40,29 @@ export function initCrashReporter() {
mkdirSync(dir, { recursive: true })
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
write("crash", "crash reporter started", { path: dir })
writeLog("crash", "crash reporter started", { path: dir })
}
export async function startNetLog() {
async function startNetLog() {
if (netLog.currentlyLogging) return
netLogPath = join(run, "network.netlog")
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
write("network", "net log started", { path: netLogPath })
writeLog("network", "net log started", { path: netLogPath })
}
export function startNetworkLogging() {
return startNetLog().catch((error) => logger.warn("failed to start net log", error))
}
export async function exportDebugLogs() {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
await netLog.stopLogging().catch((error) => writeLog("network", "failed to stop net log", { error }))
}
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
try {
write("main", "exporting debug logs", { output })
writeLog("main", "exporting debug logs", { output })
await writeZip(output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
...collect(root, "desktop"),
@@ -68,12 +73,12 @@ export async function exportDebugLogs() {
return output
} finally {
if (restartNetLog) {
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
await startNetLog().catch((error) => writeLog("network", "failed to restart net log", { error }))
}
}
}
export function write(
export function writeLog(
name: string,
message: string,
extra?: Record<string, unknown>,
@@ -195,10 +200,10 @@ function initConsoleTransport() {
return
}
const write = log.transports.console.writeFn.bind(log.transports.console)
const writeConsole = log.transports.console.writeFn.bind(log.transports.console)
log.transports.console.writeFn = (options) => {
try {
write(options)
writeConsole(options)
} catch (err) {
if (!isBrokenPipe(err)) throw err
log.transports.console.level = false
@@ -1,6 +1,6 @@
import { BrowserWindow } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { createMainWindow, updateTitlebar } from "./windows"
import { createMainWindow, updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
@@ -6,11 +6,12 @@ import {
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { UPDATER_ENABLED } from "./constants"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { openExternalURL } from "./windows"
import { nativeT } from "./native-translations"
import { UPDATER_ENABLED } from "../constants"
import { openExternalURL } from "../files"
import { runDesktopMenuAction } from "./menu-actions"
import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
@@ -34,6 +35,10 @@ export function createMenu(deps: Deps) {
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
sendIpcEvent(win.webContents, Ipc.menu.command, id)
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
if (entry.type === "separator") return { type: "separator" }
if (entry.role) return { role: nativeRole(entry.role), label: entry.labelKey ? nativeT(entry.labelKey) : undefined }
+7
View File
@@ -0,0 +1,7 @@
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
export const mainBundleRoot = dirname(fileURLToPath(import.meta.url))
export const developmentResourcesRoot = join(mainBundleRoot, "../../resources")
export const preloadPath = join(mainBundleRoot, "../preload/index.js")
export const rendererRoot = join(mainBundleRoot, "../renderer")
-49
View File
@@ -1,49 +0,0 @@
import { getLogger } from "./logging"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
export function preferAppEnv() {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
})
}
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
const headers = new Headers()
if (password) {
const auth = Buffer.from(`opencode:${password}`).toString("base64")
headers.set("authorization", `Basic ${auth}`)
}
try {
const res = await fetch(new URL("/api/health", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}
@@ -3,14 +3,12 @@ import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import { app } from "electron"
import { parseCliVersion } from "./cli-version"
import { developmentResourcesRoot } from "../paths"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
@@ -45,14 +43,16 @@ export async function startBackgroundCli(logger: Logger) {
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(service.url),
...endpoint(url.origin),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
url: url.origin,
username: service.auth.username,
password: service.auth.password,
version: cli.version,
@@ -69,7 +69,7 @@ export async function startBackgroundCli(logger: Logger) {
async function resolveBundledCli(isolated: boolean, logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
: join(developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
@@ -0,0 +1,18 @@
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
const headers = new Headers()
if (password) {
const auth = Buffer.from(`opencode:${password}`).toString("base64")
headers.set("authorization", `Basic ${auth}`)
}
try {
const res = await fetch(new URL("/api/health", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}
@@ -0,0 +1,16 @@
import { DEFAULT_SERVER_URL_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
export function getDefaultServerUrl(): string | null {
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
return typeof value === "string" ? value : null
}
export function setDefaultServerUrl(url: string | null) {
if (url) {
getStore().set(DEFAULT_SERVER_URL_KEY, url)
return
}
getStore().delete(DEFAULT_SERVER_URL_KEY)
}
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
const roots: string[] = []
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { createDesktopDraftStore } from "./draft-store"
import { createDesktopDraftStore } from "./drafts"
test("flushes the latest buffered draft and stores blobs", () => {
const store = createDesktopDraftStore(":memory:")
@@ -0,0 +1,43 @@
import { join } from "node:path"
import { app } from "electron"
import { createDesktopDraftStore } from "./drafts"
import { getStore, removeStoreFileIfEmpty } from "./store"
export function createDesktopStorage() {
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
app.on("before-quit", () => drafts.flush())
app.once("will-quit", () => drafts.close())
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
return {
get(name: string, key: string) {
try {
const value = getStore(name).get(key)
if (value === undefined || value === null) return null
return typeof value === "string" ? value : JSON.stringify(value)
} catch {
return null
}
},
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
deleteValue(name: string, key: string) {
getStore(name).delete(key)
void removeStoreFileIfEmpty(name)
},
clear(name: string) {
getStore(name).clear()
void removeStoreFileIfEmpty(name)
},
keys: (name: string) => Object.keys(getStore(name).store),
length: (name: string) => Object.keys(getStore(name).store).length,
drafts: {
get: (key: string) => drafts.get(key),
set: (key: string, value: string | null) => drafts.set(key, value),
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
getBlob(id: string) {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
},
},
}
}
@@ -3,8 +3,8 @@ import electron from "electron"
import { rmSync } from "node:fs"
import { join } from "node:path"
import { SETTINGS_STORE } from "./store-keys"
import { deleteStoreFileIfEmpty } from "./store-cleanup"
import { deleteStoreFileIfEmpty } from "./cleanup"
import { SETTINGS_STORE } from "./keys"
const cache = new Map<string, Store>()
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterReadyRecord } from "./controller"
// Drives the controller the way the app does: start or check, observe the states
// the renderer sees, then install like a button click. `calls` records the platform
@@ -1,12 +1,12 @@
import { app, dialog } from "electron"
import type { WebContents } from "electron"
import { Ipc, sendIpcEvent } from "../shared/ipc-contract"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
import { nativeT } from "./native-translations"
import { createUpdaterPlatform } from "./updater-platform"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { UPDATER_ENABLED } from "../constants"
import { getLogger } from "../native/logging"
import { nativeT } from "../native/translations"
import { getStore } from "../storage/store"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./controller"
import { createUpdaterPlatform } from "./platform"
const key = "ready"
@@ -30,6 +30,13 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
})
}
export function startAutoUpdater(controller: UpdaterController) {
void controller.start()
const timer = setInterval(() => void controller.check(), 10 * 60 * 1000)
timer.unref()
app.once("will-quit", () => clearInterval(timer))
}
export function createUpdaterIpc(controller: UpdaterController) {
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
@@ -1,8 +1,8 @@
import { app, autoUpdater } from "electron"
import pkg from "electron-updater"
import { getLogger } from "./logging"
import { setAppQuitting } from "./windows"
import type { UpdaterPlatform } from "./updater-controller"
import { getLogger } from "../native/logging"
import { setAppQuitting } from "../windows"
import type { UpdaterPlatform } from "./controller"
const updateClient = pkg.autoUpdater
const restartTimeout = 10_000
-564
View File
@@ -1,564 +0,0 @@
import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol, shell } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../shared/ipc-contract"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore, removeStoreFile } from "./store"
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
import { nativeT } from "./native-translations"
import { createWindowRegistry } from "./window-registry"
import { safeWindowURL } from "./window-state"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
const rendererProtocol = "oc"
const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
protocol.registerSchemesAsPrivileged([
{
scheme: rendererProtocol,
privileges: {
secure: true,
standard: true,
supportFetchAPI: true,
stream: true,
},
},
])
let backgroundColor: string | undefined
let relaunchHandler = () => {
setAppQuitting()
app.relaunch()
app.exit(0)
}
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
cleanup: (id) => {
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
removeStoreFile(windowDataFile(id))
},
})
const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setAppQuitting(quitting = true) {
registry.setQuitting(quitting)
}
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
})
}
export function getBackgroundColor(): string | undefined {
return backgroundColor
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
}
function iconPath() {
const ext = process.platform === "win32" ? "ico" : "png"
return join(iconsDir(), `icon.${ext}`)
}
function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function defaultBackgroundColor() {
return oc2Background[tone()]
}
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
}
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
// macOS draws the window frame hairline and shadow using the NSWindow
// appearance, which follows nativeTheme rather than the rendered content.
// Align it with the app theme so a light app on a dark system does not get
// the dark-appearance border and shadow. A "system" scheme must map to
// "system" (not the resolved mode) or prefers-color-scheme stops tracking
// OS appearance changes in the renderer.
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
updateTitlebar(win)
}
export function updateTitlebar(win: BrowserWindow) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
}
export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
for (const win of BrowserWindow.getAllWindows()) {
pinchZoomEnabled.set(win, enabled)
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
}
}
export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
}
export function getWindowID(win: BrowserWindow) {
return windowIDs.get(win)
}
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
const win = registry.lastFocused()
if (!win || win.isDestroyed()) return null
return win
}
export function restoreMainWindows() {
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}
export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow(id: string = randomUUID()) {
const state = windowState({
file: windowStateFile(id),
defaultWidth: 1280,
defaultHeight: 800,
})
const mode = tone()
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
title: "OpenCode",
icon: iconPath(),
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 14, y: 14 },
}
: {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
preload: join(root, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
allowRendererPermissions(win)
wireWindowRecovery(win, id)
wireNavigationPolicy(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
callback({ requestHeaders })
})
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders = {} } = details
addRendererHeaders(details.url, responseHeaders)
callback({ responseHeaders })
})
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => {
win.show()
})
return win
}
export function openExternalURL(value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
return
}
void shell.openExternal(url)
}
export function openLocalFileURL(value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
function wireNavigationPolicy(win: BrowserWindow) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isRendererUrl(url)) openExternalURL(url)
return { action: "deny" }
})
// Renderer reloads (window.location.reload) navigate to the app's own URL
// and must stay in-window; everything else leaves through the OS.
win.webContents.on("will-navigate", (event, url) => {
if (isRendererUrl(url)) return
event.preventDefault()
openExternalURL(url)
})
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows never emits before-quit on OS shutdown/logoff, but each window
// gets session-end before it closes; flag the quit so ids stay persisted.
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => registry.closed(id))
}
function windowStateFile(id: string) {
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
// Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names
// the per-window renderer store this window persists its tabs into.
function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
writeLog("protocol", "rejected host", { url: request.url }, "warn")
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
return new Response("Not found", { status: 404 })
}
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), {
headers: range ? { range } : undefined,
})
if (response.status >= 400) {
writeLog(
"protocol",
"fetch failed",
{
url: request.url,
file,
status: response.status,
statusText: response.statusText,
},
"error",
)
}
return addDocumentPolicy(response, file)
} catch (error) {
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
return new Response("Not found", { status: 404 })
}
})
}
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
const url = new URL(html, devUrl)
void win.loadURL(url.toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
function wireWindowRecovery(win: BrowserWindow, name: string) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit"
const handle = async (action: RecoveryAction | undefined, wait: boolean) => {
if (action === "export-logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (action === "relaunch") {
sampler.stopAndFlush()
relaunchHandler()
return false
}
if (action === "quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const actions: { id: RecoveryAction; label: string }[] = wait
? [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") },
]
: [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "quit", label: nativeT("desktop.recovery.action.quit") },
]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons: actions.map((action) => action.label),
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(actions[result.response]?.id, wait)) continue
return
}
} finally {
showing = false
}
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
writeLog(
"window",
"renderer load failed",
{
window: name,
event,
errorCode,
errorDescription,
validatedURL,
currentURL: safeWindowURL(win),
isMainFrame,
},
"error",
)
if (!isMainFrame || errorCode === -3) return
void show(
nativeT("desktop.recovery.loadFailed"),
nativeT("desktop.recovery.loadFailed.detail", {
window: name,
url: validatedURL,
code: errorCode,
description: errorDescription,
}),
false,
)
}
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
void show(
nativeT("desktop.recovery.terminated"),
nativeT("desktop.recovery.terminated.detail", {
window: name,
reason: details.reason,
code: details.exitCode ?? nativeT("desktop.recovery.unknown"),
}),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
sampler.start()
void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
writeLog("pty", "console", { window: name, level, message, line, sourceId })
}
})
win.webContents.on("preload-error", (_event, preloadPath, error) => {
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
})
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
function allowRendererPermissions(win: BrowserWindow) {
const webContentsId = win.webContents.id
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
rendererPermissions.has(permission) &&
isTrustedRendererUrl(details.requestingUrl) &&
webContents.id === webContentsId,
)
})
win.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
if (!rendererPermissions.has(permission)) return false
if (webContents && webContents.id !== webContentsId) return false
return isTrustedRendererUrl(details.requestingUrl) || isTrustedRendererUrl(requestingOrigin)
})
}
function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
function addRendererHeaders(value: string, headers: Record<string, any>) {
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
}
function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, zoomDirection) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)))
updateZoom(win)
return
}
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
}
function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
}
win.on("enter-full-screen", () => send(true))
win.on("leave-full-screen", () => send(false))
}
function clampZoom(value: number) {
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
}
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
}
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
const keyToChangeLower = keyToChange.toLowerCase()
for (const key of Object.keys(obj)) {
if (key.toLowerCase() === keyToChangeLower) {
// Reassign old key
obj[key] = value
// Done
return
}
}
// Insert at end instead
obj[keyToChange] = value
}
@@ -0,0 +1,148 @@
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { join } from "node:path"
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
import { developmentResourcesRoot, preloadPath } from "../paths"
import { PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
let backgroundColor: string | undefined
export function windowAppearance() {
const mode = tone()
return {
title: "OpenCode",
icon: iconPath(),
backgroundColor: backgroundColor ?? oc2Background[mode],
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 14, y: 14 },
}
: {}),
...(process.platform === "win32"
? {
frame: false,
titleBarStyle: "hidden" as const,
titleBarOverlay: overlay({ mode }),
}
: {}),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
}
}
export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
})
}
export function getBackgroundColor() {
return backgroundColor
}
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
// The macOS frame follows nativeTheme, not the renderer theme.
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
updateTitlebar(win)
}
export function updateTitlebar(win: BrowserWindow) {
if (process.platform !== "win32") return
win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
}
export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
BrowserWindow.getAllWindows().forEach((win) => {
pinchZoomEnabled.set(win, enabled)
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
}
export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
}
export function wireZoom(win: BrowserWindow) {
pinchZoomEnabled.set(win, getPinchZoomEnabled())
win.webContents.setZoomFactor(1)
win.webContents.on("zoom-changed", (event, direction) => {
event.preventDefault()
if (pinchZoomEnabled.get(win)) {
const delta = direction === "in" ? 0.2 : -0.2
win.webContents.setZoomFactor(clampZoom(win.webContents.getZoomFactor() + delta))
updateZoom(win)
return
}
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
}
export function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
}
win.on("enter-full-screen", () => send(true))
win.on("leave-full-screen", () => send(false))
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(developmentResourcesRoot, "icons")
}
function iconPath() {
return join(iconsDir(), `icon.${process.platform === "win32" ? "ico" : "png"}`)
}
function tone() {
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
}
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
const mode = theme.mode ?? tone()
return {
color: "#00000000",
symbolColor: mode === "dark" ? "white" : "black",
height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)),
}
}
function clampZoom(value: number) {
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
}
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
}
+121
View File
@@ -0,0 +1,121 @@
import windowState from "electron-window-state"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { join } from "node:path"
import { app, BrowserWindow } from "electron"
import { removeStoreFile, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import {
getBackgroundColor,
getPinchZoomEnabled,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
windowAppearance,
wireFullscreen,
wireZoom,
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { createWindowRegistry } from "./registry"
import { wireWindowRecovery } from "./recovery"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const windowIDs = new WeakMap<BrowserWindow, string>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
cleanup: (id) => {
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
removeStoreFile(windowDataFile(id))
},
})
let relaunchHandler = () => {
setAppQuitting()
app.relaunch()
app.exit(0)
}
export {
getBackgroundColor,
getPinchZoomEnabled,
registerRendererProtocol,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
setTitlebar,
updateTitlebar,
}
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setAppQuitting(quitting = true) {
registry.setQuitting(quitting)
}
export function getWindowID(win: BrowserWindow) {
return windowIDs.get(win)
}
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
const win = registry.lastFocused()
if (!win || win.isDestroyed()) return null
return win
}
export function restoreMainWindows() {
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}
export function createMainWindow(id: string = randomUUID()) {
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
...windowAppearance(),
})
allowRendererPermissions(win)
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win)
wireRendererHeaders(win)
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => win.show())
return win
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => registry.closed(id))
}
function windowStateFile(id: string) {
return `window-state-${safeWindowID(id)}.json`
}
// Mirrors windowStorage() in packages/app/src/utils/persist.ts.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
function safeWindowID(id: string) {
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
}

Some files were not shown because too many files have changed in this diff Show More