mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 13:49:25 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b683551b11 | |||
| 401a154ec3 | |||
| 18c25ae406 | |||
| cd156f9f1c | |||
| b968a314d3 | |||
| 93cdfafa78 | |||
| b6a8d99da5 | |||
| 8cacf150db | |||
| 76e7b556b6 | |||
| a888ba4da7 |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: "",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
|
||||
@@ -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,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 } }
|
||||
|
||||
@@ -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,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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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"
|
||||
|
||||
+15
-10
@@ -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
-1
@@ -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 }
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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) => {
|
||||
+3
-3
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user