Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5954b332d5 | ||
|
|
eb89dfaf89 | ||
|
|
25bf313338 | ||
|
|
315abf21e6 | ||
|
|
f24e360d78 | ||
|
|
1a6f1fdbae | ||
|
|
e09ce0780e | ||
|
|
95fdad7523 | ||
|
|
06416a9eb3 | ||
|
|
2db62b1d17 | ||
|
|
1377bc6b91 | ||
|
|
fcb5998474 | ||
|
|
c2df32ec8b | ||
|
|
f01149ee9e | ||
|
|
eebfcb5628 | ||
|
|
4571a1dcf9 | ||
|
|
a041e1c6c3 | ||
|
|
abb8a9df19 | ||
|
|
3c450c076a | ||
|
|
4b05e698f8 | ||
|
|
a9524b3e30 | ||
|
|
154c5208b4 | ||
|
|
71479a59a7 | ||
|
|
3606d9aa50 | ||
|
|
3e4d51c9f2 | ||
|
|
2603b1d260 | ||
|
|
94aa469e90 | ||
|
|
dab1e0fa7a | ||
|
|
a14247f049 | ||
|
|
695a890e0a | ||
|
|
402d72d038 | ||
|
|
d32ec73c63 | ||
|
|
d0eac1e610 | ||
|
|
e947691aae | ||
|
|
575f987b8f | ||
|
|
28b66ed0af | ||
|
|
4060c4f60b | ||
|
|
8334e27294 | ||
|
|
722b523f92 | ||
|
|
06be455358 | ||
|
|
450f5bf0b4 | ||
|
|
997d4f4129 | ||
|
|
ff5c698131 | ||
|
|
14497f2082 | ||
|
|
f3e1966b5d | ||
|
|
78592f229e | ||
|
|
c8161669ac | ||
|
|
8ec57da275 | ||
|
|
c00b29145a | ||
|
|
7d2a349e95 | ||
|
|
6c326b18ca | ||
|
|
09229259d1 | ||
|
|
b20bfc34b2 | ||
|
|
4e1f08bfcf | ||
|
|
ef4f8ac45f | ||
|
|
6a7255d9d2 | ||
|
|
f37fcaed3d | ||
|
|
d9fd22c29f | ||
|
|
3fcab5b80a | ||
|
|
4ed2361387 | ||
|
|
75b3699649 | ||
|
|
a6404f25d9 | ||
|
|
7591e5c1c9 | ||
|
|
5e8b3fd5c9 | ||
|
|
20b82496a1 | ||
|
|
542b59940a | ||
|
|
8d5c6b37e9 | ||
|
|
8155fc9956 | ||
|
|
cd4afb5314 | ||
|
|
557c2500c7 | ||
|
|
74f8b6c31f | ||
|
|
da517416a5 | ||
|
|
b8f93bf768 | ||
|
|
0110052758 | ||
|
|
0e0da1a142 | ||
|
|
da3b66a3bd | ||
|
|
088e5f1eea | ||
|
|
0da2e1d7bb | ||
|
|
90c6835ee7 | ||
|
|
92bef8bfb8 | ||
|
|
766be00ded | ||
|
|
ce5eaa1841 | ||
|
|
c323667729 | ||
|
|
67a12d6126 | ||
|
|
bd0cb04b78 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,5 @@ release/
|
||||
.vite/
|
||||
.electron-vite/
|
||||
out/
|
||||
.dir-locals.el
|
||||
.dir-locals.el
|
||||
.opencode/bashOutputs/
|
||||
1718
package-lock.json
generated
1718
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
@@ -23,5 +23,8 @@
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
@@ -69,6 +69,10 @@
|
||||
"!icon.icns",
|
||||
"!icon.ico"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/dist/opencode-config",
|
||||
"to": "opencode-config"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
|
||||
32
packages/opencode-config/README.md
Normal file
32
packages/opencode-config/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# opencode-config
|
||||
|
||||
## TLDR
|
||||
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
|
||||
|
||||
## What it is
|
||||
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
|
||||
|
||||
## How it works
|
||||
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
|
||||
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
|
||||
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
|
||||
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
|
||||
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
|
||||
|
||||
## Expectations
|
||||
- Local-only bridge (no auth/token yet).
|
||||
- Plugin must fail startup if it cannot connect after 3 retries.
|
||||
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
|
||||
- Keep event shapes small and explicit; use `type` + `properties` only.
|
||||
|
||||
## Ideas
|
||||
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
|
||||
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
|
||||
- Promote stable event shapes and version tags once the protocol settles.
|
||||
|
||||
## Pointers
|
||||
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
|
||||
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
|
||||
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
|
||||
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
|
||||
- Workspace env injection: `packages/server/src/workspaces/manager.ts`
|
||||
3
packages/opencode-config/opencode.jsonc
Normal file
3
packages/opencode-config/opencode.jsonc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
}
|
||||
32
packages/opencode-config/plugin/codenomad.ts
Normal file
32
packages/opencode-config/plugin/codenomad.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
|
||||
import { createBackgroundProcessTools } from "./lib/background-process"
|
||||
|
||||
export async function CodeNomadPlugin(input: PluginInput) {
|
||||
const config = getCodeNomadConfig()
|
||||
const client = createCodeNomadClient(config)
|
||||
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
|
||||
|
||||
await client.startEvents((event) => {
|
||||
if (event.type === "codenomad.ping") {
|
||||
void client.postEvent({
|
||||
type: "codenomad.pong",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
pingTs: (event.properties as any)?.ts,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tool: {
|
||||
...backgroundProcessTools,
|
||||
},
|
||||
async event(input: { event: any }) {
|
||||
const opencodeEvent = input?.event
|
||||
if (!opencodeEvent || typeof opencodeEvent !== "object") return
|
||||
|
||||
},
|
||||
}
|
||||
}
|
||||
309
packages/opencode-config/plugin/lib/background-process.ts
Normal file
309
packages/opencode-config/plugin/lib/background-process.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import path from "path"
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
|
||||
type BackgroundProcess = {
|
||||
id: string
|
||||
title: string
|
||||
command: string
|
||||
status: "running" | "stopped" | "error"
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
type BackgroundProcessOptions = {
|
||||
baseDir: string
|
||||
}
|
||||
|
||||
type ParsedCommand = {
|
||||
head: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
|
||||
const base = config.baseUrl.replace(/\/+$/, "")
|
||||
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || `Request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
return {
|
||||
run_background_process: tool({
|
||||
description:
|
||||
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
|
||||
args: {
|
||||
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
|
||||
command: tool.schema.string().describe("Shell command to run in the workspace"),
|
||||
},
|
||||
async execute(args) {
|
||||
assertCommandWithinBase(args.command, options.baseDir)
|
||||
const process = await request<BackgroundProcess>("", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: args.title, command: args.command }),
|
||||
})
|
||||
|
||||
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
|
||||
},
|
||||
}),
|
||||
list_background_processes: tool({
|
||||
description: "List background processes running for this workspace.",
|
||||
args: {},
|
||||
async execute() {
|
||||
const response = await request<{ processes: BackgroundProcess[] }>("")
|
||||
if (response.processes.length === 0) {
|
||||
return "No background processes running."
|
||||
}
|
||||
|
||||
return response.processes
|
||||
.map((process) => {
|
||||
const status = process.status === "running" ? "running" : process.status
|
||||
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
|
||||
const size =
|
||||
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
|
||||
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
|
||||
})
|
||||
.join("\n")
|
||||
},
|
||||
}),
|
||||
read_background_process_output: tool({
|
||||
description: "Read output from a background process. Use full, grep, head, or tail.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
method: tool.schema
|
||||
.enum(["full", "grep", "head", "tail"])
|
||||
.default("full")
|
||||
.describe("Method to read output"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
|
||||
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
|
||||
},
|
||||
async execute(args) {
|
||||
if (args.method === "grep" && !args.pattern) {
|
||||
return "Pattern is required for grep method."
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ method: args.method })
|
||||
if (args.pattern) {
|
||||
params.set("pattern", args.pattern)
|
||||
}
|
||||
if (args.lines) {
|
||||
params.set("lines", String(args.lines))
|
||||
}
|
||||
|
||||
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
|
||||
`/${args.id}/output?${params.toString()}`,
|
||||
)
|
||||
|
||||
const header = response.truncated
|
||||
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
|
||||
|
||||
return `${header}\n\n${response.content}`
|
||||
},
|
||||
}),
|
||||
stop_background_process: tool({
|
||||
description: "Stop a background process (SIGTERM) but keep its output and entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
|
||||
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
|
||||
},
|
||||
}),
|
||||
terminate_background_process: tool({
|
||||
description: "Terminate a background process and delete its output + entry.",
|
||||
args: {
|
||||
id: tool.schema.string().describe("Background process ID"),
|
||||
},
|
||||
async execute(args) {
|
||||
await request<void>(`/${args.id}/terminate`, { method: "POST" })
|
||||
return `Terminated background process ${args.id} and removed its output.`
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
|
||||
const EXPANSION_CHARS = /[~*$?\[\]`$]/
|
||||
|
||||
function assertCommandWithinBase(command: string, baseDir: string) {
|
||||
const normalizedBase = path.resolve(baseDir)
|
||||
const commands = splitCommands(command)
|
||||
|
||||
for (const item of commands) {
|
||||
if (!FILE_COMMANDS.has(item.head)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const arg of item.args) {
|
||||
if (!arg) continue
|
||||
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
|
||||
|
||||
const literalArg = unquote(arg)
|
||||
if (EXPANSION_CHARS.test(literalArg)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
|
||||
if (!isWithinBase(normalizedBase, resolved)) {
|
||||
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitCommands(command: string): ParsedCommand[] {
|
||||
const tokens = tokenize(command)
|
||||
const commands: ParsedCommand[] = []
|
||||
let current: string[] = []
|
||||
|
||||
for (const token of tokens) {
|
||||
if (isSeparator(token)) {
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
current = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.push(token)
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
commands.push({ head: current[0], args: current.slice(1) })
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = []
|
||||
let current = ""
|
||||
let quote: "'" | '"' | null = null
|
||||
let escape = false
|
||||
|
||||
const flush = () => {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current)
|
||||
current = ""
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index]
|
||||
|
||||
if (escape) {
|
||||
current += char
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\" && quote !== "'") {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
current += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === " " || char === "\n" || char === "\t") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "|" || char === "&" || char === ";") {
|
||||
flush()
|
||||
const next = input[index + 1]
|
||||
if ((char === "|" || char === "&") && next === char) {
|
||||
tokens.push(char + next)
|
||||
index += 1
|
||||
} else {
|
||||
tokens.push(char)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function isSeparator(token: string) {
|
||||
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
|
||||
}
|
||||
|
||||
function unquote(value: string) {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isWithinBase(baseDir: string, target: string) {
|
||||
const relative = path.relative(baseDir, target)
|
||||
if (!relative) return true
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
165
packages/opencode-config/plugin/lib/client.ts
Normal file
165
packages/opencode-config/plugin/lib/client.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export type PluginEvent = {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodeNomadConfig = {
|
||||
instanceId: string
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
export function getCodeNomadConfig(): CodeNomadConfig {
|
||||
return {
|
||||
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
||||
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodeNomadClient(config: CodeNomadConfig) {
|
||||
return {
|
||||
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
|
||||
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
|
||||
}
|
||||
}
|
||||
|
||||
function requireEnv(key: string): string {
|
||||
const value = process.env[key]
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
|
||||
|
||||
// Fail plugin startup if we cannot establish the initial connection.
|
||||
const initialBody = await connectWithRetries(url, 3)
|
||||
|
||||
// After startup, keep reconnecting; throw after 3 consecutive failures.
|
||||
void consumeWithReconnect(url, onEvent, initialBody)
|
||||
}
|
||||
|
||||
async function connectWithRetries(url: string, maxAttempts: number) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
|
||||
}
|
||||
return response.body
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await delay(500 * attempt)
|
||||
}
|
||||
}
|
||||
|
||||
const reason = lastError instanceof Error ? lastError.message : String(lastError)
|
||||
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
|
||||
}
|
||||
|
||||
async function consumeWithReconnect(
|
||||
url: string,
|
||||
onEvent: (event: PluginEvent) => void,
|
||||
initialBody: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
let consecutiveFailures = 0
|
||||
let body: ReadableStream<Uint8Array> | null = initialBody
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (!body) {
|
||||
body = await connectWithRetries(url, 3)
|
||||
}
|
||||
|
||||
await consumeSseBody(body, onEvent)
|
||||
body = null
|
||||
consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
body = null
|
||||
consecutiveFailures += 1
|
||||
if (consecutiveFailures >= 3) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
|
||||
}
|
||||
await delay(500 * consecutiveFailures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || !value) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n")
|
||||
while (separatorIndex >= 0) {
|
||||
const chunk = buffer.slice(0, separatorIndex)
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
separatorIndex = buffer.indexOf("\n\n")
|
||||
|
||||
const event = parseSseChunk(chunk)
|
||||
if (event) {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("SSE stream ended")
|
||||
}
|
||||
|
||||
function parseSseChunk(chunk: string): PluginEvent | null {
|
||||
const lines = chunk.split(/\r?\n/)
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(":")) continue
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart())
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return null
|
||||
|
||||
const payload = dataLines.join("\n").trim()
|
||||
if (!payload) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payload)
|
||||
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
|
||||
return null
|
||||
}
|
||||
return parsed as PluginEvent
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
@@ -16,10 +16,11 @@
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
|
||||
"build:ui": "npm run build --prefix ../ui",
|
||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
47
packages/server/scripts/copy-opencode-config.mjs
Normal file
47
packages/server/scripts/copy-opencode-config.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "child_process"
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
const sourceDir = path.resolve(cliRoot, "../opencode-config")
|
||||
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
|
||||
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(nodeModulesDir)) {
|
||||
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
|
||||
const result = spawnSync(
|
||||
npmCmd,
|
||||
[
|
||||
"install",
|
||||
"--prefix",
|
||||
sourceDir,
|
||||
"--omit=dev",
|
||||
"--ignore-scripts",
|
||||
"--fund=false",
|
||||
"--audit=false",
|
||||
"--package-lock=false",
|
||||
"--workspaces=false",
|
||||
],
|
||||
{ cwd: sourceDir, stdio: "inherit", env: { ...process.env, npm_config_workspaces: "false" } },
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||
cpSync(sourceDir, targetDir, { recursive: true })
|
||||
|
||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||
@@ -219,6 +219,33 @@ export interface ServerMeta {
|
||||
latestRelease?: LatestReleaseInfo
|
||||
}
|
||||
|
||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||
|
||||
export interface BackgroundProcess {
|
||||
id: string
|
||||
workspaceId: string
|
||||
title: string
|
||||
command: string
|
||||
cwd: string
|
||||
status: BackgroundProcessStatus
|
||||
pid?: number
|
||||
startedAt: string
|
||||
stoppedAt?: string
|
||||
exitCode?: number
|
||||
outputSizeBytes?: number
|
||||
}
|
||||
|
||||
export interface BackgroundProcessListResponse {
|
||||
processes: BackgroundProcess[]
|
||||
}
|
||||
|
||||
export interface BackgroundProcessOutputResponse {
|
||||
id: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type {
|
||||
Preferences,
|
||||
ModelPreference,
|
||||
|
||||
438
packages/server/src/background-processes/manager.ts
Normal file
438
packages/server/src/background-processes/manager.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { randomBytes } from "crypto"
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
|
||||
|
||||
const ROOT_DIR = ".codenomad/background_processes"
|
||||
const INDEX_FILE = "index.json"
|
||||
const OUTPUT_FILE = "output.txt"
|
||||
const STOP_TIMEOUT_MS = 2000
|
||||
const MAX_OUTPUT_BYTES = 20 * 1024
|
||||
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
|
||||
|
||||
interface ManagerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess
|
||||
outputPath: string
|
||||
exitPromise: Promise<void>
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class BackgroundProcessManager {
|
||||
private readonly running = new Map<string, RunningProcess>()
|
||||
|
||||
constructor(private readonly deps: ManagerDeps) {
|
||||
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
|
||||
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
|
||||
}
|
||||
|
||||
async list(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const enriched = await Promise.all(
|
||||
records.map(async (record) => ({
|
||||
...record,
|
||||
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
|
||||
})),
|
||||
)
|
||||
return enriched
|
||||
}
|
||||
|
||||
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
|
||||
const id = this.generateId()
|
||||
const processDir = await this.ensureProcessDir(workspaceId, id)
|
||||
const outputPath = path.join(processDir, OUTPUT_FILE)
|
||||
|
||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||
|
||||
const child = spawn("bash", ["-c", command], {
|
||||
cwd: workspace.path,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const record: BackgroundProcess = {
|
||||
id,
|
||||
workspaceId,
|
||||
title,
|
||||
command,
|
||||
cwd: workspace.path,
|
||||
status: "running",
|
||||
pid: child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
outputSizeBytes: 0,
|
||||
}
|
||||
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
child.on("close", async (code) => {
|
||||
await new Promise<void>((resolve) => outputStream.end(resolve))
|
||||
this.running.delete(id)
|
||||
|
||||
record.status = this.statusFromExit(code)
|
||||
record.exitCode = code === null ? undefined : code
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
|
||||
|
||||
let lastPublishAt = 0
|
||||
const maybePublishSize = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastPublishAt = now
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
child.stderr?.on("data", (data) => {
|
||||
outputStream.write(data)
|
||||
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
|
||||
maybePublishSize()
|
||||
})
|
||||
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.child.kill("SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
if (record.status === "running") {
|
||||
record.status = "stopped"
|
||||
record.stoppedAt = new Date().toISOString()
|
||||
await this.upsertIndex(workspaceId, record)
|
||||
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
|
||||
this.publishUpdate(workspaceId, record)
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
async terminate(workspaceId: string, processId: string): Promise<void> {
|
||||
const record = await this.findProcess(workspaceId, processId)
|
||||
if (!record) return
|
||||
|
||||
const running = this.running.get(processId)
|
||||
if (running?.child && !running.child.killed) {
|
||||
running.child.kill("SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
|
||||
await this.removeFromIndex(workspaceId, processId)
|
||||
await this.removeProcessDir(workspaceId, processId)
|
||||
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.removed", properties: { processId } },
|
||||
})
|
||||
}
|
||||
|
||||
async readOutput(
|
||||
workspaceId: string,
|
||||
processId: string,
|
||||
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
|
||||
) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
|
||||
}
|
||||
|
||||
const stats = await fs.stat(outputPath)
|
||||
const sizeBytes = stats.size
|
||||
const method = options.method ?? "full"
|
||||
const lineCount = options.lines ?? 10
|
||||
|
||||
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
|
||||
let content = raw
|
||||
|
||||
switch (method) {
|
||||
case "head":
|
||||
content = this.headLines(raw, lineCount)
|
||||
break
|
||||
case "tail":
|
||||
content = this.tailLines(raw, lineCount)
|
||||
break
|
||||
case "grep":
|
||||
if (!options.pattern) {
|
||||
throw new Error("Pattern is required for grep output")
|
||||
}
|
||||
content = this.grepLines(raw, options.pattern)
|
||||
break
|
||||
default:
|
||||
content = raw
|
||||
}
|
||||
|
||||
const effectiveMaxBytes = options.maxBytes
|
||||
return {
|
||||
id: processId,
|
||||
content,
|
||||
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
|
||||
sizeBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async streamOutput(workspaceId: string, processId: string, reply: any) {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
reply.code(404).send({ error: "Output not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const file = await fs.open(outputPath, "r")
|
||||
let position = (await file.stat()).size
|
||||
|
||||
const tick = async () => {
|
||||
const stats = await file.stat()
|
||||
if (stats.size <= position) return
|
||||
|
||||
const length = stats.size - position
|
||||
const buffer = Buffer.alloc(length)
|
||||
await file.read(buffer, 0, length, position)
|
||||
position = stats.size
|
||||
|
||||
const content = buffer.toString("utf-8")
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
tick().catch((error) => {
|
||||
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(interval)
|
||||
file.close().catch(() => undefined)
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
reply.raw.on("close", close)
|
||||
reply.raw.on("error", close)
|
||||
}
|
||||
|
||||
private async cleanupWorkspace(workspaceId: string) {
|
||||
for (const [, running] of this.running.entries()) {
|
||||
if (running.workspaceId !== workspaceId) continue
|
||||
running.child.kill("SIGTERM")
|
||||
await this.waitForExit(running)
|
||||
}
|
||||
await this.removeWorkspaceDir(workspaceId)
|
||||
}
|
||||
|
||||
private async waitForExit(running: RunningProcess) {
|
||||
let resolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
running.child.kill("SIGKILL")
|
||||
}
|
||||
}, STOP_TIMEOUT_MS)
|
||||
|
||||
await running.exitPromise.finally(() => {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
return "error"
|
||||
}
|
||||
|
||||
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
|
||||
if (maxBytes === undefined || sizeBytes <= maxBytes) {
|
||||
return await fs.readFile(outputPath, "utf-8")
|
||||
}
|
||||
|
||||
const start = Math.max(0, sizeBytes - maxBytes)
|
||||
const file = await fs.open(outputPath, "r")
|
||||
const buffer = Buffer.alloc(sizeBytes - start)
|
||||
await file.read(buffer, 0, buffer.length, start)
|
||||
await file.close()
|
||||
return buffer.toString("utf-8")
|
||||
}
|
||||
|
||||
private headLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(0, Math.max(0, lines)).join("\n")
|
||||
}
|
||||
|
||||
private tailLines(input: string, lines: number): string {
|
||||
const parts = input.split(/\r?\n/)
|
||||
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
|
||||
}
|
||||
|
||||
private grepLines(input: string, pattern: string): string {
|
||||
let matcher: RegExp
|
||||
try {
|
||||
matcher = new RegExp(pattern)
|
||||
} catch {
|
||||
throw new Error("Invalid grep pattern")
|
||||
}
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => matcher.test(line))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
private async ensureProcessDir(workspaceId: string, processId: string) {
|
||||
const root = await this.ensureWorkspaceDir(workspaceId)
|
||||
const processDir = path.join(root, processId)
|
||||
await fs.mkdir(processDir, { recursive: true })
|
||||
return processDir
|
||||
}
|
||||
|
||||
private async ensureWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
return root
|
||||
}
|
||||
|
||||
private getOutputPath(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
|
||||
}
|
||||
|
||||
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
return records.find((entry) => entry.id === processId) ?? null
|
||||
}
|
||||
|
||||
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
if (!existsSync(indexPath)) return []
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, "utf-8")
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const index = records.findIndex((entry) => entry.id === record.id)
|
||||
if (index >= 0) {
|
||||
records[index] = record
|
||||
} else {
|
||||
records.push(record)
|
||||
}
|
||||
await this.writeIndex(workspaceId, records)
|
||||
}
|
||||
|
||||
private async removeFromIndex(workspaceId: string, processId: string) {
|
||||
const records = await this.readIndex(workspaceId)
|
||||
const next = records.filter((entry) => entry.id !== processId)
|
||||
await this.writeIndex(workspaceId, next)
|
||||
}
|
||||
|
||||
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
|
||||
const indexPath = await this.getIndexPath(workspaceId)
|
||||
await fs.mkdir(path.dirname(indexPath), { recursive: true })
|
||||
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
|
||||
}
|
||||
|
||||
private async getIndexPath(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found")
|
||||
}
|
||||
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
|
||||
}
|
||||
|
||||
private async removeProcessDir(workspaceId: string, processId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
|
||||
await fs.rm(processDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async removeWorkspaceDir(workspaceId: string) {
|
||||
const workspace = this.deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
|
||||
const outputPath = this.getOutputPath(workspaceId, processId)
|
||||
if (!existsSync(outputPath)) {
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(outputPath)
|
||||
return stats.size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
|
||||
this.deps.eventBus.publish({
|
||||
type: "instance.event",
|
||||
instanceId: workspaceId,
|
||||
event: { type: "background.process.updated", properties: { process: record } },
|
||||
})
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
||||
const random = randomBytes(3).toString("hex")
|
||||
return `proc_${timestamp}_${random}`
|
||||
}
|
||||
}
|
||||
@@ -122,22 +122,6 @@ async function main() {
|
||||
logger.info({ options }, "Starting CodeNomad CLI server")
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
@@ -150,6 +134,24 @@ async function main() {
|
||||
addresses: [],
|
||||
}
|
||||
|
||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
||||
const workspaceManager = new WorkspaceManager({
|
||||
rootDir: options.rootDir,
|
||||
configStore,
|
||||
binaryRegistry,
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
getServerBaseUrl: () => serverMeta.httpBaseUrl,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
const instanceEventBridge = new InstanceEventBridge({
|
||||
workspaceManager,
|
||||
eventBus,
|
||||
logger: logger.child({ component: "instance-events" }),
|
||||
})
|
||||
|
||||
const releaseMonitor = startReleaseMonitor({
|
||||
currentVersion: packageJson.version,
|
||||
logger: logger.child({ component: "release-monitor" }),
|
||||
|
||||
31
packages/server/src/opencode-config.ts
Normal file
31
packages/server/src/opencode-config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createLogger } from "./logger"
|
||||
|
||||
const log = createLogger({ component: "opencode-config" })
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
const prodTemplateDirs = [
|
||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
||||
path.resolve(__dirname, "opencode-config"),
|
||||
].filter((dir): dir is string => Boolean(dir))
|
||||
|
||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
||||
const templateDir = isDevBuild
|
||||
? devTemplateDir
|
||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
||||
|
||||
export function getOpencodeConfigDir(): string {
|
||||
if (!existsSync(templateDir)) {
|
||||
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
|
||||
}
|
||||
|
||||
if (isDevBuild) {
|
||||
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
|
||||
}
|
||||
|
||||
return templateDir
|
||||
}
|
||||
55
packages/server/src/plugins/channel.ts
Normal file
55
packages/server/src/plugins/channel.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { FastifyReply } from "fastify"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
export interface PluginOutboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ClientConnection {
|
||||
reply: FastifyReply
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export class PluginChannelManager {
|
||||
private readonly clients = new Set<ClientConnection>()
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
register(workspaceId: string, reply: FastifyReply) {
|
||||
const connection: ClientConnection = { workspaceId, reply }
|
||||
this.clients.add(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
|
||||
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
this.clients.delete(connection)
|
||||
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
|
||||
}
|
||||
|
||||
return { close }
|
||||
}
|
||||
|
||||
send(workspaceId: string, event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
if (client.workspaceId !== workspaceId) continue
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(event: PluginOutboundEvent) {
|
||||
for (const client of this.clients) {
|
||||
this.write(client.reply, event)
|
||||
}
|
||||
}
|
||||
|
||||
private write(reply: FastifyReply, event: PluginOutboundEvent) {
|
||||
try {
|
||||
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
|
||||
}
|
||||
}
|
||||
}
|
||||
36
packages/server/src/plugins/handlers.ts
Normal file
36
packages/server/src/plugins/handlers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { EventBus } from "../events/bus"
|
||||
import type { WorkspaceManager } from "../workspaces/manager"
|
||||
import type { Logger } from "../logger"
|
||||
import type { PluginOutboundEvent } from "./channel"
|
||||
|
||||
export interface PluginInboundEvent {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface HandlerDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
|
||||
switch (event.type) {
|
||||
case "codenomad.pong":
|
||||
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
|
||||
return
|
||||
|
||||
default:
|
||||
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPingEvent(): PluginOutboundEvent {
|
||||
|
||||
return {
|
||||
type: "codenomad.ping",
|
||||
properties: {
|
||||
ts: Date.now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,11 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||
import { registerMetaRoutes } from "./routes/meta"
|
||||
import { registerEventRoutes } from "./routes/events"
|
||||
import { registerStorageRoutes } from "./routes/storage"
|
||||
import { registerPluginRoutes } from "./routes/plugin"
|
||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||
|
||||
interface HttpServerDeps {
|
||||
host: string
|
||||
@@ -100,6 +103,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
},
|
||||
})
|
||||
|
||||
const backgroundProcessManager = new BackgroundProcessManager({
|
||||
workspaceManager: deps.workspaceManager,
|
||||
eventBus: deps.eventBus,
|
||||
logger: deps.logger.child({ component: "background-processes" }),
|
||||
})
|
||||
|
||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
@@ -110,6 +119,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
|
||||
85
packages/server/src/server/routes/background-processes.ts
Normal file
85
packages/server/src/server/routes/background-processes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { BackgroundProcessManager } from "../../background-processes/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
backgroundProcessManager: BackgroundProcessManager
|
||||
}
|
||||
|
||||
const StartSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
command: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const OutputQuerySchema = z.object({
|
||||
method: z.enum(["full", "tail", "head", "grep"]).optional(),
|
||||
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
|
||||
pattern: z.string().optional(),
|
||||
lines: z.coerce.number().int().positive().max(2000).optional(),
|
||||
maxBytes: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
|
||||
const processes = await deps.backgroundProcessManager.list(request.params.id)
|
||||
return { processes }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
|
||||
const payload = StartSchema.parse(request.body ?? {})
|
||||
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
|
||||
reply.code(201)
|
||||
return process
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/stop",
|
||||
async (request, reply) => {
|
||||
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
|
||||
if (!process) {
|
||||
reply.code(404)
|
||||
return { error: "Process not found" }
|
||||
}
|
||||
return process
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/terminate",
|
||||
async (request, reply) => {
|
||||
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
|
||||
reply.code(204)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/output",
|
||||
async (request, reply) => {
|
||||
const query = OutputQuerySchema.parse(request.query ?? {})
|
||||
const method = query.method ?? query.mode
|
||||
if (method === "grep" && !query.pattern) {
|
||||
reply.code(400)
|
||||
return { error: "Pattern is required for grep output" }
|
||||
}
|
||||
try {
|
||||
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
|
||||
method,
|
||||
pattern: query.pattern,
|
||||
lines: query.lines,
|
||||
maxBytes: query.maxBytes,
|
||||
})
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Invalid output request" }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string; processId: string } }>(
|
||||
"/workspaces/:id/plugin/background-processes/:processId/stream",
|
||||
async (request, reply) => {
|
||||
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
|
||||
},
|
||||
)
|
||||
}
|
||||
75
packages/server/src/server/routes/plugin.ts
Normal file
75
packages/server/src/server/routes/plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { WorkspaceManager } from "../../workspaces/manager"
|
||||
import type { EventBus } from "../../events/bus"
|
||||
import type { Logger } from "../../logger"
|
||||
import { PluginChannelManager } from "../../plugins/channel"
|
||||
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
|
||||
|
||||
interface RouteDeps {
|
||||
workspaceManager: WorkspaceManager
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
const PluginEventSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
properties: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
||||
|
||||
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
|
||||
const workspace = deps.workspaceManager.get(request.params.id)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream")
|
||||
reply.raw.setHeader("Cache-Control", "no-cache")
|
||||
reply.raw.setHeader("Connection", "keep-alive")
|
||||
reply.raw.flushHeaders?.()
|
||||
reply.hijack()
|
||||
|
||||
const registration = channel.register(request.params.id, reply)
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
channel.send(request.params.id, buildPingEvent())
|
||||
}, 15000)
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat)
|
||||
registration.close()
|
||||
reply.raw.end?.()
|
||||
}
|
||||
|
||||
request.raw.on("close", close)
|
||||
request.raw.on("error", close)
|
||||
})
|
||||
|
||||
const handleWildcard = async (request: any, reply: any) => {
|
||||
const workspaceId = request.params.id as string
|
||||
const workspace = deps.workspaceManager.get(workspaceId)
|
||||
if (!workspace) {
|
||||
reply.code(404).send({ error: "Workspace not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const suffix = (request.params["*"] as string | undefined) ?? ""
|
||||
const normalized = suffix.replace(/^\/+/, "")
|
||||
|
||||
if (normalized === "event" && request.method === "POST") {
|
||||
const parsed = PluginEventSchema.parse(request.body ?? {})
|
||||
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
|
||||
reply.code(204).send()
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(404).send({ error: "Unknown plugin endpoint" })
|
||||
}
|
||||
|
||||
app.all("/workspaces/:id/plugin/*", handleWildcard)
|
||||
app.all("/workspaces/:id/plugin", handleWildcard)
|
||||
}
|
||||
@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
|
||||
app.post("/api/workspaces", async (request, reply) => {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(request.body ?? {})
|
||||
const workspace = await deps.workspaceManager.create(body.path, body.name)
|
||||
reply.code(201)
|
||||
return workspace
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Failed to create workspace")
|
||||
const message = error instanceof Error ? error.message : "Failed to create workspace"
|
||||
reply.code(400).type("text/plain").send(message)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { spawnSync } from "child_process"
|
||||
import { connect } from "net"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
@@ -7,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
import { getOpencodeConfigDir } from "../opencode-config.js"
|
||||
|
||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||
|
||||
interface WorkspaceManagerOptions {
|
||||
rootDir: string
|
||||
@@ -16,6 +20,7 @@ interface WorkspaceManagerOptions {
|
||||
binaryRegistry: BinaryRegistry
|
||||
eventBus: EventBus
|
||||
logger: Logger
|
||||
getServerBaseUrl: () => string
|
||||
}
|
||||
|
||||
interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
@@ -23,9 +28,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
|
||||
export class WorkspaceManager {
|
||||
private readonly workspaces = new Map<string, WorkspaceRecord>()
|
||||
private readonly runtime: WorkspaceRuntime
|
||||
private readonly opencodeConfigDir: string
|
||||
|
||||
constructor(private readonly options: WorkspaceManagerOptions) {
|
||||
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
|
||||
this.opencodeConfigDir = getOpencodeConfigDir()
|
||||
}
|
||||
|
||||
list(): WorkspaceDescriptor[] {
|
||||
@@ -97,10 +104,17 @@ export class WorkspaceManager {
|
||||
|
||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||
|
||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||
const preferences = this.options.configStore.get().preferences ?? {}
|
||||
const userEnvironment = preferences.environmentVariables ?? {}
|
||||
const environment = {
|
||||
...userEnvironment,
|
||||
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
|
||||
CODENOMAD_INSTANCE_ID: id,
|
||||
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
|
||||
}
|
||||
|
||||
try {
|
||||
const { pid, port } = await this.runtime.launch({
|
||||
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
||||
workspaceId: id,
|
||||
folder: workspacePath,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
@@ -108,6 +122,8 @@ export class WorkspaceManager {
|
||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||
})
|
||||
|
||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||
|
||||
descriptor.pid = pid
|
||||
descriptor.port = port
|
||||
descriptor.status = "ready"
|
||||
@@ -233,6 +249,161 @@ export class WorkspaceManager {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async waitForWorkspaceReadiness(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
|
||||
await Promise.race([
|
||||
this.waitForPortAvailability(params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited before becoming ready",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited shortly after start",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
private async waitForInstanceHealth(params: {
|
||||
workspaceId: string
|
||||
port: number
|
||||
exitPromise: Promise<ProcessExitInfo>
|
||||
getLastOutput: () => string
|
||||
}) {
|
||||
const probeResult = await Promise.race([
|
||||
this.probeInstance(params.workspaceId, params.port),
|
||||
params.exitPromise.then((info) => {
|
||||
throw this.buildStartupError(
|
||||
params.workspaceId,
|
||||
"exited during health checks",
|
||||
info,
|
||||
params.getLastOutput(),
|
||||
)
|
||||
}),
|
||||
])
|
||||
|
||||
if (probeResult.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestOutput = params.getLastOutput().trim()
|
||||
if (latestOutput) {
|
||||
throw new Error(latestOutput)
|
||||
}
|
||||
const reason = probeResult.reason ?? "Health check failed"
|
||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||
}
|
||||
|
||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
||||
const url = `http://127.0.0.1:${port}/project/current`
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const reason = `health probe returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
|
||||
private buildStartupError(
|
||||
workspaceId: string,
|
||||
phase: string,
|
||||
exitInfo: ProcessExitInfo,
|
||||
lastOutput: string,
|
||||
): Error {
|
||||
const exitDetails = this.describeExit(exitInfo)
|
||||
const trimmedOutput = lastOutput.trim()
|
||||
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
|
||||
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
|
||||
}
|
||||
|
||||
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let settled = false
|
||||
let retryTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
settled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const tryConnect = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolve()
|
||||
})
|
||||
socket.once("error", () => {
|
||||
socket.destroy()
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
cleanup()
|
||||
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
|
||||
} else {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
tryConnect()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
|
||||
private delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, durationMs))
|
||||
}
|
||||
|
||||
private describeExit(info: ProcessExitInfo): string {
|
||||
if (info.signal) {
|
||||
return `signal ${info.signal}`
|
||||
}
|
||||
if (info.code !== null) {
|
||||
return `code ${info.code}`
|
||||
}
|
||||
return "unknown reason"
|
||||
}
|
||||
|
||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||
const workspace = this.workspaces.get(workspaceId)
|
||||
if (!workspace) return
|
||||
|
||||
@@ -13,7 +13,7 @@ interface LaunchOptions {
|
||||
onExit?: (info: ProcessExitInfo) => void
|
||||
}
|
||||
|
||||
interface ProcessExitInfo {
|
||||
export interface ProcessExitInfo {
|
||||
workspaceId: string
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
@@ -30,15 +30,45 @@ export class WorkspaceRuntime {
|
||||
|
||||
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
|
||||
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
|
||||
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
|
||||
this.validateFolder(options.folder)
|
||||
|
||||
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||
|
||||
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
|
||||
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
|
||||
exitResolve = resolveExit
|
||||
})
|
||||
|
||||
// Store recent output for debugging - keep last 50 lines from each stream
|
||||
const MAX_OUTPUT_LINES = 50
|
||||
const recentStdout: string[] = []
|
||||
const recentStderr: string[] = []
|
||||
const getLastOutput = () => {
|
||||
const combined: string[] = []
|
||||
if (recentStderr.length > 0) {
|
||||
combined.push("Error Stream")
|
||||
combined.push(...recentStderr.slice(-10))
|
||||
}
|
||||
if (recentStdout.length > 0) {
|
||||
combined.push("Output Stream")
|
||||
combined.push(...recentStdout.slice(-10))
|
||||
}
|
||||
return combined.join("\n")
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
||||
this.logger.info(
|
||||
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
args,
|
||||
commandLine,
|
||||
env,
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
@@ -83,11 +113,22 @@ export class WorkspaceRuntime {
|
||||
cleanupStreams()
|
||||
child.removeListener("error", handleError)
|
||||
child.removeListener("exit", handleExit)
|
||||
const exitInfo: ProcessExitInfo = {
|
||||
workspaceId: options.workspaceId,
|
||||
code,
|
||||
signal,
|
||||
requested: managed.requestedStop,
|
||||
}
|
||||
if (exitResolve) {
|
||||
exitResolve(exitInfo)
|
||||
exitResolve = null
|
||||
}
|
||||
if (!portFound) {
|
||||
const reason = stderrBuffer || `Process exited with code ${code}`
|
||||
const recentOutput = getLastOutput().trim()
|
||||
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(reason))
|
||||
} else {
|
||||
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
|
||||
options.onExit?.(exitInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +137,10 @@ export class WorkspaceRuntime {
|
||||
child.removeListener("exit", handleExit)
|
||||
this.processes.delete(options.workspaceId)
|
||||
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
|
||||
if (exitResolve) {
|
||||
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
|
||||
exitResolve = null
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
|
||||
@@ -109,18 +154,25 @@ export class WorkspaceRuntime {
|
||||
stdoutBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "info", line)
|
||||
|
||||
if (!portFound) {
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
|
||||
if (portMatch) {
|
||||
portFound = true
|
||||
cleanupStreams()
|
||||
stopWarningTimer()
|
||||
child.removeListener("error", handleError)
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
|
||||
resolve({ pid: child.pid!, port })
|
||||
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +185,14 @@ export class WorkspaceRuntime {
|
||||
stderrBuffer = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
recentStderr.push(trimmed)
|
||||
if (recentStderr.length > MAX_OUTPUT_LINES) {
|
||||
recentStderr.shift()
|
||||
}
|
||||
|
||||
this.emitLog(options.workspaceId, "error", line)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||
|
||||
@@ -622,6 +622,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
||||
|
||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||
for root in linux_resource_roots {
|
||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/index.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("server/dist/server/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,8 +12,12 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "^1.0.133",
|
||||
"@opencode-ai/sdk": "1.1.1",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"@suid/icons-material": "^0.9.0",
|
||||
"@suid/material": "^0.19.0",
|
||||
"@suid/system": "^0.14.0",
|
||||
"ansi-sequence-parser": "^1.1.3",
|
||||
"debug": "^4.4.3",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"lucide-solid": "^0.300.0",
|
||||
|
||||
@@ -6,9 +6,11 @@ import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
import InstanceTabs from "./components/instance-tabs"
|
||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||
import InstanceShell from "./components/instance/instance-shell"
|
||||
import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
setHasInstances,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
} from "./stores/ui"
|
||||
@@ -62,9 +63,21 @@ const App: Component = () => {
|
||||
setThinkingBlocksExpansion,
|
||||
} = useConfig()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
interface LaunchErrorState {
|
||||
message: string
|
||||
binaryPath: string
|
||||
missingBinary: boolean
|
||||
}
|
||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
@@ -74,6 +87,19 @@ const App: Component = () => {
|
||||
initReleaseNotifications()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
instances()
|
||||
hasInstances()
|
||||
requestAnimationFrame(() => updateInstanceTabBarHeight())
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
@@ -82,14 +108,30 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchErrorBinary()
|
||||
const value = launchError()?.binaryPath
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const isMissingBinaryError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||
|
||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
||||
if (!error) {
|
||||
return "Failed to launch workspace"
|
||||
}
|
||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed.error === "string") {
|
||||
return parsed.error
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const isMissingBinaryMessage = (message: string): boolean => {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
@@ -100,7 +142,7 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||
const clearLaunchError = () => setLaunchError(null)
|
||||
|
||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||
if (!folderPath) {
|
||||
@@ -112,7 +154,6 @@ const App: Component = () => {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
@@ -121,10 +162,13 @@ const App: Component = () => {
|
||||
port: instances().get(instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
const message = formatLaunchErrorMessage(error)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
setLaunchError({
|
||||
message,
|
||||
binaryPath: selectedBinary,
|
||||
missingBinary,
|
||||
})
|
||||
log.error("Failed to create instance", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
@@ -168,9 +212,6 @@ const App: Component = () => {
|
||||
if (!confirmed) return
|
||||
|
||||
await stopInstance(instanceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession(instanceId: string) {
|
||||
@@ -281,7 +322,7 @@ const App: Component = () => {
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
||||
<Dialog open={Boolean(launchError())} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
@@ -289,8 +330,8 @@ const App: Component = () => {
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||
Advanced Settings.
|
||||
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
|
||||
binary from Advanced Settings.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
@@ -299,10 +340,23 @@ const App: Component = () => {
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<Show when={launchErrorMessage()}>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
|
||||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<Show when={launchError()?.missingBinary}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
</Show>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
</button>
|
||||
@@ -328,20 +382,26 @@ const App: Component = () => {
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||
return (
|
||||
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
||||
<InstanceMetadataProvider instance={instance}>
|
||||
<InstanceShell
|
||||
instance={instance}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
paletteCommands={paletteCommands}
|
||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||
onNewSession={() => handleNewSession(instance.id)}
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
}}
|
||||
</For>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
|
||||
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||
<Select.Listbox class="selector-listbox" />
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
|
||||
167
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
167
packages/ui/src/components/background-process-output-dialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
|
||||
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
|
||||
interface BackgroundProcessOutputDialogProps {
|
||||
open: boolean
|
||||
instanceId: string
|
||||
process: BackgroundProcess | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
|
||||
const [output, setOutput] = createSignal("")
|
||||
const [outputHtml, setOutputHtml] = createSignal("")
|
||||
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
let ansiRenderer = createAnsiStreamRenderer()
|
||||
|
||||
createEffect(() => {
|
||||
const process = props.process
|
||||
if (!props.open || !process) {
|
||||
return
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let active = true
|
||||
|
||||
let rawOutput = ""
|
||||
|
||||
const setRawOutput = (next: string) => {
|
||||
rawOutput = next
|
||||
setOutput(next)
|
||||
}
|
||||
|
||||
const appendRawOutput = (chunk: string) => {
|
||||
rawOutput += chunk
|
||||
setOutput(rawOutput)
|
||||
}
|
||||
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
setRawOutput("")
|
||||
ansiRenderer.reset()
|
||||
|
||||
setLoading(true)
|
||||
serverApi
|
||||
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
|
||||
.then((response) => {
|
||||
if (!active) return
|
||||
|
||||
setRawOutput(response.content)
|
||||
setTruncated(response.truncated)
|
||||
|
||||
const detectedAnsi = hasAnsi(response.content)
|
||||
if (detectedAnsi) {
|
||||
setAnsiEnabled(true)
|
||||
ansiRenderer.reset()
|
||||
setOutputHtml(ansiRenderer.render(response.content))
|
||||
} else {
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
ansiRenderer.reset()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setRawOutput("Failed to load output.")
|
||||
setAnsiEnabled(false)
|
||||
setOutputHtml("")
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { type?: string; content?: string }
|
||||
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const chunk = payload.content
|
||||
const wasAnsiEnabled = ansiEnabled()
|
||||
|
||||
if (!wasAnsiEnabled) {
|
||||
appendRawOutput(chunk)
|
||||
|
||||
if (hasAnsi(chunk)) {
|
||||
setAnsiEnabled(true)
|
||||
ansiRenderer.reset()
|
||||
setOutputHtml(ansiRenderer.render(rawOutput))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
appendRawOutput(chunk)
|
||||
const htmlChunk = ansiRenderer.render(chunk)
|
||||
setOutputHtml((prev) => `${prev}${htmlChunk}`)
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
eventSource?.close()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
|
||||
<Show when={props.process}>
|
||||
<span class="text-xs text-secondary block">
|
||||
{props.process?.title} · {props.process?.id}
|
||||
</span>
|
||||
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
|
||||
{props.process?.command}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<Show when={loading()}>
|
||||
<p class="text-xs text-secondary">Loading output...</p>
|
||||
</Show>
|
||||
<Show when={!loading()}>
|
||||
<Show when={truncated()}>
|
||||
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={ansiEnabled()}
|
||||
fallback={
|
||||
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
|
||||
{output()}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<pre
|
||||
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
|
||||
innerHTML={outputHtml()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const success = await copyToClipboard(props.code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -224,11 +224,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
|
||||
@@ -1,134 +1,26 @@
|
||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ParsedMcpStatus = {
|
||||
name: string
|
||||
status: "running" | "stopped" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
|
||||
if (!status || typeof status !== "object") return []
|
||||
|
||||
const result: ParsedMcpStatus[] = []
|
||||
|
||||
for (const [name, value] of Object.entries(status)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const rawStatus = (value as { status?: string }).status
|
||||
if (!rawStatus) continue
|
||||
|
||||
let mappedStatus: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") {
|
||||
mappedStatus = "running"
|
||||
} else if (rawStatus === "failed") {
|
||||
mappedStatus = "error"
|
||||
} else {
|
||||
mappedStatus = "stopped"
|
||||
}
|
||||
|
||||
result.push({
|
||||
name,
|
||||
status: mappedStatus,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const pendingMetadataRequests = new Set<string>()
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const metadata = () => props.instance.metadata
|
||||
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
|
||||
const mcpServers = () => {
|
||||
const status = metadata()?.mcpStatus
|
||||
return status ? parseMcpStatus(status) : []
|
||||
}
|
||||
const lspServers = () => metadata()?.lspStatus ?? []
|
||||
|
||||
createEffect(() => {
|
||||
const instance = props.instance
|
||||
const instanceId = instance.id
|
||||
const client = instance.client
|
||||
const hasMetadata = Boolean(instance.metadata)
|
||||
|
||||
if (!client) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMetadata) {
|
||||
setIsLoadingMetadata(false)
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingMetadataRequests.has(instanceId)) {
|
||||
setIsLoadingMetadata(true)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
pendingMetadataRequests.add(instanceId)
|
||||
setIsLoadingMetadata(true)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
||||
client.project.current(),
|
||||
client.mcp.status(),
|
||||
fetchLspStatus(instanceId),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
||||
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
||||
|
||||
const nextMetadata = {
|
||||
...(instance.metadata ?? {}),
|
||||
...(project ? { project } : {}),
|
||||
...(mcpStatus ? { mcpStatus } : {}),
|
||||
...(lspStatus ? { lspStatus } : {}),
|
||||
}
|
||||
|
||||
if (!nextMetadata.version && instance.binaryVersion) {
|
||||
nextMetadata.version = instance.binaryVersion
|
||||
}
|
||||
|
||||
updateInstance(instanceId, { metadata: nextMetadata })
|
||||
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
log.error("Failed to load instance metadata", error)
|
||||
}
|
||||
} finally {
|
||||
pendingMetadataRequests.delete(instanceId)
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetadata(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
const environmentVariables = () => currentInstance().environmentVariables
|
||||
const environmentEntries = createMemo(() => {
|
||||
const env = environmentVariables()
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -140,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{props.instance.folder}
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.binaryPath}>
|
||||
<Show when={currentInstance().binaryPath}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
Binary Path
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{props.instance.binaryPath}
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
|
||||
<Show when={environmentEntries().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
|
||||
Environment Variables ({environmentEntries().length})
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<For each={Object.entries(props.instance.environmentVariables!)}>
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
@@ -222,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
LSP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
MCP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div
|
||||
class={`status-dot ${
|
||||
server.status === "running"
|
||||
? "ready animate-pulse"
|
||||
: server.status === "error"
|
||||
? "error"
|
||||
: "stopped"
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{
|
||||
server.status === "running"
|
||||
? "Connected"
|
||||
: server.status === "error"
|
||||
? "Error"
|
||||
: "Disabled"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={server.error}>
|
||||
{(error) => (
|
||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||
|
||||
<Show when={isLoadingMetadata()}>
|
||||
<div class="text-xs text-muted py-1">
|
||||
@@ -317,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1 text-xs">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Port:</span>
|
||||
<span class="text-primary font-mono">{props.instance.port}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().port}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">PID:</span>
|
||||
<span class="text-primary font-mono">{props.instance.pid}</span>
|
||||
<span class="text-primary font-mono">{currentInstance().pid}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-secondary">Status:</span>
|
||||
<span
|
||||
class={`status-badge ${props.instance.status}`}
|
||||
>
|
||||
<span class={`status-badge ${currentInstance().status}`}>
|
||||
<div
|
||||
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
|
||||
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{props.instance.status}
|
||||
{currentInstance().status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
254
packages/ui/src/components/instance-service-status.tsx
Normal file
254
packages/ui/src/components/instance-service-status.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import Switch from "@suid/material/Switch"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ServiceSection = "lsp" | "mcp" | "plugins"
|
||||
|
||||
interface InstanceServiceStatusProps {
|
||||
sections?: ServiceSection[]
|
||||
showSectionHeadings?: boolean
|
||||
class?: string
|
||||
initialInstance?: Instance
|
||||
}
|
||||
|
||||
type ParsedMcpStatus = {
|
||||
name: string
|
||||
status: "running" | "stopped" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
|
||||
if (!status || typeof status !== "object") return []
|
||||
const result: ParsedMcpStatus[] = []
|
||||
for (const [name, value] of Object.entries(status)) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const rawStatus = (value as { status?: string }).status
|
||||
if (!rawStatus) continue
|
||||
let mapped: ParsedMcpStatus["status"]
|
||||
if (rawStatus === "connected") mapped = "running"
|
||||
else if (rawStatus === "failed") mapped = "error"
|
||||
else mapped = "stopped"
|
||||
result.push({
|
||||
name,
|
||||
status: mapped,
|
||||
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const instance = metadataContext?.instance ?? (() => {
|
||||
if (props.initialInstance) {
|
||||
return props.initialInstance
|
||||
}
|
||||
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
|
||||
})
|
||||
const isLoading = metadataContext?.isLoading ?? (() => false)
|
||||
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
|
||||
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp", "plugins"])
|
||||
const includeLsp = createMemo(() => sections().includes("lsp"))
|
||||
const includeMcp = createMemo(() => sections().includes("mcp"))
|
||||
const includePlugins = createMemo(() => sections().includes("plugins"))
|
||||
const showHeadings = () => props.showSectionHeadings !== false
|
||||
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
|
||||
const metadata = createMemo(() => metadataAccessor())
|
||||
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
|
||||
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
|
||||
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
|
||||
|
||||
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
|
||||
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
|
||||
const plugins = createMemo(() => metadata()?.plugins ?? [])
|
||||
|
||||
const isLspLoading = () => isLoading() || !hasLspMetadata()
|
||||
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
|
||||
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
|
||||
|
||||
|
||||
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
|
||||
|
||||
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
|
||||
setPendingMcpActions((prev) => {
|
||||
const next = { ...prev }
|
||||
if (action) next[name] = action
|
||||
else delete next[name]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
|
||||
const client = instance().client
|
||||
if (!client?.mcp) return
|
||||
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
|
||||
setPendingMcpAction(serverName, action)
|
||||
try {
|
||||
if (shouldEnable) {
|
||||
await client.mcp.connect({ name: serverName })
|
||||
} else {
|
||||
await client.mcp.disconnect({ name: serverName })
|
||||
}
|
||||
await refreshMetadata()
|
||||
} catch (error) {
|
||||
log.error("Failed to toggle MCP server", { serverName, action, error })
|
||||
} finally {
|
||||
setPendingMcpAction(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEmptyState = (message: string) => (
|
||||
<p class="text-[11px] text-secondary italic" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
|
||||
const renderLspSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
LSP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isLspLoading() && lspServers().length > 0}
|
||||
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
const renderMcpSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
MCP Servers
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isMcpLoading() && mcpServers().length > 0}
|
||||
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={mcpServers()}>
|
||||
{(server) => {
|
||||
const pendingAction = () => pendingMcpActions()[server.name]
|
||||
const isPending = () => Boolean(pendingAction())
|
||||
const isRunning = () => server.status === "running"
|
||||
const switchDisabled = () => isPending() || !instance().client
|
||||
const statusDotClass = () => {
|
||||
if (isPending()) return "status-dot animate-pulse"
|
||||
if (server.status === "running") return "status-dot ready animate-pulse"
|
||||
if (server.status === "error") return "status-dot error"
|
||||
return "status-dot stopped"
|
||||
}
|
||||
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
|
||||
return (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<Show when={isPending()}>
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<div class={statusDotClass()} style={statusDotStyle()} />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Switch
|
||||
checked={isRunning()}
|
||||
disabled={switchDisabled()}
|
||||
color="success"
|
||||
size="small"
|
||||
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
|
||||
onChange={(_, checked) => {
|
||||
if (switchDisabled()) return
|
||||
void toggleMcpServer(server.name, Boolean(checked))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<Show when={server.error}>
|
||||
{(error) => (
|
||||
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
const renderPluginsSection = () => (
|
||||
<section class="space-y-1.5">
|
||||
<Show when={showHeadings()}>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
Plugins
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!isPluginsLoading() && plugins().length > 0}
|
||||
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="text-xs text-primary font-medium break-words whitespace-normal">{plugin}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<Show when={includeLsp()}>{renderLspSection()}</Show>
|
||||
<Show when={includeMcp()}>{renderMcpSection()}</Show>
|
||||
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceServiceStatus
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { FolderOpen, X } from "lucide-solid"
|
||||
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
|
||||
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
|
||||
|
||||
interface InstanceTabProps {
|
||||
instance: Instance
|
||||
@@ -26,6 +27,24 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
|
||||
}
|
||||
|
||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
|
||||
const statusClassName = createMemo(() => {
|
||||
const status = aggregatedStatus()
|
||||
return status === "permission" ? "session-permission" : `session-${status}`
|
||||
})
|
||||
const statusTitle = createMemo(() => {
|
||||
switch (aggregatedStatus()) {
|
||||
case "permission":
|
||||
return "Waiting on permission"
|
||||
case "compacting":
|
||||
return "Compacting"
|
||||
case "working":
|
||||
return "Working"
|
||||
default:
|
||||
return "Idle"
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="group">
|
||||
<button
|
||||
@@ -40,7 +59,18 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close ml-auto"
|
||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||
title={statusTitle()}
|
||||
aria-label={`Instance status: ${statusTitle()}`}
|
||||
>
|
||||
{aggregatedStatus() === "permission" ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
class="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { Loader2, Trash2 } from "lucide-solid"
|
||||
import { Loader2, Pencil, Trash2 } from "lucide-solid"
|
||||
|
||||
import type { Instance } from "../types/instance"
|
||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading } from "../stores/sessions"
|
||||
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
|
||||
import { isMac } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -24,6 +26,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
|
||||
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
|
||||
)
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
|
||||
const parentSessions = () => getParentSessions(props.instance.id)
|
||||
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
|
||||
@@ -74,6 +78,25 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
let activeElement: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
activeElement = document.activeElement as HTMLElement | null
|
||||
}
|
||||
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||
const isEditingField =
|
||||
activeElement &&
|
||||
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
|
||||
activeElement.isContentEditable ||
|
||||
Boolean(insideModal))
|
||||
|
||||
if (isEditingField) {
|
||||
if (insideModal && e.key === "Escape" && renameTarget()) {
|
||||
e.preventDefault()
|
||||
closeRenameDialog()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showInstanceInfoOverlay()) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
@@ -81,53 +104,67 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const sessions = parentSessions()
|
||||
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||
e.preventDefault()
|
||||
handleNewSession()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
|
||||
const listFocused = focusMode() === "sessions"
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
if (!listFocused) {
|
||||
setFocusMode("sessions")
|
||||
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
|
||||
}
|
||||
e.preventDefault()
|
||||
const newIndex = Math.max(selectedIndex() - 1, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageDown") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!listFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "PageDown") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "PageUp") {
|
||||
e.preventDefault()
|
||||
const pageSize = 5
|
||||
const newIndex = Math.max(selectedIndex() - pageSize, 0)
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(0)
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault()
|
||||
const newIndex = sessions.length - 1
|
||||
setSelectedIndex(newIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(newIndex)
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
@@ -138,6 +175,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleEnterKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
@@ -234,6 +272,31 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
function openRenameDialogForSession(sessionId: string, title: string) {
|
||||
const label = title && title.trim() ? title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: title ?? "", label })
|
||||
}
|
||||
|
||||
function closeRenameDialog() {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
|
||||
async function handleRenameSubmit(nextTitle: string) {
|
||||
const target = renameTarget()
|
||||
if (!target) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await renameSession(props.instance.id, target.id, nextTitle)
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error("Failed to rename session:", error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
if (isCreating()) return
|
||||
|
||||
@@ -251,8 +314,8 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
|
||||
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
|
||||
<Show
|
||||
when={parentSessions().length > 0}
|
||||
fallback={
|
||||
@@ -336,7 +399,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-primary truncate transition-colors"
|
||||
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
|
||||
classList={{
|
||||
"text-accent": isFocused(),
|
||||
}}
|
||||
@@ -355,6 +418,18 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<Show when={isFocused()}>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<kbd class="kbd flex-shrink-0">↵</kbd>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
title="Rename session"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openRenameDialogForSession(session.id, session.title || "")
|
||||
}}
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
@@ -431,7 +506,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block lg:w-80 flex-shrink-0">
|
||||
<div class="sticky top-0">
|
||||
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
|
||||
<InstanceInfo instance={props.instance} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,10 +563,17 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SessionRenameDialog
|
||||
open={Boolean(renameTarget())}
|
||||
currentTitle={renameTarget()?.title ?? ""}
|
||||
sessionLabel={renameTarget()?.label}
|
||||
isSubmitting={isRenaming()}
|
||||
onRename={handleRenameSubmit}
|
||||
onClose={closeRenameDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
|
||||
export default InstanceWelcomeView
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import { clearSessionRenderCache } from "../message-block"
|
||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import Kbd from "../kbd"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
escapeInDebounce: boolean
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
onNewSession: () => Promise<void> | void
|
||||
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
||||
const SESSION_CACHE_LIMIT = 2
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
||||
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
||||
const sidebarId = `session-sidebar-${props.instance.id}`
|
||||
let previousIsCompact = false
|
||||
|
||||
const shouldShowSidebarToggle = () => isCompactLayout() && !isSidebarOpen()
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const handleResize = () => {
|
||||
const compact = window.innerWidth < MOBILE_SIDEBAR_BREAKPOINT
|
||||
setIsCompactLayout(compact)
|
||||
if (!compact) {
|
||||
setIsSidebarOpen(true)
|
||||
} else if (!previousIsCompact && compact) {
|
||||
setIsSidebarOpen(false)
|
||||
}
|
||||
previousIsCompact = compact
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
const sessionFamily = getSessionFamily(props.instance.id, parentId)
|
||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||
})
|
||||
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
return activeSessionMap().get(props.instance.id) || null
|
||||
})
|
||||
|
||||
const parentSessionIdForInstance = createMemo(() => {
|
||||
return activeParentSessionId().get(props.instance.id) || null
|
||||
})
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return activeSessions().get(sessionId) ?? null
|
||||
})
|
||||
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||
|
||||
const keyboardShortcuts = createMemo(() =>
|
||||
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
||||
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
||||
),
|
||||
)
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
const evictSession = (sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
log.info("Evicting cached session", { instanceId: props.instance.id, sessionId })
|
||||
const store = messageStoreBus.getInstance(props.instance.id)
|
||||
store?.clearSession(sessionId)
|
||||
clearSessionRenderCache(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
const scheduleEvictions = (ids: string[]) => {
|
||||
if (!ids.length) return
|
||||
setPendingEvictions((current) => {
|
||||
const existing = new Set(current)
|
||||
const next = [...current]
|
||||
ids.forEach((id) => {
|
||||
if (!existing.has(id)) {
|
||||
next.push(id)
|
||||
existing.add(id)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingEvictions()
|
||||
if (!pending.length) return
|
||||
const cached = new Set(cachedSessionIds())
|
||||
const remaining: string[] = []
|
||||
pending.forEach((id) => {
|
||||
if (cached.has(id)) {
|
||||
remaining.push(id)
|
||||
} else {
|
||||
evictSession(id)
|
||||
}
|
||||
})
|
||||
if (remaining.length !== pending.length) {
|
||||
setPendingEvictions(remaining)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionsMap = activeSessions()
|
||||
const parentId = parentSessionIdForInstance()
|
||||
const activeId = activeSessionIdForInstance()
|
||||
setCachedSessionIds((current) => {
|
||||
const next: string[] = []
|
||||
const append = (id: string | null) => {
|
||||
if (!id || id === "info") return
|
||||
if (!sessionsMap.has(id)) return
|
||||
if (next.includes(id)) return
|
||||
next.push(id)
|
||||
}
|
||||
|
||||
append(parentId)
|
||||
append(activeId)
|
||||
current.forEach((id) => append(id))
|
||||
|
||||
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
||||
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
||||
const trimmedSet = new Set(trimmed)
|
||||
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||
if (removed.length) {
|
||||
scheduleEvictions(removed)
|
||||
}
|
||||
return trimmed
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div
|
||||
class="flex flex-1 min-h-0 relative"
|
||||
classList={{ "session-layout-compact": isCompactLayout() }}
|
||||
>
|
||||
<div
|
||||
id={sidebarId}
|
||||
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||
classList={{
|
||||
"session-sidebar-overlay": isCompactLayout(),
|
||||
"session-sidebar-collapsed": isCompactLayout() && !isSidebarOpen(),
|
||||
}}
|
||||
style={!isCompactLayout() ? { width: `${sessionSidebarWidth()}px` } : undefined}
|
||||
aria-hidden={isCompactLayout() && !isSidebarOpen()}
|
||||
>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => log.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<div class="session-sidebar-header-row">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<Show when={isCompactLayout()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-close"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
aria-label="Close session sidebar"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={shouldShowSidebarToggle() && (!activeSessionIdForInstance() || activeSessionIdForInstance() === "info")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-menu-button session-sidebar-menu-button--floating"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
aria-controls={sidebarId}
|
||||
aria-expanded={isSidebarOpen()}
|
||||
aria-label="Open session list"
|
||||
>
|
||||
<span aria-hidden="true" class="session-sidebar-menu-icon">☰</span>
|
||||
</button>
|
||||
</Show>
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
<Show
|
||||
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={cachedSessionIds()}>
|
||||
{(sessionId) => {
|
||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||
return (
|
||||
<div
|
||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||
style={{ display: isActive() ? "flex" : "none" }}
|
||||
data-session-id={sessionId}
|
||||
aria-hidden={!isActive()}
|
||||
>
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
showSidebarToggle={shouldShowSidebarToggle()}
|
||||
onSidebarToggle={() => setIsSidebarOpen(true)}
|
||||
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
||||
isActive={isActive()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isCompactLayout() && isSidebarOpen()}>
|
||||
<button
|
||||
type="button"
|
||||
class="session-sidebar-backdrop"
|
||||
aria-label="Close session sidebar"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen()}
|
||||
onClose={() => hideCommandPalette(props.instance.id)}
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={props.onExecuteCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceShell
|
||||
1436
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
1436
packages/ui/src/components/instance/instance-shell2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,32 @@
|
||||
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart } from "../types/message"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
function hashText(value: string): string {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
|
||||
function resolvePartVersion(part: TextPart, text: string): string {
|
||||
if (typeof part.version === "number") {
|
||||
return String(part.version)
|
||||
}
|
||||
return `text-${hashText(text)}`
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
part: TextPart
|
||||
instanceId?: string
|
||||
sessionId?: string
|
||||
isDark?: boolean
|
||||
size?: "base" | "sm" | "tight"
|
||||
disableHighlight?: boolean
|
||||
@@ -22,18 +42,63 @@ export function Markdown(props: MarkdownProps) {
|
||||
Promise.resolve().then(() => props.onRendered?.())
|
||||
}
|
||||
|
||||
createEffect(async () => {
|
||||
const resolved = createMemo(() => {
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const dark = Boolean(props.isDark)
|
||||
const themeKey = dark ? "dark" : "light"
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
const highlightEnabled = !props.disableHighlight
|
||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||
if (!partId) {
|
||||
throw new Error("Markdown rendering requires a part id")
|
||||
}
|
||||
const version = resolvePartVersion(part, text)
|
||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
||||
})
|
||||
|
||||
const cacheHandle = useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: "markdown",
|
||||
cacheId: () => {
|
||||
const { partId, themeKey, highlightEnabled } = resolved()
|
||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||
},
|
||||
version: () => resolved().version,
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
||||
|
||||
latestRequestedText = text
|
||||
|
||||
// Markdown initialization is now handled globally in App.
|
||||
// initMarkdown is idempotent but we avoid per-part calls here.
|
||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||
if (!cache) return false
|
||||
return cache.theme === themeKey && cache.mode === version
|
||||
}
|
||||
|
||||
const localCache = part.renderCache
|
||||
if (localCache && cacheMatches(localCache)) {
|
||||
setHtml(localCache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
const globalCache = cacheHandle.get<RenderCache>()
|
||||
if (globalCache && cacheMatches(globalCache)) {
|
||||
setHtml(globalCache.html)
|
||||
part.renderCache = globalCache
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
const commitCacheEntry = (renderedHtml: string) => {
|
||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||
setHtml(renderedHtml)
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
|
||||
if (!highlightEnabled) {
|
||||
part.renderCache = undefined
|
||||
@@ -42,40 +107,26 @@ export function Markdown(props: MarkdownProps) {
|
||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cache = part.renderCache
|
||||
if (cache && cache.text === text && cache.theme === themeKey) {
|
||||
setHtml(cache.html)
|
||||
notifyRendered()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(rendered)
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
notifyRendered()
|
||||
commitCacheEntry(rendered)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to render markdown:", error)
|
||||
if (latestRequestedText === text) {
|
||||
setHtml(text)
|
||||
part.renderCache = { text, html: text, theme: themeKey }
|
||||
notifyRendered()
|
||||
commitCacheEntry(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -90,13 +141,20 @@ export function Markdown(props: MarkdownProps) {
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
await navigator.clipboard.writeText(decodedCode)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
if (success) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = "Failed"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
|
||||
|
||||
containerRef?.addEventListener("click", handleClick)
|
||||
|
||||
// Register listener for language loading completion
|
||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
||||
if (props.disableHighlight) {
|
||||
return
|
||||
}
|
||||
|
||||
const part = props.part
|
||||
const rawText = typeof part.text === "string" ? part.text : ""
|
||||
const text = decodeHtmlEntities(rawText)
|
||||
const { part, text, themeKey, version } = resolved()
|
||||
|
||||
if (latestRequestedText !== text) {
|
||||
return
|
||||
@@ -121,9 +176,10 @@ export function Markdown(props: MarkdownProps) {
|
||||
try {
|
||||
const rendered = await renderMarkdown(text)
|
||||
if (latestRequestedText === text) {
|
||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||
setHtml(rendered)
|
||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||
part.renderCache = { text, html: rendered, theme: themeKey }
|
||||
part.renderCache = cacheEntry
|
||||
cacheHandle.set(cacheEntry)
|
||||
notifyRendered()
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Index, createEffect, createSignal, type Accessor } from "solid-js"
|
||||
import { Index, type Accessor } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -10,12 +10,10 @@ export function getMessageAnchorId(messageId: string) {
|
||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||
|
||||
interface MessageBlockListProps {
|
||||
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIds: () => string[]
|
||||
messageIndexMap: () => Map<string, number>
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
@@ -27,62 +25,38 @@ interface MessageBlockListProps {
|
||||
onContentRendered?: () => void
|
||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||
suspendMeasurements?: () => boolean
|
||||
onInitialRenderComplete?: () => void
|
||||
}
|
||||
|
||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||
const totalMessages = () => props.messageIds().length
|
||||
let renderedCount = 0
|
||||
let initialRenderReported = false
|
||||
const handleBlockRendered = () => {
|
||||
if (initialRenderReported) return
|
||||
renderedCount += 1
|
||||
if (renderedCount >= totalMessages() && totalMessages() > 0) {
|
||||
initialRenderReported = true
|
||||
renderedCount = 0
|
||||
props.onInitialRenderComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) {
|
||||
renderedCount = 0
|
||||
initialRenderReported = false
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Index each={props.messageIds()}>
|
||||
{(messageId) => {
|
||||
return (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
onMeasured={handleBlockRendered}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndexMap={props.messageIndexMap}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)
|
||||
}}
|
||||
{(messageId, index) => (
|
||||
<VirtualItem
|
||||
id={getMessageAnchorId(messageId())}
|
||||
cacheKey={messageId()}
|
||||
scrollContainer={props.scrollContainer}
|
||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={() => !props.loading}
|
||||
suspendMeasurements={props.suspendMeasurements}
|
||||
>
|
||||
<MessageBlock
|
||||
messageId={messageId()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndex={index}
|
||||
lastAssistantIndex={props.lastAssistantIndex}
|
||||
showThinking={props.showThinking}
|
||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
||||
showUsageMetrics={props.showUsageMetrics}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</VirtualItem>
|
||||
)}
|
||||
</Index>
|
||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { FoldVertical } from "lucide-solid"
|
||||
import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
@@ -192,7 +193,15 @@ type ReasoningDisplayItem = {
|
||||
defaultExpanded: boolean
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
|
||||
type CompactionDisplayItem = {
|
||||
type: "compaction"
|
||||
key: string
|
||||
part: ClientPart
|
||||
messageInfo?: MessageInfo
|
||||
accentColor?: string
|
||||
}
|
||||
|
||||
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
|
||||
|
||||
interface MessageDisplayBlock {
|
||||
record: MessageRecord
|
||||
@@ -204,7 +213,7 @@ interface MessageBlockProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
store: () => InstanceMessageStore
|
||||
messageIndexMap: () => Map<string, number>
|
||||
messageIndex: number
|
||||
lastAssistantIndex: () => number
|
||||
showThinking: () => boolean
|
||||
thinkingDefaultExpanded: () => boolean
|
||||
@@ -223,7 +232,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const current = record()
|
||||
if (!current) return null
|
||||
|
||||
const index = props.messageIndexMap().get(current.id) ?? 0
|
||||
const index = props.messageIndex
|
||||
const lastAssistantIdx = props.lastAssistantIndex()
|
||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||
const info = messageInfo()
|
||||
@@ -330,6 +339,21 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushContent()
|
||||
const key = `${current.id}:${part.id ?? partIndex}:compaction`
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
items.push({
|
||||
type: "compaction",
|
||||
key,
|
||||
part,
|
||||
messageInfo: info,
|
||||
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
|
||||
})
|
||||
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "step-start") {
|
||||
flushContent()
|
||||
return
|
||||
@@ -453,7 +477,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
</div>
|
||||
<ToolCall
|
||||
toolCall={toolItem.toolPart}
|
||||
toolCallId={toolItem.key}
|
||||
toolCallId={toolItem.toolPart.id}
|
||||
messageId={toolItem.messageId}
|
||||
messageVersion={toolItem.messageVersion}
|
||||
partVersion={toolItem.partVersion}
|
||||
@@ -477,6 +501,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
borderColor={(item as StepDisplayItem).accentColor}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item.type === "compaction"}>
|
||||
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
|
||||
</Match>
|
||||
<Match when={item.type === "reasoning"}>
|
||||
<ReasoningCard
|
||||
part={(item as ReasoningDisplayItem).part}
|
||||
@@ -505,6 +532,29 @@ interface StepCardProps {
|
||||
borderColor?: string
|
||||
}
|
||||
|
||||
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
|
||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
|
||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||
|
||||
const containerClass = () =>
|
||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||
|
||||
return (
|
||||
<div
|
||||
class={containerClass()}
|
||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||
role="status"
|
||||
aria-label="Session compaction"
|
||||
>
|
||||
<div class="message-compaction-row">
|
||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||
<span class="message-compaction-label">{label()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepCard(props: StepCardProps) {
|
||||
const timestamp = () => {
|
||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -15,9 +16,10 @@ interface MessageItemProps {
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||
@@ -36,7 +38,7 @@ interface MessageItemProps {
|
||||
}
|
||||
|
||||
const messageParts = () => props.parts
|
||||
|
||||
|
||||
const fileAttachments = () =>
|
||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
@@ -143,6 +145,22 @@ interface MessageItemProps {
|
||||
}
|
||||
}
|
||||
|
||||
const getRawContent = () => {
|
||||
return props.parts
|
||||
.filter(part => part.type === "text")
|
||||
.map(part => (part as { text?: string }).text || "")
|
||||
.filter(text => text.trim().length > 0)
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
const success = await copyToClipboard(content)
|
||||
setCopied(success)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!isUser() && !hasContent()) {
|
||||
return null
|
||||
}
|
||||
@@ -218,8 +236,30 @@ interface MessageItemProps {
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isUser()}>
|
||||
<button
|
||||
class="message-action-button"
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
>
|
||||
<Show when={copied()} fallback="Copy">
|
||||
Copied!
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ interface MessagePartProps {
|
||||
>
|
||||
<Markdown
|
||||
part={createTextPartForMarkdown()}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
size={isAssistantMessage() ? "tight" : "base"}
|
||||
onRendered={props.onRendered}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import type { Component } from "solid-js"
|
||||
import MessageBlock from "./message-block"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
@@ -10,14 +10,7 @@ interface MessagePreviewProps {
|
||||
}
|
||||
|
||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
const indexMap = createMemo(() => new Map([[props.messageId, 0]]))
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const record = props.store().getMessage(props.messageId)
|
||||
if (record?.role === "assistant") {
|
||||
return 0
|
||||
}
|
||||
return -1
|
||||
})
|
||||
const lastAssistantIndex = () => 0
|
||||
|
||||
return (
|
||||
<div class="message-preview message-stream">
|
||||
@@ -26,7 +19,7 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
store={props.store}
|
||||
messageIndexMap={indexMap}
|
||||
messageIndex={0}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => false}
|
||||
thinkingDefaultExpanded={() => false}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import Kbd from "./kbd"
|
||||
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||
import MessageListHeader from "./message-list-header"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
import { showCommandPalette } from "../stores/command-palette"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { formatTokenTotal } from "../lib/formatters"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
@@ -19,10 +15,6 @@ const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
return formatTokenTotal(tokens)
|
||||
}
|
||||
|
||||
export interface MessageSectionProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
@@ -77,25 +69,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
return `${showThinking}|${thinkingExpansion}|${showUsage}`
|
||||
})
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
const handleCommandPaletteClick = () => {
|
||||
showCommandPalette(props.instanceId)
|
||||
}
|
||||
|
||||
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||
if (typeof document === "undefined") return
|
||||
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||
}
|
||||
|
||||
const messageIndexMap = createMemo(() => {
|
||||
|
||||
const map = new Map<string, number>()
|
||||
const ids = messageIds()
|
||||
ids.forEach((id, index) => map.set(id, index))
|
||||
return map
|
||||
})
|
||||
|
||||
const lastAssistantIndex = createMemo(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
@@ -108,23 +87,57 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
return -1
|
||||
})
|
||||
|
||||
const timelineSegments = createMemo<TimelineSegment[]>(() => {
|
||||
const ids = messageIds()
|
||||
const resolvedStore = store()
|
||||
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
|
||||
const seenTimelineMessageIds = new Set<string>()
|
||||
const seenTimelineSegmentKeys = new Set<string>()
|
||||
|
||||
function makeTimelineKey(segment: TimelineSegment) {
|
||||
return `${segment.messageId}:${segment.id}:${segment.type}`
|
||||
}
|
||||
|
||||
function seedTimeline() {
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
const ids = untrack(messageIds)
|
||||
const resolvedStore = untrack(store)
|
||||
const segments: TimelineSegment[] = []
|
||||
ids.forEach((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return
|
||||
seenTimelineMessageIds.add(messageId)
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
segments.push(...built)
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
segments.push(segment)
|
||||
})
|
||||
})
|
||||
return segments
|
||||
})
|
||||
|
||||
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||
setTimelineSegments(segments)
|
||||
}
|
||||
|
||||
function appendTimelineForMessage(messageId: string) {
|
||||
const record = untrack(() => store().getMessage(messageId))
|
||||
if (!record) return
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
if (built.length === 0) return
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
}
|
||||
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||
|
||||
const changeToken = createMemo(() => String(sessionRevision()))
|
||||
const isActive = createMemo(() => props.isActive !== false)
|
||||
|
||||
|
||||
const scrollCache = useScrollCache({
|
||||
@@ -164,8 +177,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
let scrollToBottomDelayedFrame: number | null = null
|
||||
let pendingInitialScroll = true
|
||||
|
||||
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
|
||||
|
||||
function markUserScrollIntent() {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||
@@ -236,11 +247,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = false) {
|
||||
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
|
||||
if (!containerRef) return
|
||||
const sentinel = bottomSentinel()
|
||||
const behavior = immediate ? "auto" : "smooth"
|
||||
if (!immediate) {
|
||||
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
|
||||
if (suppressAutoAnchor) {
|
||||
suppressAutoScrollOnce = true
|
||||
}
|
||||
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
|
||||
@@ -260,6 +272,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
}
|
||||
|
||||
function requestScrollToBottom(immediate = true) {
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (!containerRef || !bottomSentinel()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
@@ -277,7 +293,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
function resolvePendingActiveScroll() {
|
||||
if (!pendingActiveScroll) return
|
||||
if (!props.isActive) return
|
||||
if (!isActive()) return
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
|
||||
@@ -292,8 +308,15 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
function scheduleAnchorScroll(immediate = false) {
|
||||
if (!autoScroll()) return
|
||||
if (!isActive()) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
const sentinel = bottomSentinel()
|
||||
if (!sentinel) return
|
||||
if (!sentinel) {
|
||||
pendingActiveScroll = true
|
||||
return
|
||||
}
|
||||
if (pendingAnchorScroll !== null) {
|
||||
cancelAnimationFrame(pendingAnchorScroll)
|
||||
pendingAnchorScroll = null
|
||||
@@ -377,10 +400,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
scheduleAnchorScroll()
|
||||
}
|
||||
|
||||
function handleInitialRenderComplete() {
|
||||
setInitialRenderComplete(true)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
|
||||
if (!containerRef) return
|
||||
@@ -404,6 +423,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
clearQuoteSelection()
|
||||
scheduleScrollPersist()
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -415,9 +435,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
let lastActiveState = false
|
||||
createEffect(() => {
|
||||
const active = Boolean(props.isActive)
|
||||
if (active && !lastActiveState) {
|
||||
requestScrollToBottom(true)
|
||||
const active = isActive()
|
||||
if (active) {
|
||||
resolvePendingActiveScroll()
|
||||
if (!lastActiveState && autoScroll()) {
|
||||
requestScrollToBottom(true)
|
||||
}
|
||||
} else if (autoScroll()) {
|
||||
pendingActiveScroll = true
|
||||
}
|
||||
lastActiveState = active
|
||||
})
|
||||
@@ -426,12 +451,123 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
const loading = Boolean(props.loading)
|
||||
if (loading) {
|
||||
pendingInitialScroll = true
|
||||
setInitialRenderComplete(false)
|
||||
return
|
||||
}
|
||||
if (pendingInitialScroll && initialRenderComplete()) {
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(false)
|
||||
if (!pendingInitialScroll) {
|
||||
return
|
||||
}
|
||||
const container = scrollElement()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel || messageIds().length === 0) {
|
||||
return
|
||||
}
|
||||
pendingInitialScroll = false
|
||||
requestScrollToBottom(true)
|
||||
})
|
||||
|
||||
let previousTimelineIds: string[] = []
|
||||
let previousLastTimelineMessageId: string | null = null
|
||||
let previousLastTimelinePartCount = 0
|
||||
|
||||
createEffect(() => {
|
||||
const loading = Boolean(props.loading)
|
||||
const ids = messageIds()
|
||||
|
||||
if (loading) {
|
||||
previousTimelineIds = []
|
||||
previousLastTimelineMessageId = null
|
||||
previousLastTimelinePartCount = 0
|
||||
setTimelineSegments([])
|
||||
seenTimelineMessageIds.clear()
|
||||
seenTimelineSegmentKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (previousTimelineIds.length === 0 && ids.length > 0) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length < previousTimelineIds.length) {
|
||||
seedTimeline()
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === previousTimelineIds.length) {
|
||||
let changedIndex = -1
|
||||
let changeCount = 0
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
if (ids[index] !== previousTimelineIds[index]) {
|
||||
changedIndex = index
|
||||
changeCount += 1
|
||||
if (changeCount > 1) break
|
||||
}
|
||||
}
|
||||
if (changeCount === 1 && changedIndex >= 0) {
|
||||
const oldId = previousTimelineIds[changedIndex]
|
||||
const newId = ids[changedIndex]
|
||||
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
|
||||
seenTimelineMessageIds.delete(oldId)
|
||||
seenTimelineMessageIds.add(newId)
|
||||
setTimelineSegments((prev) => {
|
||||
const next = prev.map((segment) => {
|
||||
if (segment.messageId !== oldId) return segment
|
||||
const updatedId = segment.id.replace(oldId, newId)
|
||||
return { ...segment, messageId: newId, id: updatedId }
|
||||
})
|
||||
seenTimelineSegmentKeys.clear()
|
||||
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
|
||||
return next
|
||||
})
|
||||
previousTimelineIds = ids.slice()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newIds: string[] = []
|
||||
ids.forEach((id) => {
|
||||
if (!seenTimelineMessageIds.has(id)) {
|
||||
newIds.push(id)
|
||||
}
|
||||
})
|
||||
|
||||
if (newIds.length > 0) {
|
||||
newIds.forEach((id) => {
|
||||
seenTimelineMessageIds.add(id)
|
||||
appendTimelineForMessage(id)
|
||||
})
|
||||
}
|
||||
|
||||
previousTimelineIds = ids.slice()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.loading) return
|
||||
const ids = messageIds()
|
||||
if (ids.length === 0) return
|
||||
const lastId = ids[ids.length - 1]
|
||||
if (!lastId) return
|
||||
const record = store().getMessage(lastId)
|
||||
if (!record) return
|
||||
const partCount = record.partIds.length
|
||||
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
|
||||
return
|
||||
}
|
||||
previousLastTimelineMessageId = lastId
|
||||
previousLastTimelinePartCount = partCount
|
||||
const built = buildTimelineSegments(props.instanceId, record)
|
||||
const newSegments: TimelineSegment[] = []
|
||||
built.forEach((segment) => {
|
||||
const key = makeTimelineKey(segment)
|
||||
if (seenTimelineSegmentKeys.has(key)) return
|
||||
seenTimelineSegmentKeys.add(key)
|
||||
newSegments.push(segment)
|
||||
})
|
||||
if (newSegments.length > 0) {
|
||||
setTimelineSegments((prev) => [...prev, ...newSegments])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -609,17 +745,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<MessageListHeader
|
||||
usedTokens={tokenStats().used}
|
||||
availableTokens={tokenStats().avail}
|
||||
connectionStatus={connectionStatus()}
|
||||
onCommandPalette={handleCommandPaletteClick}
|
||||
formatTokens={formatTokens}
|
||||
showSidebarToggle={props.showSidebarToggle}
|
||||
onSidebarToggle={props.onSidebarToggle}
|
||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||
/>
|
||||
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
@@ -659,7 +784,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
sessionId={props.sessionId}
|
||||
store={store}
|
||||
messageIds={messageIds}
|
||||
messageIndexMap={messageIndexMap}
|
||||
lastAssistantIndex={lastAssistantIndex}
|
||||
showThinking={() => preferences().showThinkingBlocks}
|
||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||
@@ -670,8 +794,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
onFork={props.onFork}
|
||||
onContentRendered={handleContentRendered}
|
||||
setBottomSentinel={setBottomSentinel}
|
||||
suspendMeasurements={() => props.isActive === false}
|
||||
onInitialRenderComplete={handleInitialRenderComplete}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -688,7 +811,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onClick={() => scrollToBottom()}
|
||||
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
|
||||
aria-label="Scroll to latest message"
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { ClientPart } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||
import { getToolIcon } from "./tool-call/utils"
|
||||
import { User as UserIcon, Bot as BotIcon } from "lucide-solid"
|
||||
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
|
||||
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool"
|
||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||
|
||||
export interface TimelineSegment {
|
||||
id: string
|
||||
@@ -16,6 +16,7 @@ export interface TimelineSegment {
|
||||
label: string
|
||||
tooltip: string
|
||||
shortLabel?: string
|
||||
variant?: "auto" | "manual"
|
||||
}
|
||||
|
||||
interface MessageTimelineProps {
|
||||
@@ -31,6 +32,7 @@ const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||
user: "You",
|
||||
assistant: "Asst",
|
||||
tool: "Tool",
|
||||
compaction: "Compaction",
|
||||
}
|
||||
|
||||
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||
@@ -215,6 +217,21 @@ export function buildTimelineSegments(instanceId: string, record: MessageRecord)
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "compaction") {
|
||||
flushPending()
|
||||
const isAuto = Boolean((part as any)?.auto)
|
||||
result.push({
|
||||
id: `${record.id}:${segmentIndex}`,
|
||||
messageId: record.id,
|
||||
type: "compaction",
|
||||
label: SEGMENT_LABELS.compaction,
|
||||
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
|
||||
variant: isAuto ? "auto" : "manual",
|
||||
})
|
||||
segmentIndex += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "step-start" || part.type === "step-finish") {
|
||||
continue
|
||||
}
|
||||
@@ -343,20 +360,26 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||
onCleanup(() => buttonRefs.delete(segment.id))
|
||||
const isActive = () => props.activeMessageId === segment.messageId
|
||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
const shortLabelContent = () => {
|
||||
if (segment.type === "tool") {
|
||||
return segment.shortLabel ?? getToolIcon("tool")
|
||||
}
|
||||
if (segment.type === "compaction") {
|
||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
if (segment.type === "user") {
|
||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||
<button
|
||||
ref={(el) => registerButtonRef(segment.id, el)}
|
||||
type="button"
|
||||
data-variant={segment.variant}
|
||||
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||
|
||||
aria-current={isActive() ? "true" : undefined}
|
||||
aria-hidden={isHidden() ? "true" : undefined}
|
||||
onClick={() => props.onSegmentClick?.(segment)}
|
||||
|
||||
@@ -1086,8 +1086,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="prompt-input-field">
|
||||
<textarea
|
||||
<div class="prompt-input-field-container">
|
||||
<div class="prompt-input-field">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
@@ -1167,6 +1168,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-actions">
|
||||
<button
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Component, For, Show, createSignal, createEffect, onCleanup, onMount, createMemo, JSX } from "solid-js"
|
||||
import { Component, For, Show, createSignal, createMemo, JSX } from "solid-js"
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { MessageSquare, Info, X, Copy, Trash2 } from "lucide-solid"
|
||||
import { MessageSquare, Info, X, Copy, Trash2, Pencil, ShieldAlert } from "lucide-solid"
|
||||
import KeyboardHint from "./keyboard-hint"
|
||||
import Kbd from "./kbd"
|
||||
import SessionRenameDialog from "./session-rename-dialog"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { deleteSession, loading } from "../stores/sessions"
|
||||
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -24,14 +26,8 @@ interface SessionListProps {
|
||||
showFooter?: boolean
|
||||
headerContent?: JSX.Element
|
||||
footerContent?: JSX.Element
|
||||
onWidthChange?: (width: number) => void
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 200
|
||||
const MAX_WIDTH = 520
|
||||
const DEFAULT_WIDTH = 360
|
||||
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
|
||||
|
||||
function formatSessionStatus(status: SessionStatus): string {
|
||||
switch (status) {
|
||||
case "working":
|
||||
@@ -62,10 +58,8 @@ function arraysEqual(prev: readonly string[] | undefined, next: readonly string[
|
||||
}
|
||||
|
||||
const SessionList: Component<SessionListProps> = (props) => {
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(DEFAULT_WIDTH)
|
||||
const [isResizing, setIsResizing] = createSignal(false)
|
||||
const [startX, setStartX] = createSignal(0)
|
||||
const [startWidth, setStartWidth] = createSignal(DEFAULT_WIDTH)
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
|
||||
const [isRenaming, setIsRenaming] = createSignal(false)
|
||||
const infoShortcut = keyboardRegistry.get("switch-to-info")
|
||||
|
||||
const isSessionDeleting = (sessionId: string) => {
|
||||
@@ -76,45 +70,17 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const selectSession = (sessionId: string) => {
|
||||
props.onSelect(sessionId)
|
||||
}
|
||||
|
||||
|
||||
let mouseMoveHandler: ((event: MouseEvent) => void) | null = null
|
||||
let mouseUpHandler: (() => void) | null = null
|
||||
let touchMoveHandler: ((event: TouchEvent) => void) | null = null
|
||||
let touchEndHandler: (() => void) | null = null
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const saved = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return
|
||||
|
||||
const width = Number.parseInt(saved, 10)
|
||||
if (Number.isFinite(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
|
||||
setSidebarWidth(width)
|
||||
setStartWidth(width)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const width = sidebarWidth()
|
||||
window.localStorage.setItem(STORAGE_KEY, width.toString())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onWidthChange?.(sidebarWidth())
|
||||
})
|
||||
|
||||
const copySessionId = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
|
||||
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
throw new Error("Clipboard API unavailable")
|
||||
const success = await copyToClipboard(sessionId)
|
||||
if (success) {
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} else {
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} catch (error) {
|
||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
@@ -132,96 +98,35 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
showToastNotification({ message: "Unable to delete session", variant: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
const clampWidth = (width: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
|
||||
const openRenameDialog = (sessionId: string) => {
|
||||
const session = props.sessions.get(sessionId)
|
||||
if (!session) return
|
||||
const label = session.title && session.title.trim() ? session.title : sessionId
|
||||
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
|
||||
}
|
||||
|
||||
const closeRenameDialog = () => {
|
||||
setRenameTarget(null)
|
||||
}
|
||||
|
||||
const handleRenameSubmit = async (nextTitle: string) => {
|
||||
const target = renameTarget()
|
||||
if (!target) return
|
||||
|
||||
setIsRenaming(true)
|
||||
try {
|
||||
await renameSession(props.instanceId, target.id, nextTitle)
|
||||
setRenameTarget(null)
|
||||
} catch (error) {
|
||||
log.error(`Failed to rename session ${target.id}:`, error)
|
||||
showToastNotification({ message: "Unable to rename session", variant: "error" })
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const removeMouseListeners = () => {
|
||||
if (mouseMoveHandler) {
|
||||
document.removeEventListener("mousemove", mouseMoveHandler)
|
||||
mouseMoveHandler = null
|
||||
}
|
||||
if (mouseUpHandler) {
|
||||
document.removeEventListener("mouseup", mouseUpHandler)
|
||||
mouseUpHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
const removeTouchListeners = () => {
|
||||
if (touchMoveHandler) {
|
||||
document.removeEventListener("touchmove", touchMoveHandler)
|
||||
touchMoveHandler = null
|
||||
}
|
||||
if (touchEndHandler) {
|
||||
document.removeEventListener("touchend", touchEndHandler)
|
||||
touchEndHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
const stopResizing = () => {
|
||||
setIsResizing(false)
|
||||
removeMouseListeners()
|
||||
removeTouchListeners()
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isResizing()) return
|
||||
const diff = event.clientX - startX()
|
||||
const newWidth = clampWidth(startWidth() + diff)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
stopResizing()
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!isResizing()) return
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
const diff = touch.clientX - startX()
|
||||
const newWidth = clampWidth(startWidth() + diff)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
stopResizing()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setIsResizing(true)
|
||||
setStartX(event.clientX)
|
||||
setStartWidth(sidebarWidth())
|
||||
|
||||
mouseMoveHandler = handleMouseMove
|
||||
mouseUpHandler = handleMouseUp
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
event.preventDefault()
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
setIsResizing(true)
|
||||
setStartX(touch.clientX)
|
||||
setStartWidth(sidebarWidth())
|
||||
|
||||
touchMoveHandler = handleTouchMove
|
||||
touchEndHandler = handleTouchEnd
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove)
|
||||
document.addEventListener("touchend", handleTouchEnd)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
removeMouseListeners()
|
||||
removeTouchListeners()
|
||||
})
|
||||
|
||||
const SessionRow: Component<{ sessionId: string; canClose?: boolean }> = (rowProps) => {
|
||||
const session = () => props.sessions.get(rowProps.sessionId)
|
||||
if (!session()) {
|
||||
@@ -267,7 +172,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class="status-dot" />
|
||||
{pendingPermission() ? (
|
||||
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<span class="status-dot" />
|
||||
)}
|
||||
{statusText()}
|
||||
</span>
|
||||
<div class="session-item-actions">
|
||||
@@ -281,6 +190,19 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
openRenameDialog(rowProps.sessionId)
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Rename session"
|
||||
title="Rename session"
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
</span>
|
||||
<span
|
||||
class={`session-item-close opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
|
||||
onClick={(event) => handleDeleteSession(event, rowProps.sessionId)}
|
||||
@@ -348,14 +270,6 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<div
|
||||
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
|
||||
>
|
||||
<div
|
||||
class="session-resize-handle"
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<Show when={props.showHeader !== false}>
|
||||
<div class="session-list-header p-3 border-b border-base">
|
||||
{props.headerContent ?? (
|
||||
@@ -418,8 +332,18 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
{props.footerContent ?? null}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<SessionRenameDialog
|
||||
open={Boolean(renameTarget())}
|
||||
currentTitle={renameTarget()?.title ?? ""}
|
||||
sessionLabel={renameTarget()?.label}
|
||||
isSubmitting={isRenaming()}
|
||||
onRename={handleRenameSubmit}
|
||||
onClose={closeRenameDialog}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionList
|
||||
|
||||
|
||||
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
130
packages/ui/src/components/session-rename-dialog.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Component, Show, createEffect, createSignal } from "solid-js"
|
||||
|
||||
interface SessionRenameDialogProps {
|
||||
open: boolean
|
||||
currentTitle: string
|
||||
sessionLabel?: string
|
||||
isSubmitting?: boolean
|
||||
onRename: (nextTitle: string) => Promise<void> | void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
|
||||
const [title, setTitle] = createSignal("")
|
||||
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
setTitle(props.currentTitle ?? "")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef?.focus()
|
||||
inputRef?.select()
|
||||
})
|
||||
})
|
||||
|
||||
const isSubmitting = () => Boolean(props.isSubmitting)
|
||||
const isRenameDisabled = () => isSubmitting() || !title().trim()
|
||||
|
||||
async function handleRename(event?: Event) {
|
||||
event?.preventDefault()
|
||||
if (isRenameDisabled()) return
|
||||
await props.onRename(title().trim())
|
||||
}
|
||||
|
||||
const description = () => {
|
||||
if (props.sessionLabel && props.sessionLabel.trim()) {
|
||||
return `Update the title for "${props.sessionLabel}".`
|
||||
}
|
||||
return "Set a new title for this session."
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isSubmitting()) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
|
||||
<Dialog.Title class="text-lg font-semibold text-primary">Rename Session</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">
|
||||
{description()}
|
||||
</Dialog.Description>
|
||||
|
||||
<form class="mt-4 space-y-4" onSubmit={handleRename}>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-secondary" for={inputId}>
|
||||
Session name
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={(element) => {
|
||||
inputRef = element
|
||||
}}
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
placeholder="Enter a session name"
|
||||
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="button-tertiary"
|
||||
onClick={() => {
|
||||
if (!isSubmitting()) {
|
||||
props.onClose()
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={isRenameDisabled()}
|
||||
>
|
||||
<Show
|
||||
when={!isSubmitting()}
|
||||
fallback={
|
||||
<>
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Renaming…</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionRenameDialog
|
||||
@@ -10,6 +10,7 @@ import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setAc
|
||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { requestData } from "../../lib/opencode-api"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -39,6 +40,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||
})
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
function scheduleScrollToBottom() {
|
||||
if (!scrollToBottomHandle) return
|
||||
requestAnimationFrame(() => {
|
||||
@@ -74,9 +76,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
if (scrollToBottomHandle && import.meta.env?.DEV) {
|
||||
console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
|
||||
}
|
||||
scheduleScrollToBottom()
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
}
|
||||
@@ -124,14 +123,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
if (!instance || !instance.client) return
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: props.sessionId },
|
||||
body: { messageID: messageId },
|
||||
})
|
||||
await requestData(
|
||||
instance.client.session.revert({
|
||||
sessionID: props.sessionId,
|
||||
messageID: messageId,
|
||||
}),
|
||||
"session.revert",
|
||||
)
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
@@ -167,7 +169,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
@@ -197,7 +199,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const activeSession = sessionAccessor()
|
||||
if (!activeSession) return null
|
||||
return (
|
||||
<div class="session-view">
|
||||
<div ref={rootRef} class="session-view">
|
||||
<MessageSection
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeSession.id}
|
||||
|
||||
@@ -7,23 +7,30 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { sendPermissionResponse } from "../stores/instances"
|
||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||
import type {
|
||||
DiffPayload,
|
||||
DiffRenderOptions,
|
||||
MarkdownRenderOptions,
|
||||
AnsiRenderOptions,
|
||||
ToolCallPart,
|
||||
ToolRendererContext,
|
||||
ToolScrollHelpers,
|
||||
} from "./tool-call/types"
|
||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils"
|
||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
import { escapeHtml } from "../lib/markdown"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -117,21 +124,16 @@ function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = (() => {
|
||||
if (!normalizedPreferred) return candidateEntries
|
||||
const matched = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
if (normalized === normalizedPreferred) return true
|
||||
if (normalized.endsWith(`/${normalizedPreferred}`)) return true
|
||||
const normalizedBase = normalized.split("/").pop()
|
||||
const preferredBase = normalizedPreferred.split("/").pop()
|
||||
return normalizedBase && preferredBase ? normalizedBase === preferredBase : false
|
||||
})
|
||||
return matched.length > 0 ? matched : candidateEntries
|
||||
})()
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
@@ -221,7 +223,13 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const { isDark } = useTheme()
|
||||
const toolCallMemo = createMemo(() => props.toolCall)
|
||||
const toolName = createMemo(() => toolCallMemo()?.tool || "")
|
||||
const toolCallIdentifier = createMemo(() => toolCallMemo()?.callID || props.toolCallId || toolCallMemo()?.id || "")
|
||||
const toolCallIdentifier = createMemo(() => {
|
||||
const partId = toolCallMemo()?.id
|
||||
if (!partId) {
|
||||
throw new Error("Tool call requires a part id")
|
||||
}
|
||||
return partId
|
||||
})
|
||||
const toolState = createMemo(() => toolCallMemo()?.state)
|
||||
|
||||
const cacheContext = createMemo(() => ({
|
||||
@@ -232,21 +240,36 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||
|
||||
const createVariantCache = (variant: string) =>
|
||||
const cacheVersion = createMemo(() => {
|
||||
if (typeof props.partVersion === "number") {
|
||||
return String(props.partVersion)
|
||||
}
|
||||
if (typeof props.messageVersion === "number") {
|
||||
return String(props.messageVersion)
|
||||
}
|
||||
return "noversion"
|
||||
})
|
||||
|
||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
scope: TOOL_CALL_CACHE_SCOPE,
|
||||
key: () => {
|
||||
cacheId: () => {
|
||||
const context = cacheContext()
|
||||
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, variant)
|
||||
const resolvedVariant = typeof variant === "function" ? variant() : variant
|
||||
return makeRenderCacheKey(context.toolCallId || undefined, context.messageId, context.partId, resolvedVariant)
|
||||
},
|
||||
version: () => (version ? version() : cacheVersion()),
|
||||
})
|
||||
|
||||
const diffCache = createVariantCache("diff")
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const markdownCache = createVariantCache("markdown")
|
||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||
const ansiFinalCache = createVariantCache("ansi-final")
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||
const pendingPermission = createMemo(() => {
|
||||
const state = permissionState()
|
||||
@@ -623,6 +646,75 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function renderAnsiContent(options: AnsiRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined
|
||||
const isRunningVariant = options.variant === "running"
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
if (isRunningVariant) {
|
||||
const content = options.content
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else {
|
||||
runningAnsiRenderer.reset()
|
||||
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
|
||||
}
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
|
||||
runningAnsiSource = nextCache.text
|
||||
cacheHandle.set(nextCache)
|
||||
} else {
|
||||
if (cached && cached.text === options.content) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else {
|
||||
const detectedAnsi = hasAnsi(options.content)
|
||||
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
|
||||
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
|
||||
cacheHandle.set(nextCache)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireAnsi && !nextCache.hasAnsi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
@@ -632,14 +724,24 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
|
||||
const markdownPart: TextPart = { type: "text", text: options.content }
|
||||
const cached = markdownCache.get<RenderCache>()
|
||||
if (cached) {
|
||||
markdownPart.renderCache = cached
|
||||
const state = toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const partId = toolCallMemo()?.id
|
||||
if (!partId) {
|
||||
throw new Error("Tool call markdown requires a part id")
|
||||
}
|
||||
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
markdownCache.set(markdownPart.renderCache)
|
||||
handleScrollRendered()
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
@@ -648,6 +750,8 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
@@ -668,6 +772,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown: renderMarkdownContent,
|
||||
renderAnsi: renderAnsiContent,
|
||||
renderDiff: renderDiffContent,
|
||||
scrollHelpers,
|
||||
}
|
||||
@@ -699,6 +804,12 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const renderToolTitle = () => {
|
||||
const state = toolState()
|
||||
const currentTool = toolName()
|
||||
|
||||
if (currentTool !== "task") {
|
||||
return resolveTitleForTool({ toolName: currentTool, state })
|
||||
}
|
||||
|
||||
if (!state) return getRendererAction()
|
||||
if (state.status === "pending") return getRendererAction()
|
||||
|
||||
@@ -713,7 +824,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return state.title
|
||||
}
|
||||
|
||||
return getToolName(toolName())
|
||||
return getToolName(currentTool)
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
@@ -728,7 +839,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
setPermissionSubmitting(true)
|
||||
setPermissionError(null)
|
||||
try {
|
||||
const sessionId = permission.sessionID || props.sessionId
|
||||
const sessionId = getPermissionSessionId(permission) || props.sessionId
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||
} catch (error) {
|
||||
log.error("Failed to send permission response", error)
|
||||
@@ -773,11 +884,11 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{permission.type}</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{permission.title}</code>
|
||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
||||
</div>
|
||||
<Show when={diffPayload}>
|
||||
{(payload) => (
|
||||
@@ -907,33 +1018,3 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultToolAction(toolName: string) {
|
||||
switch (toolName) {
|
||||
case "task":
|
||||
return "Delegating..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const bashRenderer: ToolRenderer = {
|
||||
const timeoutLabel = `${timeout}ms`
|
||||
return `${baseTitle} · Timeout: ${timeoutLabel}`
|
||||
},
|
||||
renderBody({ toolState, renderMarkdown }) {
|
||||
renderBody({ toolState, renderMarkdown, renderAnsi }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
@@ -36,9 +36,19 @@ export const bashRenderer: ToolRenderer = {
|
||||
const parts = [command, outputResult?.text].filter(Boolean)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const content = ensureMarkdownContent(parts.join("\n"), "bash", true)
|
||||
const joined = parts.join("\n")
|
||||
if (state.status === "running") {
|
||||
return renderAnsi({ content: joined, variant: "running" })
|
||||
}
|
||||
|
||||
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
|
||||
if (ansiBody) {
|
||||
return ansiBody
|
||||
}
|
||||
|
||||
const content = ensureMarkdownContent(joined, "bash", true)
|
||||
if (!content) return null
|
||||
|
||||
return renderMarkdown({ content, disableHighlight: state.status === "running" })
|
||||
return renderMarkdown({ content })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,84 @@
|
||||
import { For, createMemo } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { getTodoTitle } from "./todo"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
id: string
|
||||
tool: string
|
||||
input: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
state?: ToolState
|
||||
status?: ToolState["status"]
|
||||
title?: string
|
||||
}
|
||||
|
||||
function describeTaskItem(item: TaskSummaryItem): string {
|
||||
const input = item.input || {}
|
||||
switch (item.tool) {
|
||||
case "bash":
|
||||
return typeof input.description === "string" ? input.description : input.command || "bash"
|
||||
case "edit":
|
||||
case "read":
|
||||
case "write":
|
||||
return `${item.tool} ${getRelativePath(typeof input.filePath === "string" ? input.filePath : "")}`.trim()
|
||||
default:
|
||||
return item.tool
|
||||
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||
return status
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function summarizeStatusIcon(status?: ToolState["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏸"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "completed":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending"
|
||||
case "running":
|
||||
return "Running"
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "error":
|
||||
return "Error"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
function describeTaskTitle(input: Record<string, any>) {
|
||||
const description = typeof input.description === "string" ? input.description : undefined
|
||||
const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined
|
||||
const base = getToolName("task")
|
||||
if (description && subagent) {
|
||||
return `${base}[${subagent}] ${description}`
|
||||
}
|
||||
if (description) {
|
||||
return `${base} ${description}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function describeToolTitle(item: TaskSummaryItem): string {
|
||||
if (item.title && item.title.length > 0) {
|
||||
return item.title
|
||||
}
|
||||
|
||||
if (item.tool === "task") {
|
||||
return describeTaskTitle({ ...item.metadata, ...item.input })
|
||||
}
|
||||
|
||||
if (item.state) {
|
||||
return resolveTitleForTool({ toolName: item.tool, state: item.state })
|
||||
}
|
||||
|
||||
return getDefaultToolAction(item.tool)
|
||||
}
|
||||
|
||||
export const taskRenderer: ToolRenderer = {
|
||||
@@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
const { input } = readToolStatePayload(state)
|
||||
const description = input.description
|
||||
const subagent = input.subagent_type
|
||||
const base = getToolName("task")
|
||||
if (description && subagent) {
|
||||
return `${base}[${subagent}] ${description}`
|
||||
}
|
||||
if (description) {
|
||||
return `${base} ${description}`
|
||||
}
|
||||
return base
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) {
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||
const items = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
@@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = {
|
||||
|
||||
return summary.map((entry, index) => {
|
||||
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
||||
const input = typeof (entry as any)?.state?.input === "object" && entry.state?.input ? entry.state.input : {}
|
||||
const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined
|
||||
const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {}
|
||||
const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {}
|
||||
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
||||
return { id, tool, input }
|
||||
const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status)
|
||||
const title = typeof entry?.title === "string" ? entry.title : undefined
|
||||
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,11 +126,23 @@ export const taskRenderer: ToolRenderer = {
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeTaskItem(item)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id}>
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -87,4 +153,3 @@ export const taskRenderer: ToolRenderer = {
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { For } from "solid-js"
|
||||
import { For, Show } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { readToolStatePayload } from "../utils"
|
||||
|
||||
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||
|
||||
interface TodoViewItem {
|
||||
export interface TodoViewItem {
|
||||
id: string
|
||||
content: string
|
||||
status: TodoViewStatus
|
||||
@@ -58,7 +58,56 @@ function getTodoStatusLabel(status: TodoViewStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getTodoTitle(state?: ToolState): string {
|
||||
interface TodoListViewProps {
|
||||
state?: ToolState
|
||||
emptyLabel?: string
|
||||
showStatusLabel?: boolean
|
||||
}
|
||||
|
||||
export function TodoListView(props: TodoListViewProps) {
|
||||
const todos = extractTodosFromState(props.state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-todo-region">
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": todo.status === "completed",
|
||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<div class="tool-call-todo-heading">
|
||||
<span class="tool-call-todo-text">{todo.content}</span>
|
||||
<Show when={props.showStatusLabel !== false}>
|
||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getTodoTitle(state?: ToolState): string {
|
||||
if (!state) return "Plan"
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
@@ -80,42 +129,6 @@ export const todoRenderer: ToolRenderer = {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">No plan items yet.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-todo-region">
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": todo.status === "completed",
|
||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<div class="tool-call-todo-heading">
|
||||
<span class="tool-call-todo-text">{todo.content}</span>
|
||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <TodoListView state={state} />
|
||||
},
|
||||
}
|
||||
|
||||
88
packages/ui/src/components/tool-call/tool-title.ts
Normal file
88
packages/ui/src/components/tool-call/tool-title.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||
import { defaultRenderer } from "./renderers/default"
|
||||
import { bashRenderer } from "./renderers/bash"
|
||||
import { readRenderer } from "./renderers/read"
|
||||
import { writeRenderer } from "./renderers/write"
|
||||
import { editRenderer } from "./renderers/edit"
|
||||
import { patchRenderer } from "./renderers/patch"
|
||||
import { webfetchRenderer } from "./renderers/webfetch"
|
||||
import { todoRenderer } from "./renderers/todo"
|
||||
import { invalidRenderer } from "./renderers/invalid"
|
||||
|
||||
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||
bash: bashRenderer,
|
||||
read: readRenderer,
|
||||
write: writeRenderer,
|
||||
edit: editRenderer,
|
||||
patch: patchRenderer,
|
||||
webfetch: webfetchRenderer,
|
||||
todowrite: todoRenderer,
|
||||
todoread: todoRenderer,
|
||||
invalid: invalidRenderer,
|
||||
}
|
||||
|
||||
interface TitleSnapshot {
|
||||
toolName: string
|
||||
state?: ToolState
|
||||
}
|
||||
|
||||
function lookupRenderer(toolName: string): ToolRenderer {
|
||||
return TITLE_RENDERERS[toolName] ?? defaultRenderer
|
||||
}
|
||||
|
||||
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
||||
return {
|
||||
id: "",
|
||||
type: "tool",
|
||||
tool: snapshot.toolName,
|
||||
state: snapshot.state,
|
||||
} as ToolCallPart
|
||||
}
|
||||
|
||||
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||
const toolStateAccessor = () => snapshot.state
|
||||
const toolNameAccessor = () => snapshot.toolName
|
||||
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||
const messageVersionAccessor = () => undefined
|
||||
const partVersionAccessor = () => undefined
|
||||
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||
const renderAnsi: ToolRendererContext["renderAnsi"] = () => null
|
||||
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||
|
||||
return {
|
||||
toolCall: toolCallAccessor,
|
||||
toolState: toolStateAccessor,
|
||||
toolName: toolNameAccessor,
|
||||
messageVersion: messageVersionAccessor,
|
||||
partVersion: partVersionAccessor,
|
||||
renderMarkdown,
|
||||
renderAnsi,
|
||||
renderDiff,
|
||||
scrollHelpers: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
|
||||
const renderer = lookupRenderer(snapshot.toolName)
|
||||
const context = createStaticContext(snapshot)
|
||||
const state = snapshot.state
|
||||
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
|
||||
|
||||
if (!state || state.status === "pending") {
|
||||
return defaultAction
|
||||
}
|
||||
|
||||
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
|
||||
if (stateTitle && stateTitle.length > 0) {
|
||||
return stateTitle
|
||||
}
|
||||
|
||||
const customTitle = renderer.getTitle?.(context)
|
||||
if (customTitle) {
|
||||
return customTitle
|
||||
}
|
||||
|
||||
return getToolName(snapshot.toolName)
|
||||
}
|
||||
@@ -15,6 +15,13 @@ export interface MarkdownRenderOptions {
|
||||
disableHighlight?: boolean
|
||||
}
|
||||
|
||||
export interface AnsiRenderOptions {
|
||||
content: string
|
||||
size?: "default" | "large"
|
||||
requireAnsi?: boolean
|
||||
variant?: "running" | "final"
|
||||
}
|
||||
|
||||
export interface DiffRenderOptions {
|
||||
variant?: string
|
||||
disableScrollTracking?: boolean
|
||||
@@ -34,6 +41,7 @@ export interface ToolRendererContext {
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null
|
||||
scrollHelpers?: ToolScrollHelpers
|
||||
}
|
||||
|
||||
@@ -192,3 +192,33 @@ export function readToolStatePayload(state?: ToolState): {
|
||||
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultToolAction(toolName: string) {
|
||||
switch (toolName) {
|
||||
case "task":
|
||||
return "Delegating..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, c
|
||||
const sizeCache = new Map<string, number>()
|
||||
const DEFAULT_MARGIN_PX = 600
|
||||
const MIN_PLACEHOLDER_HEIGHT = 32
|
||||
const VISIBILITY_BUFFER_PX = 48
|
||||
|
||||
type ObserverRoot = Element | Document | null
|
||||
|
||||
@@ -48,6 +49,19 @@ function createSharedObserver(root: ObserverRoot, margin: number): SharedObserve
|
||||
return { observer, listeners }
|
||||
}
|
||||
|
||||
function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
||||
const rootBounds = entry.rootBounds
|
||||
if (!rootBounds) {
|
||||
return entry.isIntersecting
|
||||
}
|
||||
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
|
||||
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
|
||||
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function subscribeToSharedObserver(
|
||||
target: Element,
|
||||
root: ObserverRoot,
|
||||
@@ -167,6 +181,18 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
return
|
||||
}
|
||||
const normalized = nextHeight
|
||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
||||
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
|
||||
if (shouldKeepPrevious) {
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
setHasMeasured(true)
|
||||
sizeCache.set(props.cacheKey, previous)
|
||||
setMeasuredHeight(previous)
|
||||
return
|
||||
}
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
setHasMeasured(true)
|
||||
@@ -212,7 +238,8 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
}
|
||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
||||
queueVisibility(entry.isIntersecting)
|
||||
const nextVisible = shouldRenderEntry(entry)
|
||||
queueVisibility(nextVisible)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,6 +289,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
measurementsSuspended()
|
||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
||||
refreshIntersectionObserver(root ?? null)
|
||||
})
|
||||
|
||||
136
packages/ui/src/lib/ansi.ts
Normal file
136
packages/ui/src/lib/ansi.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createAnsiSequenceParser, createColorPalette } from "ansi-sequence-parser"
|
||||
|
||||
const ESC_CHAR = "\u001b"
|
||||
const ANSI_LITERAL_PATTERN = /\\u001b|\\x1b|\\033/
|
||||
const ANSI_ESCAPE_PATTERN = /\u001b/
|
||||
|
||||
const colorPalette = createColorPalette()
|
||||
|
||||
export function hasAnsi(text: string): boolean {
|
||||
const normalized = normalizeAnsiText(text)
|
||||
return ANSI_ESCAPE_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
export function ansiToHtml(text: string): string {
|
||||
const normalized = normalizeAnsiText(text)
|
||||
const parser = createAnsiSequenceParser()
|
||||
const tokens = parser.parse(normalized)
|
||||
return tokensToHtml(tokens)
|
||||
}
|
||||
|
||||
export interface AnsiStreamRenderer {
|
||||
reset: () => void
|
||||
render: (chunk: string) => string
|
||||
}
|
||||
|
||||
export function createAnsiStreamRenderer(): AnsiStreamRenderer {
|
||||
let parser = createAnsiSequenceParser()
|
||||
|
||||
return {
|
||||
reset() {
|
||||
parser = createAnsiSequenceParser()
|
||||
},
|
||||
render(chunk: string) {
|
||||
const normalized = normalizeAnsiText(chunk)
|
||||
const tokens = parser.parse(normalized)
|
||||
return tokensToHtml(tokens)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnsiText(text: string): string {
|
||||
if (!ANSI_LITERAL_PATTERN.test(text)) {
|
||||
return text
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/\\u001b/gi, ESC_CHAR)
|
||||
.replace(/\\x1b/gi, ESC_CHAR)
|
||||
.replace(/\\033/g, ESC_CHAR)
|
||||
}
|
||||
|
||||
function tokensToHtml(tokens: { value: string; foreground: unknown; background: unknown; decorations: Set<string> }[]): string {
|
||||
let html = ""
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const styles = buildTokenStyles(token)
|
||||
const escaped = escapeHtml(token.value)
|
||||
|
||||
if (!styles) {
|
||||
html += escaped
|
||||
continue
|
||||
}
|
||||
|
||||
html += `<span style="${styles}">${escaped}</span>`
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function buildTokenStyles(token: { foreground: any; background: any; decorations: Set<string> }): string | null {
|
||||
const decorations = token.decorations
|
||||
let foreground = token.foreground ? colorPalette.value(token.foreground) : null
|
||||
let background = token.background ? colorPalette.value(token.background) : null
|
||||
|
||||
if (decorations.has("reverse")) {
|
||||
const swapped = foreground
|
||||
foreground = background
|
||||
background = swapped
|
||||
}
|
||||
|
||||
const styles: string[] = []
|
||||
|
||||
if (foreground) {
|
||||
styles.push(`color: ${foreground}`)
|
||||
}
|
||||
|
||||
if (background) {
|
||||
styles.push(`background-color: ${background}`)
|
||||
}
|
||||
|
||||
if (decorations.has("bold")) {
|
||||
styles.push("font-weight: 600")
|
||||
}
|
||||
|
||||
if (decorations.has("dim")) {
|
||||
styles.push("opacity: 0.7")
|
||||
}
|
||||
|
||||
if (decorations.has("italic")) {
|
||||
styles.push("font-style: italic")
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
if (decorations.has("underline")) {
|
||||
lines.push("underline")
|
||||
}
|
||||
if (decorations.has("strikethrough")) {
|
||||
lines.push("line-through")
|
||||
}
|
||||
if (decorations.has("overline")) {
|
||||
lines.push("overline")
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
styles.push(`text-decoration-line: ${lines.join(" ")}`)
|
||||
}
|
||||
|
||||
if (decorations.has("hidden")) {
|
||||
styles.push("color: transparent")
|
||||
styles.push("background-color: transparent")
|
||||
}
|
||||
|
||||
return styles.length > 0 ? styles.join("; ") : null
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
BackgroundProcess,
|
||||
BackgroundProcessListResponse,
|
||||
BackgroundProcessOutputResponse,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
@@ -28,6 +31,12 @@ const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
|
||||
|
||||
export const CODENOMAD_API_BASE = API_BASE
|
||||
|
||||
export function buildBackgroundProcessStreamUrl(instanceId: string, processId: string): string {
|
||||
const encodedInstanceId = encodeURIComponent(instanceId)
|
||||
const encodedProcessId = encodeURIComponent(processId)
|
||||
return buildAbsoluteUrl(`/workspaces/${encodedInstanceId}/plugin/background-processes/${encodedProcessId}/stream`)
|
||||
}
|
||||
|
||||
function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
@@ -39,9 +48,41 @@ function buildEventsUrl(base: string | undefined, path: string): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function buildAbsoluteUrl(path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path
|
||||
}
|
||||
if (!API_BASE) {
|
||||
return path
|
||||
}
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`
|
||||
return `${API_BASE}${normalized}`
|
||||
}
|
||||
|
||||
const httpLogger = getLogger("api")
|
||||
const sseLogger = getLogger("sse")
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const output: Record<string, string> = {}
|
||||
if (!headers) return output
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
output[key] = value
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
output[key] = value
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
return { ...headers }
|
||||
}
|
||||
|
||||
function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
if (context) {
|
||||
httpLogger.info(message, context)
|
||||
@@ -52,9 +93,9 @@ function logHttp(message: string, context?: Record<string, unknown>) {
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = API_BASE ? new URL(path, API_BASE).toString() : path
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
const headers = normalizeHeaders(init?.headers)
|
||||
if (init?.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase()
|
||||
@@ -186,6 +227,47 @@ export const serverApi = {
|
||||
deleteInstanceData(id: string): Promise<void> {
|
||||
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
listBackgroundProcesses(instanceId: string): Promise<BackgroundProcessListResponse> {
|
||||
return request<BackgroundProcessListResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes`,
|
||||
)
|
||||
},
|
||||
stopBackgroundProcess(instanceId: string, processId: string): Promise<BackgroundProcess> {
|
||||
return request<BackgroundProcess>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/stop`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
terminateBackgroundProcess(instanceId: string, processId: string): Promise<void> {
|
||||
return request(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/terminate`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
},
|
||||
fetchBackgroundProcessOutput(
|
||||
instanceId: string,
|
||||
processId: string,
|
||||
options?: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
|
||||
): Promise<BackgroundProcessOutputResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.method) {
|
||||
params.set("method", options.method)
|
||||
}
|
||||
if (options?.pattern) {
|
||||
params.set("pattern", options.pattern)
|
||||
}
|
||||
if (options?.lines) {
|
||||
params.set("lines", String(options.lines))
|
||||
}
|
||||
if (options?.maxBytes !== undefined) {
|
||||
params.set("maxBytes", String(options.maxBytes))
|
||||
}
|
||||
const query = params.toString()
|
||||
const suffix = query ? `?${query}` : ""
|
||||
return request<BackgroundProcessOutputResponse>(
|
||||
`/workspaces/${encodeURIComponent(instanceId)}/plugin/background-processes/${encodeURIComponent(processId)}/output${suffix}`,
|
||||
)
|
||||
},
|
||||
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
|
||||
sseLogger.info(`Connecting to ${EVENTS_URL}`)
|
||||
const source = new EventSource(EVENTS_URL)
|
||||
|
||||
61
packages/ui/src/lib/clipboard.ts
Normal file
61
packages/ui/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Clipboard utility with fallback for non-secure contexts
|
||||
* The modern Clipboard API requires HTTPS or localhost, but document.execCommand
|
||||
* works in HTTP contexts as a fallback.
|
||||
*/
|
||||
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with fallback for non-secure contexts
|
||||
* @param text - The text to copy
|
||||
* @returns Promise<boolean> - true if successful, false if failed
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
// Try modern Clipboard API first (requires secure context)
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
log.info("Copied text using Clipboard API")
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Clipboard API failed, trying fallback:", error)
|
||||
}
|
||||
|
||||
// Fallback for non-secure contexts (HTTP) using document.execCommand
|
||||
try {
|
||||
if (typeof document === "undefined") {
|
||||
log.error("Document not available for clipboard fallback")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create temporary textarea element
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "-9999px"
|
||||
textArea.style.opacity = "0"
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const success = document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (success) {
|
||||
log.info("Copied text using execCommand fallback")
|
||||
return true
|
||||
} else {
|
||||
log.error("execCommand copy failed")
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Clipboard fallback failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
83
packages/ui/src/lib/contexts/instance-metadata-context.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, JSX, createContext, createEffect, createMemo, createSignal, useContext, type Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { getInstanceMetadata } from "../../stores/instance-metadata"
|
||||
import { loadInstanceMetadata, hasMetadataLoaded } from "../hooks/use-instance-metadata"
|
||||
|
||||
interface InstanceMetadataContextValue {
|
||||
isLoading: Accessor<boolean>
|
||||
instance: Accessor<Instance>
|
||||
metadata: Accessor<Instance["metadata"] | undefined>
|
||||
refreshMetadata: () => Promise<void>
|
||||
}
|
||||
|
||||
const InstanceMetadataContext = createContext<InstanceMetadataContextValue | null>(null)
|
||||
|
||||
interface InstanceMetadataProviderProps {
|
||||
instance: Instance
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const InstanceMetadataProvider: Component<InstanceMetadataProviderProps> = (props) => {
|
||||
const resolvedInstance = createMemo(() => instances().get(props.instance.id) ?? props.instance)
|
||||
const [isLoading, setIsLoading] = createSignal(true)
|
||||
|
||||
const ensureMetadata = async (force = false) => {
|
||||
const current = resolvedInstance()
|
||||
if (!current) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const cachedMetadata = getInstanceMetadata(current.id) ?? current.metadata
|
||||
if (!force && hasMetadataLoaded(cachedMetadata)) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
await loadInstanceMetadata(current, { force })
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const current = resolvedInstance()
|
||||
if (!current) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const tracked = getInstanceMetadata(current.id) ?? current.metadata
|
||||
if (!tracked || !hasMetadataLoaded(tracked)) {
|
||||
void ensureMetadata()
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
const contextValue: InstanceMetadataContextValue = {
|
||||
isLoading,
|
||||
instance: resolvedInstance,
|
||||
metadata: () => getInstanceMetadata(resolvedInstance().id) ?? resolvedInstance().metadata,
|
||||
refreshMetadata: () => ensureMetadata(true),
|
||||
}
|
||||
|
||||
return (
|
||||
<InstanceMetadataContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</InstanceMetadataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useInstanceMetadataContext(): InstanceMetadataContextValue {
|
||||
const ctx = useContext(InstanceMetadataContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useInstanceMetadataContext must be used within InstanceMetadataProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useOptionalInstanceMetadataContext(): InstanceMetadataContextValue | null {
|
||||
return useContext(InstanceMetadataContext)
|
||||
}
|
||||
@@ -5,10 +5,16 @@ export interface CacheEntryBaseParams {
|
||||
}
|
||||
|
||||
export interface CacheEntryParams extends CacheEntryBaseParams {
|
||||
key: string
|
||||
cacheId: string
|
||||
version: string
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, unknown>
|
||||
type VersionedCacheEntry = {
|
||||
version: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type CacheValueMap = Map<string, VersionedCacheEntry>
|
||||
type CacheScopeMap = Map<string, CacheValueMap>
|
||||
type CacheSessionMap = Map<string, CacheScopeMap>
|
||||
|
||||
@@ -83,18 +89,22 @@ export function setCacheEntry<T>(params: CacheEntryParams, value: T | undefined)
|
||||
|
||||
if (value === undefined) {
|
||||
const existingMap = getScopeValueMap(params, false)
|
||||
existingMap?.delete(params.key)
|
||||
existingMap?.delete(params.cacheId)
|
||||
cleanupHierarchy(instanceKey, sessionKey, params.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const scopeEntries = getScopeValueMap(params, true)
|
||||
scopeEntries?.set(params.key, value)
|
||||
scopeEntries?.set(params.cacheId, { version: params.version, value })
|
||||
}
|
||||
|
||||
export function getCacheEntry<T>(params: CacheEntryParams): T | undefined {
|
||||
const scopeEntries = getScopeValueMap(params, false)
|
||||
return scopeEntries?.get(params.key) as T | undefined
|
||||
const entry = scopeEntries?.get(params.cacheId)
|
||||
if (!entry || entry.version !== params.version) {
|
||||
return undefined
|
||||
}
|
||||
return entry.value as T
|
||||
}
|
||||
|
||||
export function clearCacheScope(params: CacheEntryBaseParams): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import { getLogger } from "../logger"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -44,49 +45,29 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
registerNavigationShortcuts()
|
||||
registerInputShortcuts(
|
||||
() => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = document.querySelector(
|
||||
".session-cache-pane[aria-hidden=\"false\"] .prompt-input",
|
||||
) as HTMLTextAreaElement
|
||||
if (textarea) textarea.value = ""
|
||||
},
|
||||
() => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = document.querySelector(
|
||||
".session-cache-pane[aria-hidden=\"false\"] .prompt-input",
|
||||
) as HTMLTextAreaElement
|
||||
textarea?.focus()
|
||||
},
|
||||
)
|
||||
|
||||
registerAgentShortcuts(
|
||||
() => {
|
||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
|
||||
},
|
||||
() => {
|
||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -11,16 +11,18 @@ import {
|
||||
getSessions,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import { cleanupBlankSessions } from "../../stores/session-state"
|
||||
import { getLogger } from "../logger"
|
||||
import { requestData } from "../opencode-api"
|
||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
@@ -191,7 +193,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
||||
if (ids[next]) {
|
||||
setActiveSession(instanceId, ids[next])
|
||||
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,7 +217,10 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
||||
if (ids[prev]) {
|
||||
setActiveSession(instanceId, ids[prev])
|
||||
emitSessionSidebarRequest({ instanceId, action: "show-session-list" })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -232,16 +240,15 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
if (!session) return
|
||||
|
||||
try {
|
||||
setSessionCompactionState(instance.id, sessionId, true)
|
||||
await instance.client.session.summarize({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
await requestData(
|
||||
instance.client.session.summarize({
|
||||
sessionID: sessionId,
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
})
|
||||
}),
|
||||
"session.summarize",
|
||||
)
|
||||
} catch (error) {
|
||||
setSessionCompactionState(instance.id, sessionId, false)
|
||||
log.error("Failed to compact session", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
showAlertDialog(`Compact failed: ${message}`, {
|
||||
@@ -253,6 +260,22 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
function escapeCss(value: string) {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as any).escape === "function") {
|
||||
return (CSS as any).escape(value)
|
||||
}
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")
|
||||
}
|
||||
|
||||
function findVisiblePromptTextarea(sessionId?: string): HTMLTextAreaElement | null {
|
||||
if (typeof document === "undefined") return null
|
||||
const base = ".session-cache-pane[aria-hidden=\"false\"]"
|
||||
const selector = sessionId
|
||||
? `${base}[data-session-id=\"${escapeCss(sessionId)}\"] .prompt-input`
|
||||
: `${base} .prompt-input`
|
||||
return document.querySelector(selector) as HTMLTextAreaElement | null
|
||||
}
|
||||
|
||||
commandRegistry.register({
|
||||
id: "undo",
|
||||
label: "Undo Last Message",
|
||||
@@ -308,10 +331,13 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: sessionId },
|
||||
body: { messageID },
|
||||
})
|
||||
await requestData(
|
||||
instance.client.session.revert({
|
||||
sessionID: sessionId,
|
||||
messageID,
|
||||
}),
|
||||
"session.revert",
|
||||
)
|
||||
|
||||
if (!restoredText) {
|
||||
const fallbackRecord = store.getMessage(messageID)
|
||||
@@ -319,7 +345,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
}
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = findVisiblePromptTextarea(sessionId)
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
@@ -345,21 +371,9 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
keywords: ["model", "llm", "ai"],
|
||||
shortcut: { key: "M", meta: true, shift: true },
|
||||
action: () => {
|
||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-model-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -371,21 +385,9 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
keywords: ["agent", "mode"],
|
||||
shortcut: { key: "A", meta: true, shift: true },
|
||||
action: () => {
|
||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -397,7 +399,7 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
keywords: ["clear", "reset"],
|
||||
shortcut: { key: "K", meta: true },
|
||||
action: () => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
const textarea = findVisiblePromptTextarea()
|
||||
if (textarea) textarea.value = ""
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,8 +18,9 @@ export function useGlobalCache(params: UseGlobalCacheParams): GlobalCacheHandle
|
||||
const instanceId = normalizeId(resolveValue(params.instanceId))
|
||||
const sessionId = normalizeId(resolveValue(params.sessionId))
|
||||
const scope = resolveValue(params.scope)
|
||||
const key = resolveValue(params.key)
|
||||
return { instanceId, sessionId, scope, key }
|
||||
const cacheId = resolveValue(params.cacheId)
|
||||
const version = String(resolveValue(params.version))
|
||||
return { instanceId, sessionId, scope, cacheId, version }
|
||||
})
|
||||
|
||||
const scopeParams = createMemo(() => {
|
||||
@@ -73,7 +74,8 @@ interface UseGlobalCacheParams {
|
||||
instanceId?: MaybeAccessor<string | undefined>
|
||||
sessionId?: MaybeAccessor<string | undefined>
|
||||
scope: MaybeAccessor<string>
|
||||
key: MaybeAccessor<string>
|
||||
cacheId: MaybeAccessor<string>
|
||||
version: MaybeAccessor<string | number>
|
||||
}
|
||||
|
||||
interface GlobalCacheHandle {
|
||||
|
||||
83
packages/ui/src/lib/hooks/use-instance-metadata.ts
Normal file
83
packages/ui/src/lib/hooks/use-instance-metadata.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { Instance, RawMcpStatus } from "../../types/instance"
|
||||
import { fetchLspStatus } from "../../stores/instances"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import { getInstanceMetadata, mergeInstanceMetadata } from "../../stores/instance-metadata"
|
||||
|
||||
const log = getLogger("session")
|
||||
const pendingMetadataRequests = new Set<string>()
|
||||
|
||||
function hasMetadataLoaded(metadata?: Instance["metadata"]): boolean {
|
||||
if (!metadata) return false
|
||||
return "project" in metadata && "mcpStatus" in metadata && "lspStatus" in metadata && "plugins" in metadata
|
||||
}
|
||||
|
||||
export async function loadInstanceMetadata(instance: Instance, options?: { force?: boolean }): Promise<void> {
|
||||
const client = instance.client
|
||||
if (!client) {
|
||||
log.warn("[metadata] Skipping fetch; client missing", { instanceId: instance.id })
|
||||
return
|
||||
}
|
||||
|
||||
const currentMetadata = getInstanceMetadata(instance.id) ?? instance.metadata
|
||||
if (!options?.force && hasMetadataLoaded(currentMetadata)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingMetadataRequests.has(instance.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingMetadataRequests.add(instance.id)
|
||||
|
||||
try {
|
||||
const [projectResult, mcpResult, lspResult, configResult] = await Promise.allSettled([
|
||||
client.project.current(),
|
||||
client.mcp.status(),
|
||||
fetchLspStatus(instance.id),
|
||||
client.config.get(),
|
||||
])
|
||||
|
||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
||||
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
||||
const config = configResult.status === "fulfilled" ? (configResult.value.data as { plugin?: unknown } | undefined) : undefined
|
||||
const plugins = Array.isArray(config?.plugin)
|
||||
? (config?.plugin as string[]).map((plugin) =>
|
||||
plugin.startsWith("file://") ? plugin.slice("file://".length) : plugin,
|
||||
)
|
||||
: undefined
|
||||
|
||||
const updates: Instance["metadata"] = { ...(currentMetadata ?? {}) }
|
||||
|
||||
if (projectResult.status === "fulfilled") {
|
||||
updates.project = project ?? null
|
||||
}
|
||||
|
||||
if (mcpResult.status === "fulfilled") {
|
||||
updates.mcpStatus = mcpStatus ?? {}
|
||||
}
|
||||
|
||||
if (lspResult.status === "fulfilled") {
|
||||
updates.lspStatus = lspStatus ?? []
|
||||
}
|
||||
|
||||
if (configResult.status === "fulfilled") {
|
||||
updates.plugins = plugins ?? []
|
||||
}
|
||||
|
||||
if (!updates?.version && instance.binaryVersion) {
|
||||
updates.version = instance.binaryVersion
|
||||
}
|
||||
|
||||
|
||||
mergeInstanceMetadata(instance.id, updates)
|
||||
} catch (error) {
|
||||
log.error("Failed to load instance metadata", error)
|
||||
} finally {
|
||||
pendingMetadataRequests.delete(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
export { hasMetadataLoaded }
|
||||
|
||||
|
||||
37
packages/ui/src/lib/opencode-api.ts
Normal file
37
packages/ui/src/lib/opencode-api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export class OpencodeApiError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message)
|
||||
this.name = "OpencodeApiError"
|
||||
if (options && "cause" in options) {
|
||||
;(this as any).cause = options.cause
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RequestResultLike<T> =
|
||||
| {
|
||||
data: T
|
||||
error?: undefined
|
||||
}
|
||||
| {
|
||||
data?: undefined
|
||||
error: unknown
|
||||
}
|
||||
|
||||
export async function requestData<T>(
|
||||
promise: Promise<RequestResultLike<T> | undefined>,
|
||||
label: string,
|
||||
): Promise<T> {
|
||||
const result = await promise
|
||||
if (!result) {
|
||||
throw new OpencodeApiError(`${label} returned no result`)
|
||||
}
|
||||
if ((result as any).error) {
|
||||
throw new OpencodeApiError(`${label} failed`, { cause: (result as any).error })
|
||||
}
|
||||
return (result as any).data as T
|
||||
}
|
||||
|
||||
export type { OpencodeClient }
|
||||
@@ -1,18 +1,20 @@
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { CODENOMAD_API_BASE } from "./api-client"
|
||||
|
||||
class SDKManager {
|
||||
private clients = new Map<string, OpencodeClient>()
|
||||
|
||||
createClient(instanceId: string, proxyPath: string): OpencodeClient {
|
||||
if (this.clients.has(instanceId)) {
|
||||
return this.clients.get(instanceId)!
|
||||
const existing = this.clients.get(instanceId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const baseUrl = buildInstanceBaseUrl(proxyPath)
|
||||
const client = createOpencodeClient({ baseUrl })
|
||||
|
||||
this.clients.set(instanceId, client)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -29,6 +31,8 @@ class SDKManager {
|
||||
}
|
||||
}
|
||||
|
||||
export type { OpencodeClient }
|
||||
|
||||
function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
const normalized = normalizeProxyPath(proxyPath)
|
||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
||||
|
||||
13
packages/ui/src/lib/session-sidebar-events.ts
Normal file
13
packages/ui/src/lib/session-sidebar-events.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||
|
||||
export interface SessionSidebarRequestDetail {
|
||||
instanceId: string
|
||||
action: SessionSidebarRequestAction
|
||||
}
|
||||
|
||||
export const SESSION_SIDEBAR_EVENT = "opencode:session-sidebar-request"
|
||||
|
||||
export function emitSessionSidebarRequest(detail: SessionSidebarRequestDetail) {
|
||||
if (typeof window === "undefined") return
|
||||
window.dispatchEvent(new CustomEvent<SessionSidebarRequestDetail>(SESSION_SIDEBAR_EVENT, { detail }))
|
||||
}
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventLspUpdated,
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
EventSessionStatus,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { serverEvents } from "./server-events"
|
||||
import type {
|
||||
BackgroundProcess,
|
||||
InstanceStreamEvent,
|
||||
InstanceStreamStatus,
|
||||
WorkspaceEventPayload,
|
||||
@@ -37,6 +38,20 @@ interface TuiToastEvent {
|
||||
}
|
||||
}
|
||||
|
||||
interface BackgroundProcessUpdatedEvent {
|
||||
type: "background.process.updated"
|
||||
properties: {
|
||||
process: BackgroundProcess
|
||||
}
|
||||
}
|
||||
|
||||
interface BackgroundProcessRemovedEvent {
|
||||
type: "background.process.removed"
|
||||
properties: {
|
||||
processId: string
|
||||
}
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
| MessageUpdateEvent
|
||||
| MessageRemovedEvent
|
||||
@@ -46,10 +61,12 @@ type SSEEvent =
|
||||
| EventSessionCompacted
|
||||
| EventSessionError
|
||||
| EventSessionIdle
|
||||
| EventPermissionUpdated
|
||||
| EventPermissionReplied
|
||||
| { type: "permission.updated" | "permission.asked"; properties?: any }
|
||||
| { type: "permission.replied"; properties?: any }
|
||||
| EventLspUpdated
|
||||
| TuiToastEvent
|
||||
| BackgroundProcessUpdatedEvent
|
||||
| BackgroundProcessRemovedEvent
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
@@ -117,15 +134,25 @@ class SSEManager {
|
||||
case "session.idle":
|
||||
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
|
||||
break
|
||||
case "session.status":
|
||||
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
|
||||
break
|
||||
case "permission.updated":
|
||||
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
|
||||
case "permission.asked":
|
||||
this.onPermissionUpdated?.(instanceId, event as any)
|
||||
break
|
||||
case "permission.replied":
|
||||
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
|
||||
this.onPermissionReplied?.(instanceId, event as any)
|
||||
break
|
||||
case "lsp.updated":
|
||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||
break
|
||||
case "background.process.updated":
|
||||
this.onBackgroundProcessUpdated?.(instanceId, event as BackgroundProcessUpdatedEvent)
|
||||
break
|
||||
case "background.process.removed":
|
||||
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
|
||||
break
|
||||
default:
|
||||
log.warn("Unknown SSE event type", { type: event.type })
|
||||
}
|
||||
@@ -148,9 +175,12 @@ class SSEManager {
|
||||
onSessionError?: (instanceId: string, event: EventSessionError) => void
|
||||
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
|
||||
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
||||
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
||||
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
||||
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
|
||||
onPermissionUpdated?: (instanceId: string, event: any) => void
|
||||
onPermissionReplied?: (instanceId: string, event: any) => void
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): ConnectionStatus | null {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
|
||||
import { createContext, createEffect, createMemo, createSignal, onMount, useContext, type JSX } from "solid-js"
|
||||
import { createTheme, ThemeProvider as MuiThemeProvider } from "@suid/material/styles"
|
||||
import CssBaseline from "@suid/material/CssBaseline"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface ThemeContextValue {
|
||||
@@ -10,6 +12,7 @@ interface ThemeContextValue {
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
|
||||
function applyTheme(dark: boolean) {
|
||||
if (typeof document === "undefined") return
|
||||
if (dark) {
|
||||
document.documentElement.setAttribute("data-theme", "dark")
|
||||
return
|
||||
@@ -18,8 +21,61 @@ function applyTheme(dark: boolean) {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
}
|
||||
|
||||
interface ResolvedPaletteColors {
|
||||
backgroundDefault: string
|
||||
backgroundPaper: string
|
||||
primary: string
|
||||
primaryContrast: string
|
||||
textPrimary: string
|
||||
textSecondary: string
|
||||
divider: string
|
||||
}
|
||||
|
||||
const lightPaletteFallbacks: ResolvedPaletteColors = {
|
||||
backgroundDefault: "#ffffff",
|
||||
backgroundPaper: "#f5f5f5",
|
||||
primary: "#0066ff",
|
||||
primaryContrast: "#ffffff",
|
||||
textPrimary: "#1a1a1a",
|
||||
textSecondary: "#666666",
|
||||
divider: "#e0e0e0",
|
||||
}
|
||||
|
||||
const darkPaletteFallbacks: ResolvedPaletteColors = {
|
||||
backgroundDefault: "#1a1a1a",
|
||||
backgroundPaper: "#2a2a2a",
|
||||
primary: "#0080ff",
|
||||
primaryContrast: "#1a1a1a",
|
||||
textPrimary: "#cfd4dc",
|
||||
textSecondary: "#999999",
|
||||
divider: "#3a3a3a",
|
||||
}
|
||||
|
||||
const readCssVar = (token: string, fallback: string, rootStyle: CSSStyleDeclaration | null) => {
|
||||
if (!rootStyle) return fallback
|
||||
const value = rootStyle.getPropertyValue(token)
|
||||
if (!value) return fallback
|
||||
const trimmed = value.trim()
|
||||
return trimmed || fallback
|
||||
}
|
||||
|
||||
const resolvePaletteColors = (dark: boolean): ResolvedPaletteColors => {
|
||||
const fallbackSet = dark ? darkPaletteFallbacks : lightPaletteFallbacks
|
||||
const rootStyle = typeof window !== "undefined" ? getComputedStyle(document.documentElement) : null
|
||||
|
||||
return {
|
||||
backgroundDefault: readCssVar("--surface-base", fallbackSet.backgroundDefault, rootStyle),
|
||||
backgroundPaper: readCssVar("--surface-secondary", fallbackSet.backgroundPaper, rootStyle),
|
||||
primary: readCssVar("--accent-primary", fallbackSet.primary, rootStyle),
|
||||
primaryContrast: readCssVar("--text-inverted", fallbackSet.primaryContrast, rootStyle),
|
||||
textPrimary: readCssVar("--text-primary", fallbackSet.textPrimary, rootStyle),
|
||||
textSecondary: readCssVar("--text-secondary", fallbackSet.textSecondary, rootStyle),
|
||||
divider: readCssVar("--border-base", fallbackSet.divider, rootStyle),
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const mediaQuery = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||
|
||||
@@ -39,14 +95,15 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!mediaQuery) return
|
||||
const handleSystemThemeChange = () => {
|
||||
applyResolvedTheme()
|
||||
}
|
||||
|
||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange)
|
||||
|
||||
return () => {
|
||||
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
|
||||
mediaQuery.removeEventListener("change", handleSystemThemeChange)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -58,7 +115,73 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
setTheme(true)
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
||||
const muiTheme = createMemo(() => {
|
||||
const paletteColors = resolvePaletteColors(isDark())
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: isDark() ? "dark" : "light",
|
||||
primary: {
|
||||
main: paletteColors.primary,
|
||||
contrastText: paletteColors.primaryContrast,
|
||||
},
|
||||
secondary: {
|
||||
main: paletteColors.primary,
|
||||
},
|
||||
background: {
|
||||
default: paletteColors.backgroundDefault,
|
||||
paper: paletteColors.backgroundPaper,
|
||||
},
|
||||
text: {
|
||||
primary: paletteColors.textPrimary,
|
||||
secondary: paletteColors.textSecondary,
|
||||
},
|
||||
divider: paletteColors.divider,
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "var(--font-family-sans)",
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: paletteColors.backgroundPaper,
|
||||
color: paletteColors.textPrimary,
|
||||
boxShadow: "none",
|
||||
borderBottom: `1px solid ${paletteColors.divider}`,
|
||||
zIndex: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiToolbar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minHeight: "56px",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>
|
||||
<MuiThemeProvider theme={muiTheme()}>
|
||||
<CssBaseline />
|
||||
{props.children}
|
||||
</MuiThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
|
||||
66
packages/ui/src/stores/background-processes.ts
Normal file
66
packages/ui/src/stores/background-processes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { BackgroundProcess } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
const [backgroundProcesses, setBackgroundProcesses] = createSignal<Map<string, BackgroundProcess[]>>(new Map())
|
||||
|
||||
function setProcesses(instanceId: string, processes: BackgroundProcess[]) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, processes)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function updateProcess(instanceId: string, process: BackgroundProcess) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = next.get(instanceId) ?? []
|
||||
const index = current.findIndex((entry) => entry.id === process.id)
|
||||
const updated = index >= 0 ? [...current.slice(0, index), process, ...current.slice(index + 1)] : [...current, process]
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeProcess(instanceId: string, processId: string) {
|
||||
setBackgroundProcesses((prev) => {
|
||||
const next = new Map(prev)
|
||||
const current = next.get(instanceId) ?? []
|
||||
next.set(
|
||||
instanceId,
|
||||
current.filter((entry) => entry.id !== processId),
|
||||
)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function loadBackgroundProcesses(instanceId: string) {
|
||||
const response = await serverApi.listBackgroundProcesses(instanceId)
|
||||
setProcesses(instanceId, response.processes)
|
||||
}
|
||||
|
||||
function getBackgroundProcesses(instanceId: string): BackgroundProcess[] {
|
||||
return backgroundProcesses().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
sseManager.onBackgroundProcessUpdated = (instanceId, event) => {
|
||||
const process = event.properties?.process
|
||||
if (!process) return
|
||||
updateProcess(instanceId, process)
|
||||
}
|
||||
|
||||
sseManager.onBackgroundProcessRemoved = (instanceId, event) => {
|
||||
const processId = event.properties?.processId
|
||||
if (!processId) return
|
||||
removeProcess(instanceId, processId)
|
||||
}
|
||||
|
||||
export {
|
||||
backgroundProcesses,
|
||||
getBackgroundProcesses,
|
||||
loadBackgroundProcesses,
|
||||
removeProcess as removeBackgroundProcess,
|
||||
updateProcess as updateBackgroundProcess,
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
|
||||
|
||||
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
|
||||
const response = await client.command.list()
|
||||
const commands = response.data ?? []
|
||||
const commands = await requestData<SDKCommand[]>(client.command.list(), "command.list").catch(() => [])
|
||||
setCommandMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, commands)
|
||||
|
||||
35
packages/ui/src/stores/instance-metadata.ts
Normal file
35
packages/ui/src/stores/instance-metadata.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { InstanceMetadata } from "../types/instance"
|
||||
|
||||
const [metadataMap, setMetadataMap] = createSignal<Map<string, InstanceMetadata | undefined>>(new Map())
|
||||
|
||||
function getInstanceMetadata(instanceId: string): InstanceMetadata | undefined {
|
||||
return metadataMap().get(instanceId)
|
||||
}
|
||||
|
||||
function setInstanceMetadata(instanceId: string, metadata: InstanceMetadata | undefined): void {
|
||||
setMetadataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (metadata === undefined) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, metadata)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function mergeInstanceMetadata(instanceId: string, updates: InstanceMetadata): void {
|
||||
setMetadataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(instanceId) ?? {}
|
||||
next.set(instanceId, { ...existing, ...updates })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstanceMetadata(instanceId: string): void {
|
||||
setInstanceMetadata(instanceId, undefined)
|
||||
}
|
||||
|
||||
export { metadataMap, getInstanceMetadata, setInstanceMetadata, mergeInstanceMetadata, clearInstanceMetadata }
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2"
|
||||
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
@@ -18,8 +21,10 @@ import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -30,9 +35,14 @@ const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(ne
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
// Permission queue management per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
|
||||
function syncHasInstancesFlag() {
|
||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||
setHasInstances(readyExists)
|
||||
}
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
@@ -67,7 +77,6 @@ function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready") {
|
||||
@@ -116,6 +125,37 @@ function releaseInstanceResources(instanceId: string) {
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
|
||||
async function syncPendingPermissions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
|
||||
try {
|
||||
const remote = await requestData<PermissionRequestLike[]>(
|
||||
instance.client.permission.list(),
|
||||
"permission.list",
|
||||
)
|
||||
|
||||
const remoteIds = new Set(remote.map((item) => item.id))
|
||||
const local = getPermissionQueue(instanceId)
|
||||
|
||||
// Remove any stale local permissions missing from server.
|
||||
for (const entry of local) {
|
||||
if (!remoteIds.has(entry.id)) {
|
||||
removePermissionFromQueue(instanceId, entry.id)
|
||||
removePermissionV2(instanceId, entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert all server-side pending permissions.
|
||||
for (const permission of remote) {
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to sync pending permissions", { instanceId, error })
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
@@ -125,6 +165,7 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
await syncPendingPermissions(instanceId)
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch initial data", error)
|
||||
}
|
||||
@@ -134,9 +175,6 @@ void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
@@ -158,9 +196,6 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
@@ -248,6 +283,7 @@ function addInstance(instance: Instance) {
|
||||
})
|
||||
ensureLogContainer(instance.id)
|
||||
ensureLogStreamingState(instance.id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
@@ -259,6 +295,7 @@ function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
}
|
||||
return next
|
||||
})
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function removeInstance(id: string) {
|
||||
@@ -290,6 +327,7 @@ function removeInstance(id: string) {
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
clearPermissionQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
@@ -299,6 +337,7 @@ function removeInstance(id: string) {
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||
@@ -326,9 +365,6 @@ async function stopInstance(id: string) {
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
@@ -347,8 +383,7 @@ async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefin
|
||||
return undefined
|
||||
}
|
||||
log.info("lsp.status", { instanceId })
|
||||
const response = await lsp.status()
|
||||
return response.data ?? []
|
||||
return await requestData<LspStatus[]>(lsp.status(), "lsp.status")
|
||||
}
|
||||
|
||||
function getActiveInstance(): Instance | null {
|
||||
@@ -382,7 +417,7 @@ function clearLogs(id: string) {
|
||||
}
|
||||
|
||||
// Permission management functions
|
||||
function getPermissionQueue(instanceId: string): Permission[] {
|
||||
function getPermissionQueue(instanceId: string): PermissionRequestLike[] {
|
||||
const queue = permissionQueues().get(instanceId)
|
||||
if (!queue) {
|
||||
return []
|
||||
@@ -429,7 +464,7 @@ function clearSessionPendingCounts(instanceId: string): void {
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
|
||||
let inserted = false
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
@@ -440,7 +475,7 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
|
||||
return next
|
||||
}
|
||||
|
||||
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
||||
const updatedQueue = [...queue, permission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b))
|
||||
next.set(instanceId, updatedQueue)
|
||||
inserted = true
|
||||
return next
|
||||
@@ -459,17 +494,19 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
|
||||
})
|
||||
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
if (sessionId) {
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
}
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
let removedPermission: Permission | null = null
|
||||
let removedPermission: PermissionRequestLike | null = null
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
const filtered: Permission[] = []
|
||||
const filtered: PermissionRequestLike[] = []
|
||||
|
||||
for (const item of queue) {
|
||||
if (item.id === permissionId) {
|
||||
@@ -493,7 +530,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
const next = new Map(prev)
|
||||
const activeId = next.get(instanceId)
|
||||
if (activeId === permissionId) {
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
@@ -502,8 +539,10 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
if (removedSessionId) {
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,15 +560,13 @@ function clearPermissionQueue(instanceId: string): void {
|
||||
clearSessionPendingCounts(instanceId)
|
||||
}
|
||||
|
||||
function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
|
||||
async function sendPermissionResponse(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject"
|
||||
requestId: string,
|
||||
reply: PermissionReply
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
@@ -537,13 +574,16 @@ async function sendPermissionResponse(
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: permissionId },
|
||||
body: { response },
|
||||
})
|
||||
await requestData(
|
||||
instance.client.permission.reply({
|
||||
requestID: requestId,
|
||||
reply,
|
||||
}),
|
||||
"permission.reply",
|
||||
)
|
||||
|
||||
// Remove from queue after successful response
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
removePermissionFromQueue(instanceId, requestId)
|
||||
} catch (error) {
|
||||
log.error("Failed to send permission response", error)
|
||||
throw error
|
||||
@@ -570,17 +610,7 @@ sseManager.onLspUpdated = async (instanceId) => {
|
||||
if (!lspStatus) {
|
||||
return
|
||||
}
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
log.warn("[LSP] Instance disappeared before metadata update", { instanceId })
|
||||
return
|
||||
}
|
||||
updateInstance(instanceId, {
|
||||
metadata: {
|
||||
...(instance.metadata ?? {}),
|
||||
lspStatus,
|
||||
},
|
||||
})
|
||||
mergeInstanceMetadata(instanceId, { lspStatus })
|
||||
} catch (error) {
|
||||
log.error("Failed to refresh LSP status", error)
|
||||
}
|
||||
@@ -598,9 +628,6 @@ async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
log.error("Failed to stop disconnected instance", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
|
||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||
import type { Session } from "../../types/session"
|
||||
import { messageStoreBus } from "./bus"
|
||||
@@ -107,42 +108,108 @@ export function replaceMessageIdV2(instanceId: string, oldId: string, newId: str
|
||||
store.replaceMessageId({ oldId, newId })
|
||||
}
|
||||
|
||||
function extractPermissionMessageId(permission: Permission): string | undefined {
|
||||
return (permission as any).messageID || (permission as any).messageId
|
||||
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {
|
||||
return getPermissionMessageId(permission)
|
||||
}
|
||||
|
||||
function extractPermissionPartId(permission: Permission): string | undefined {
|
||||
function extractPermissionPartId(permission: PermissionRequestLike): string | undefined {
|
||||
const metadata = (permission as any).metadata || {}
|
||||
return (
|
||||
(permission as any).callID ||
|
||||
(permission as any).callId ||
|
||||
(permission as any).toolCallID ||
|
||||
(permission as any).toolCallId ||
|
||||
metadata.partId ||
|
||||
(permission as any).partID ||
|
||||
(permission as any).partId ||
|
||||
metadata.partID ||
|
||||
metadata.callID ||
|
||||
metadata.callId ||
|
||||
metadata.partId ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function upsertPermissionV2(instanceId: string, permission: Permission): void {
|
||||
function extractPermissionCallId(permission: PermissionRequestLike): string | undefined {
|
||||
return getPermissionCallId(permission)
|
||||
}
|
||||
|
||||
function resolvePartIdFromCallId(store: ReturnType<typeof messageStoreBus.getOrCreate>, messageId?: string, callId?: string): string | undefined {
|
||||
if (!messageId || !callId) return undefined
|
||||
const record = store.getMessage(messageId)
|
||||
if (!record) return undefined
|
||||
for (const partId of record.partIds) {
|
||||
const part = record.parts[partId]?.data
|
||||
if (!part || part.type !== "tool") continue
|
||||
const toolCallId =
|
||||
(part as any).callID ??
|
||||
(part as any).callId ??
|
||||
(part as any).toolCallID ??
|
||||
(part as any).toolCallId ??
|
||||
undefined
|
||||
if (toolCallId === callId && typeof part.id === "string" && part.id.length > 0) {
|
||||
return part.id
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function upsertPermissionV2(instanceId: string, permission: PermissionRequestLike): void {
|
||||
if (!permission) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageId = extractPermissionMessageId(permission)
|
||||
let partId = extractPermissionPartId(permission)
|
||||
if (!partId) {
|
||||
const callId = extractPermissionCallId(permission)
|
||||
partId = resolvePartIdFromCallId(store, messageId, callId)
|
||||
}
|
||||
store.upsertPermission({
|
||||
permission,
|
||||
messageId: extractPermissionMessageId(permission),
|
||||
partId: extractPermissionPartId(permission),
|
||||
messageId,
|
||||
partId,
|
||||
enqueuedAt: (permission as any).time?.created ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: string): void {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const pending = store.state.permissions.queue
|
||||
if (!pending || pending.length === 0) return
|
||||
|
||||
for (const entry of pending) {
|
||||
if (!entry || entry.partId) continue
|
||||
const permission = entry.permission
|
||||
if (!permission) continue
|
||||
|
||||
const permissionSessionId = (permission as any)?.sessionID ?? (permission as any)?.sessionId ?? undefined
|
||||
if (sessionId && permissionSessionId && permissionSessionId !== sessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messageId = entry.messageId ?? extractPermissionMessageId(permission)
|
||||
const callId = extractPermissionCallId(permission)
|
||||
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
|
||||
if (!resolvedPartId) continue
|
||||
|
||||
store.upsertPermission({
|
||||
...entry,
|
||||
messageId,
|
||||
partId: resolvedPartId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||
if (!permissionId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.removePermission(permissionId)
|
||||
}
|
||||
|
||||
export function removeMessageV2(instanceId: string, messageId: string): void {
|
||||
if (!messageId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.removeMessage(messageId)
|
||||
}
|
||||
|
||||
export function removeMessagePartV2(instanceId: string, messageId: string, partId: string): void {
|
||||
if (!messageId || !partId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.removeMessagePart(messageId, partId)
|
||||
}
|
||||
|
||||
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
|
||||
if (!session) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||
import type {
|
||||
InstanceMessageState,
|
||||
LatestTodoSnapshot,
|
||||
MessageRecord,
|
||||
MessageUpsertInput,
|
||||
PartUpdateInput,
|
||||
@@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
|
||||
},
|
||||
usage: {},
|
||||
scrollState: {},
|
||||
latestTodos: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +51,8 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
|
||||
return part.id
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part as any).callID ??
|
||||
(part as any).callId ??
|
||||
(part as any).toolCallID ??
|
||||
(part as any).toolCallId ??
|
||||
undefined
|
||||
|
||||
if (part.type === "tool" && typeof toolCallId === "string" && toolCallId.length > 0) {
|
||||
part.id = toolCallId
|
||||
return toolCallId
|
||||
if (part.type === "tool") {
|
||||
throw new Error("Tool part missing id")
|
||||
}
|
||||
|
||||
const fallbackId = `${messageId}-part-${index}`
|
||||
@@ -189,6 +183,8 @@ export interface InstanceMessageStore {
|
||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
||||
upsertMessage: (input: MessageUpsertInput) => void
|
||||
applyPartUpdate: (input: PartUpdateInput) => void
|
||||
removeMessage: (messageId: string) => void
|
||||
removeMessagePart: (messageId: string, partId: string) => void
|
||||
bufferPendingPart: (entry: PendingPartEntry) => void
|
||||
flushPendingParts: (messageId: string) => void
|
||||
replaceMessageId: (options: ReplaceMessageIdOptions) => void
|
||||
@@ -206,6 +202,7 @@ export interface InstanceMessageStore {
|
||||
getSessionRevision: (sessionId: string) => number
|
||||
getSessionMessageIds: (sessionId: string) => string[]
|
||||
getMessage: (messageId: string) => MessageRecord | undefined
|
||||
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
|
||||
clearSession: (sessionId: string) => void
|
||||
clearInstance: () => void
|
||||
}
|
||||
@@ -213,9 +210,55 @@ export interface InstanceMessageStore {
|
||||
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
|
||||
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||
|
||||
const TODO_TOOL_NAME = "todowrite"
|
||||
|
||||
const messageInfoCache = new Map<string, MessageInfo>()
|
||||
|
||||
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
|
||||
if (!part || (part as any).type !== "tool") {
|
||||
return false
|
||||
}
|
||||
const toolName = typeof (part as any).tool === "string" ? (part as any).tool : ""
|
||||
if (toolName !== TODO_TOOL_NAME) {
|
||||
return false
|
||||
}
|
||||
const toolState = (part as any).state
|
||||
if (!toolState || typeof toolState !== "object") {
|
||||
return false
|
||||
}
|
||||
return (toolState as { status?: string }).status === "completed"
|
||||
}
|
||||
|
||||
function recordLatestTodoSnapshot(sessionId: string, snapshot: LatestTodoSnapshot) {
|
||||
if (!sessionId) return
|
||||
setState("latestTodos", sessionId, (existing) => {
|
||||
if (existing && existing.timestamp > snapshot.timestamp) {
|
||||
return existing
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
}
|
||||
|
||||
function maybeUpdateLatestTodoFromRecord(record: MessageRecord | undefined) {
|
||||
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) {
|
||||
return
|
||||
}
|
||||
for (let index = record.partIds.length - 1; index >= 0; index -= 1) {
|
||||
const partId = record.partIds[index]
|
||||
const partRecord = record.parts[partId]
|
||||
if (!partRecord) continue
|
||||
if (isCompletedTodoPart(partRecord.data)) {
|
||||
const timestamp = typeof record.updatedAt === "number" ? record.updatedAt : Date.now()
|
||||
recordLatestTodoSnapshot(record.sessionId, { messageId: record.id, partId, timestamp })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLatestTodoSnapshot(sessionId: string) {
|
||||
setState("latestTodos", sessionId, undefined)
|
||||
}
|
||||
|
||||
function bumpSessionRevision(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
|
||||
@@ -365,6 +408,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
|
||||
Object.values(normalizedRecords).forEach((record) => {
|
||||
maybeUpdateLatestTodoFromRecord(record)
|
||||
})
|
||||
|
||||
bumpSessionRevision(sessionId)
|
||||
})
|
||||
}
|
||||
@@ -405,9 +452,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
||||
const now = Date.now()
|
||||
|
||||
let nextRecord: MessageRecord | undefined
|
||||
|
||||
setState("messages", input.id, (previous) => {
|
||||
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
|
||||
return {
|
||||
const record: MessageRecord = {
|
||||
id: input.id,
|
||||
sessionId: input.sessionId,
|
||||
role: input.role,
|
||||
@@ -419,8 +468,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
||||
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
||||
}
|
||||
nextRecord = record
|
||||
return record
|
||||
})
|
||||
|
||||
if (nextRecord) {
|
||||
maybeUpdateLatestTodoFromRecord(nextRecord)
|
||||
}
|
||||
|
||||
insertMessageIntoSession(input.sessionId, input.id)
|
||||
flushPendingParts(input.id)
|
||||
bumpSessionRevision(input.sessionId)
|
||||
@@ -441,16 +496,62 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
})
|
||||
}
|
||||
|
||||
function rebindPermissionForPart(messageId: string, partId: string, part: ClientPart) {
|
||||
if (!messageId || !partId || part.type !== "tool") {
|
||||
return
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part as any).callID ??
|
||||
(part as any).callId ??
|
||||
(part as any).toolCallID ??
|
||||
(part as any).toolCallId ??
|
||||
undefined
|
||||
if (!toolCallId) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(
|
||||
"permissions",
|
||||
"byMessage",
|
||||
messageId,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const existing = draft[partId]
|
||||
for (const [key, entry] of Object.entries(draft)) {
|
||||
if (!entry || entry.partId) continue
|
||||
const permissionCallId =
|
||||
(entry.permission as any).tool?.callID ??
|
||||
(entry.permission as any).tool?.callId ??
|
||||
(entry.permission as any).callID ??
|
||||
(entry.permission as any).callId ??
|
||||
(entry.permission as any).toolCallID ??
|
||||
(entry.permission as any).toolCallId ??
|
||||
(entry.permission as any).metadata?.callID ??
|
||||
(entry.permission as any).metadata?.callId ??
|
||||
undefined
|
||||
if (permissionCallId !== toolCallId) continue
|
||||
if (!existing || existing.permission.id === entry.permission.id) {
|
||||
entry.partId = partId
|
||||
draft[partId] = entry
|
||||
delete draft[key]
|
||||
}
|
||||
break
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function applyPartUpdate(input: PartUpdateInput) {
|
||||
const message = state.messages[input.messageId]
|
||||
if (!message) {
|
||||
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
||||
const cloned = clonePart(input.part)
|
||||
|
||||
|
||||
setState(
|
||||
"messages",
|
||||
input.messageId,
|
||||
@@ -459,7 +560,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
draft.partIds = [...draft.partIds, partId]
|
||||
}
|
||||
const existing = draft.parts[partId]
|
||||
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
|
||||
const nextRevision = existing ? existing.revision + 1 : (cloned as any).version ?? 0
|
||||
draft.parts[partId] = {
|
||||
id: partId,
|
||||
data: cloned,
|
||||
@@ -471,12 +572,116 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
rebindPermissionForPart(input.messageId, partId, cloned)
|
||||
|
||||
if (isCompletedTodoPart(cloned)) {
|
||||
recordLatestTodoSnapshot(message.sessionId, {
|
||||
messageId: input.messageId,
|
||||
partId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Any part update can change the rendered height of the message
|
||||
// list, so we treat it as a session revision for scroll purposes.
|
||||
bumpSessionRevision(message.sessionId)
|
||||
}
|
||||
|
||||
function removeMessage(messageId: string) {
|
||||
if (!messageId) return
|
||||
|
||||
const record = state.messages[messageId]
|
||||
const sessionIds = new Set<string>()
|
||||
|
||||
if (record?.sessionId) {
|
||||
sessionIds.add(record.sessionId)
|
||||
} else {
|
||||
Object.values(state.sessions).forEach((session) => {
|
||||
if (session.messageIds.includes(messageId)) {
|
||||
sessionIds.add(session.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
clearRecordDisplayCacheForMessages(instanceId, [messageId])
|
||||
|
||||
batch(() => {
|
||||
sessionIds.forEach((sessionId) => {
|
||||
setState("sessions", sessionId, "messageIds", (ids = []) => ids.filter((id) => id !== messageId))
|
||||
})
|
||||
|
||||
setState("messages", (prev) => {
|
||||
if (!prev[messageId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
|
||||
setState("messageInfoVersion", (prev) => {
|
||||
if (!(messageId in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
|
||||
messageInfoCache.delete(messageId)
|
||||
|
||||
setState("pendingParts", (prev) => {
|
||||
if (!prev[messageId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
|
||||
setState("permissions", "byMessage", (prev) => {
|
||||
if (!prev[messageId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
|
||||
sessionIds.forEach((sessionId) => {
|
||||
withUsageState(sessionId, (draft) => removeUsageEntry(draft, messageId))
|
||||
if (state.latestTodos[sessionId]?.messageId === messageId) {
|
||||
clearLatestTodoSnapshot(sessionId)
|
||||
}
|
||||
bumpSessionRevision(sessionId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function removeMessagePart(messageId: string, partId: string) {
|
||||
if (!messageId || !partId) return
|
||||
const message = state.messages[messageId]
|
||||
if (!message) return
|
||||
|
||||
clearRecordDisplayCacheForMessages(instanceId, [messageId])
|
||||
|
||||
batch(() => {
|
||||
setState(
|
||||
"messages",
|
||||
messageId,
|
||||
produce((draft: MessageRecord) => {
|
||||
if (!draft.parts[partId] && !draft.partIds.includes(partId)) return
|
||||
draft.partIds = draft.partIds.filter((id) => id !== partId)
|
||||
delete draft.parts[partId]
|
||||
draft.updatedAt = Date.now()
|
||||
draft.revision += 1
|
||||
}),
|
||||
)
|
||||
|
||||
setState("permissions", "byMessage", messageId, (prev) => {
|
||||
if (!prev || !prev[partId]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[partId]
|
||||
return next
|
||||
})
|
||||
|
||||
bumpSessionRevision(message.sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function flushPendingParts(messageId: string) {
|
||||
const pending = state.pendingParts[messageId]
|
||||
@@ -557,6 +762,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
setState("pendingParts", options.newId, pending)
|
||||
}
|
||||
clearPendingPartsForMessage(options.oldId)
|
||||
maybeUpdateLatestTodoFromRecord(cloned)
|
||||
}
|
||||
|
||||
function setMessageInfo(messageId: string, info: MessageInfo) {
|
||||
@@ -574,7 +780,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
|
||||
function upsertPermission(entry: PermissionEntry) {
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
|
||||
|
||||
setState(
|
||||
"permissions",
|
||||
@@ -778,6 +984,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
|
||||
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
||||
})
|
||||
|
||||
clearLatestTodoSnapshot(sessionId)
|
||||
|
||||
hooks?.onSessionCleared?.(instanceId, sessionId)
|
||||
}
|
||||
@@ -796,8 +1004,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
addOrUpdateSession,
|
||||
hydrateMessages,
|
||||
upsertMessage,
|
||||
applyPartUpdate,
|
||||
bufferPendingPart,
|
||||
applyPartUpdate,
|
||||
removeMessage,
|
||||
removeMessagePart,
|
||||
bufferPendingPart,
|
||||
flushPendingParts,
|
||||
replaceMessageId,
|
||||
setMessageInfo,
|
||||
@@ -812,10 +1022,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
||||
setScrollSnapshot,
|
||||
getScrollSnapshot,
|
||||
getSessionRevision: getSessionRevisionValue,
|
||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||
getMessage: (messageId: string) => state.messages[messageId],
|
||||
clearSession,
|
||||
clearInstance,
|
||||
}
|
||||
}
|
||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||
getMessage: (messageId: string) => state.messages[messageId],
|
||||
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||
clearSession,
|
||||
clearInstance,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,35 +26,13 @@ function decodeTextSegment(segment: any): any {
|
||||
return segment
|
||||
}
|
||||
|
||||
function deriveToolPartId(part: any): string | undefined {
|
||||
if (!part || typeof part !== "object") {
|
||||
return undefined
|
||||
}
|
||||
if (part.type !== "tool") {
|
||||
return undefined
|
||||
}
|
||||
const callId =
|
||||
part.callID ??
|
||||
part.callId ??
|
||||
part.toolCallID ??
|
||||
part.toolCallId ??
|
||||
undefined
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
return callId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMessagePart(part: any): any {
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
|
||||
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") {
|
||||
const inferredId = deriveToolPartId(part)
|
||||
if (inferredId) {
|
||||
part = { ...part, id: inferredId }
|
||||
}
|
||||
if (part.type === "tool" && (typeof part.id !== "string" || part.id.length === 0)) {
|
||||
throw new Error("Tool part missing id")
|
||||
}
|
||||
|
||||
if (part.type !== "text") {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import type { MessageRecord } from "./types"
|
||||
|
||||
type ClientPartWithRevision = ClientPart & { revision?: number }
|
||||
|
||||
export interface RecordDisplayData {
|
||||
orderedParts: ClientPart[]
|
||||
orderedParts: ClientPartWithRevision[]
|
||||
}
|
||||
|
||||
interface RecordDisplayCacheEntry {
|
||||
@@ -23,12 +25,12 @@ export function buildRecordDisplayData(instanceId: string, record: MessageRecord
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const orderedParts: ClientPart[] = []
|
||||
const orderedParts: ClientPartWithRevision[] = []
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts[partId]
|
||||
if (!entry?.data) continue
|
||||
orderedParts.push(entry.data)
|
||||
orderedParts.push({ ...(entry.data as ClientPart), revision: entry.revision })
|
||||
}
|
||||
|
||||
const data: RecordDisplayData = { orderedParts }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
|
||||
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
||||
export type MessageRole = "user" | "assistant"
|
||||
@@ -47,7 +47,7 @@ export interface PendingPartEntry {
|
||||
}
|
||||
|
||||
export interface PermissionEntry {
|
||||
permission: Permission
|
||||
permission: PermissionRequestLike
|
||||
messageId?: string
|
||||
partId?: string
|
||||
enqueuedAt: number
|
||||
@@ -88,6 +88,12 @@ export interface SessionUsageState {
|
||||
latestMessageId?: string
|
||||
}
|
||||
|
||||
export interface LatestTodoSnapshot {
|
||||
messageId: string
|
||||
partId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface InstanceMessageState {
|
||||
instanceId: string
|
||||
sessions: Record<string, SessionRecord>
|
||||
@@ -99,6 +105,7 @@ export interface InstanceMessageState {
|
||||
permissions: InstancePermissionState
|
||||
usage: Record<string, SessionUsageState>
|
||||
scrollState: Record<string, ScrollSnapshot>
|
||||
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||
}
|
||||
|
||||
export interface SessionUpsertInput {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -178,18 +179,14 @@ async function sendMessage(
|
||||
})
|
||||
|
||||
try {
|
||||
log.info("session.prompt", { instanceId, sessionId, requestBody })
|
||||
const response = await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
log.info("sendMessage response", response)
|
||||
|
||||
if (response.error) {
|
||||
log.error("sendMessage server error", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
await requestData(
|
||||
instance.client.session.promptAsync({
|
||||
sessionID: sessionId,
|
||||
...(requestBody as any),
|
||||
}),
|
||||
"session.promptAsync",
|
||||
)
|
||||
} catch (error) {
|
||||
log.error("Failed to send prompt", error)
|
||||
throw error
|
||||
@@ -232,10 +229,13 @@ async function executeCustomCommand(
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
}
|
||||
|
||||
await instance.client.session.command({
|
||||
path: { id: sessionId },
|
||||
body,
|
||||
})
|
||||
await requestData(
|
||||
instance.client.session.command({
|
||||
sessionID: sessionId,
|
||||
...(body as any),
|
||||
}),
|
||||
"session.command",
|
||||
)
|
||||
}
|
||||
|
||||
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
|
||||
@@ -251,13 +251,14 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
await requestData(
|
||||
instance.client.session.shell({
|
||||
sessionID: sessionId,
|
||||
agent,
|
||||
command,
|
||||
},
|
||||
})
|
||||
}),
|
||||
"session.shell",
|
||||
)
|
||||
}
|
||||
|
||||
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
@@ -270,9 +271,12 @@ async function abortSession(instanceId: string, sessionId: string): Promise<void
|
||||
|
||||
try {
|
||||
log.info("session.abort", { instanceId, sessionId })
|
||||
await instance.client.session.abort({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
await requestData(
|
||||
instance.client.session.abort({
|
||||
sessionID: sessionId,
|
||||
}),
|
||||
"session.abort",
|
||||
)
|
||||
log.info("abortSession complete", { instanceId, sessionId })
|
||||
} catch (error) {
|
||||
log.error("Failed to abort session", error)
|
||||
@@ -334,9 +338,42 @@ async function updateSessionModel(
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const trimmedTitle = nextTitle.trim()
|
||||
if (!trimmedTitle) {
|
||||
throw new Error("Session title is required")
|
||||
}
|
||||
|
||||
await requestData(
|
||||
instance.client.session.update({
|
||||
sessionID: sessionId,
|
||||
title: trimmedTitle,
|
||||
}),
|
||||
"session.update",
|
||||
)
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.title = trimmedTitle
|
||||
const time = { ...(current.time ?? {}) }
|
||||
time.updated = Date.now()
|
||||
current.time = time
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Session } from "../types/session"
|
||||
import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
agents,
|
||||
@@ -23,14 +22,16 @@ import {
|
||||
loading,
|
||||
setLoading,
|
||||
cleanupBlankSessions,
|
||||
syncInstanceSessionIndicator,
|
||||
} from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
||||
import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -77,10 +78,30 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
let statusById: Record<string, any> = {}
|
||||
try {
|
||||
const statusResponse = await instance.client.session.status()
|
||||
if (statusResponse.data && typeof statusResponse.data === "object") {
|
||||
statusById = statusResponse.data as Record<string, any>
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch session status:", error)
|
||||
}
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
const existingStatus = existingSession?.status
|
||||
|
||||
let status: SessionStatus
|
||||
if (existingStatus === "compacting") {
|
||||
status = "compacting"
|
||||
} else {
|
||||
const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle"
|
||||
}
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
@@ -89,6 +110,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
parentId: apiSession.parentID || null,
|
||||
agent: existingSession?.agent ?? "",
|
||||
model: existingSession?.model ?? { providerId: "", modelId: "" },
|
||||
status,
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
...apiSession.time,
|
||||
@@ -112,6 +134,8 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, sessionMap)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
@@ -127,11 +151,6 @@ async function fetchSessions(instanceId: string): Promise<void> {
|
||||
return next
|
||||
})
|
||||
|
||||
for (const session of sessionMap.values()) {
|
||||
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
|
||||
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
|
||||
setSessionCompactionState(instanceId, session.id, active)
|
||||
}
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
} catch (error) {
|
||||
@@ -183,6 +202,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
parentId: null,
|
||||
agent: selectedAgent,
|
||||
model: defaultModel,
|
||||
status: "idle",
|
||||
version: response.data.version,
|
||||
time: {
|
||||
...response.data.time,
|
||||
@@ -205,6 +225,8 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||
@@ -261,25 +283,16 @@ async function forkSession(
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const request: {
|
||||
path: { id: string }
|
||||
body?: { messageID: string }
|
||||
} = {
|
||||
path: { id: sourceSessionId },
|
||||
}
|
||||
|
||||
if (options?.messageId) {
|
||||
request.body = { messageID: options.messageId }
|
||||
const request: { sessionID: string; messageID?: string } = {
|
||||
sessionID: sourceSessionId,
|
||||
messageID: options?.messageId,
|
||||
}
|
||||
|
||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const response = await instance.client.session.fork(request)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to fork session: No data returned")
|
||||
}
|
||||
|
||||
const info = response.data as SessionForkResponse
|
||||
const info = await requestData<SessionForkResponse>(
|
||||
instance.client.session.fork(request),
|
||||
"session.fork",
|
||||
)
|
||||
const forkedSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
@@ -290,6 +303,7 @@ async function forkSession(
|
||||
providerId: info.model?.providerID || "",
|
||||
modelId: info.model?.modelID || "",
|
||||
},
|
||||
status: "idle",
|
||||
version: "0",
|
||||
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
||||
revert: info.revert
|
||||
@@ -310,6 +324,8 @@ async function forkSession(
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
@@ -356,18 +372,22 @@ async function deleteSession(instanceId: string, sessionId: string): Promise<voi
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
await requestData(instance.client.session.delete({ sessionID: sessionId }), "session.delete")
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
if (instanceSessions.size === 0) {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setSessionCompactionState(instanceId, sessionId, false)
|
||||
syncInstanceSessionIndicator(instanceId)
|
||||
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
// Drop normalized message state and caches for this session
|
||||
@@ -519,14 +539,30 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
|
||||
const apiMessages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: sessionId }),
|
||||
"session.messages",
|
||||
)
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
if (!Array.isArray(apiMessages)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Treat empty sessions as loaded to avoid re-fetch loops.
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId) || new Set()
|
||||
loadedSet.add(sessionId)
|
||||
next.set(instanceId, loadedSet)
|
||||
return next
|
||||
})
|
||||
|
||||
if (apiMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagesInfo = new Map<string, any>()
|
||||
const messages: Message[] = response.data.map((apiMessage: any) => {
|
||||
const messages: Message[] = apiMessages.map((apiMessage: any) => {
|
||||
const info = apiMessage.info || apiMessage
|
||||
const role = info.role || "assistant"
|
||||
const messageId = info.id || String(Date.now())
|
||||
@@ -552,8 +588,8 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
let providerID = ""
|
||||
let modelID = ""
|
||||
|
||||
for (let i = response.data.length - 1; i >= 0; i--) {
|
||||
const apiMessage = response.data[i]
|
||||
for (let i = apiMessages.length - 1; i >= 0; i--) {
|
||||
const apiMessage = apiMessages[i]
|
||||
const info = apiMessage.info || apiMessage
|
||||
|
||||
if (info.role === "assistant") {
|
||||
@@ -574,19 +610,23 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const nextInstanceSessions = next.get(instanceId)
|
||||
if (nextInstanceSessions) {
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
const updatedInstanceSessions = new Map(nextInstanceSessions)
|
||||
updatedInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, updatedInstanceSessions)
|
||||
}
|
||||
if (!nextInstanceSessions) {
|
||||
return next
|
||||
}
|
||||
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (!existingSession) {
|
||||
return next
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
|
||||
nextInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, nextInstanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
@@ -606,6 +646,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
||||
}
|
||||
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
||||
|
||||
// Permissions can be hydrated before messages/tool parts exist in the store.
|
||||
// After message hydration, try to attach any pending permissions to tool-call part ids.
|
||||
reconcilePendingPermissionsV2(instanceId, sessionId)
|
||||
|
||||
|
||||
} catch (error) {
|
||||
log.error("Failed to load messages:", error)
|
||||
throw error
|
||||
|
||||
@@ -6,37 +6,43 @@ import type {
|
||||
MessageUpdateEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
EventSessionStatus,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
|
||||
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
|
||||
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
upsertMessageInfoV2,
|
||||
upsertPermissionV2,
|
||||
removeMessagePartV2,
|
||||
removeMessageV2,
|
||||
removePermissionV2,
|
||||
setSessionRevertV2,
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
|
||||
const log = getLogger("sse")
|
||||
const pendingSessionFetches = new Map<string, Promise<void>>()
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
@@ -49,8 +55,98 @@ interface TuiToastEvent {
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const current = session.status ?? "idle"
|
||||
if (current === status) return false
|
||||
|
||||
if (current === "compacting" && status !== "compacting") {
|
||||
return false
|
||||
}
|
||||
|
||||
session.status = status
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return null
|
||||
|
||||
try {
|
||||
const info = await requestData<any>(
|
||||
instance.client.session.get({ sessionID: sessionId }),
|
||||
"session.get",
|
||||
)
|
||||
|
||||
let fetchedStatus: SessionStatus = "idle"
|
||||
try {
|
||||
const statuses = await requestData<Record<string, any>>(instance.client.session.status(), "session.status")
|
||||
const rawStatus = (info as any)?.status ?? statuses?.[sessionId]
|
||||
const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string"
|
||||
fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle"
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch session status", error)
|
||||
}
|
||||
|
||||
const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus)
|
||||
|
||||
let updatedInstanceSessions: Map<string, Session> | undefined
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
|
||||
const existing = instanceSessions.get(sessionId)
|
||||
const merged: Session = {
|
||||
...fetched,
|
||||
agent: existing?.agent ?? fetched.agent,
|
||||
model: existing?.model ?? fetched.model,
|
||||
status: existing?.status === "compacting" ? "compacting" : fetched.status,
|
||||
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
|
||||
}
|
||||
instanceSessions.set(sessionId, merged)
|
||||
next.set(instanceId, instanceSessions)
|
||||
updatedInstanceSessions = instanceSessions
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
|
||||
return fetched
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch session info", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const existing = instanceSessions?.get(sessionId)
|
||||
if (existing) {
|
||||
if ((existing.status ?? "idle") === status) {
|
||||
return
|
||||
}
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${instanceId}:${sessionId}`
|
||||
if (pendingSessionFetches.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = (async () => {
|
||||
const fetched = await fetchSessionInfo(instanceId, sessionId)
|
||||
if (!fetched) return
|
||||
applySessionStatus(instanceId, sessionId, status)
|
||||
})()
|
||||
|
||||
pendingSessionFetches.set(key, pending)
|
||||
void pending.finally(() => pendingSessionFetches.delete(key))
|
||||
}
|
||||
|
||||
type MessageRole = "user" | "assistant"
|
||||
|
||||
|
||||
function resolveMessageRole(info?: MessageInfo | null): MessageRole {
|
||||
return info?.role === "user" ? "user" : "assistant"
|
||||
}
|
||||
@@ -72,7 +168,6 @@ function findPendingMessageId(
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const rawPart = event.properties?.part
|
||||
@@ -87,10 +182,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
if (part.type === "compaction") {
|
||||
ensureSessionStatus(instanceId, sessionId, "compacting")
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
||||
@@ -133,10 +228,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.time = { ...(session.time ?? {}), updated: Date.now() }
|
||||
})
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||
const hasError = Boolean((info as any).error)
|
||||
const status: MessageStatus = hasError ? "error" : "complete"
|
||||
@@ -174,12 +271,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
|
||||
if (!info) return
|
||||
|
||||
const compactingFlag = info.time?.compacting
|
||||
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
|
||||
setSessionCompactionState(instanceId, info.id, isCompacting)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
const instanceSessions = sessions().get(instanceId) ?? new Map<string, Session>()
|
||||
|
||||
const existingSession = instanceSessions.get(info.id)
|
||||
|
||||
@@ -194,6 +286,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
},
|
||||
status: "idle",
|
||||
version: info.version || "0",
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
@@ -201,15 +294,20 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
} as any
|
||||
} as Session
|
||||
|
||||
let updatedInstanceSessions: Map<string, Session> | undefined
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(newSession.id, newSession)
|
||||
next.set(instanceId, updated)
|
||||
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
|
||||
instanceSessions.set(newSession.id, newSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
updatedInstanceSessions = instanceSessions
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
|
||||
log.info(`[SSE] New session created: ${info.id}`, newSession)
|
||||
@@ -218,13 +316,10 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
...existingSession.time,
|
||||
...(info.time ?? {}),
|
||||
}
|
||||
if (!info.time?.updated) {
|
||||
mergedTime.updated = Date.now()
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
status: existingSession.status ?? "idle",
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
@@ -236,37 +331,53 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
|
||||
: existingSession.revert,
|
||||
}
|
||||
|
||||
let updatedInstanceSessions: Map<string, Session> | undefined
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, updated)
|
||||
const instanceSessions = next.get(instanceId) ?? new Map<string, Session>()
|
||||
instanceSessions.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
updatedInstanceSessions = instanceSessions
|
||||
return next
|
||||
})
|
||||
|
||||
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
ensureSessionStatus(instanceId, sessionId, "idle")
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionStatus(instanceId: string, event: EventSessionStatus): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
const status = mapSdkSessionStatus(event.properties.status)
|
||||
ensureSessionStatus(instanceId, sessionId, status)
|
||||
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
log.info(`[SSE] Session compacted: ${sessionID}`)
|
||||
|
||||
setSessionCompactionState(instanceId, sessionID, false)
|
||||
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = 0
|
||||
session.time = time
|
||||
})
|
||||
const existing = sessions().get(instanceId)?.get(sessionID)
|
||||
if (existing) {
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
session.status = "working"
|
||||
})
|
||||
} else {
|
||||
ensureSessionStatus(instanceId, sessionID, "working")
|
||||
}
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
@@ -305,19 +416,21 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
const { sessionID, messageID } = event.properties
|
||||
if (!sessionID || !messageID) return
|
||||
|
||||
log.info(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after removal", error))
|
||||
log.info(`[SSE] Message removed from session ${sessionID}`, { messageID })
|
||||
removeMessageV2(instanceId, messageID)
|
||||
updateSessionInfo(instanceId, sessionID)
|
||||
}
|
||||
|
||||
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
const { sessionID, messageID, partID } = event.properties
|
||||
if (!sessionID || !messageID || !partID) return
|
||||
|
||||
log.info(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after part removal", error))
|
||||
log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID })
|
||||
removeMessagePartV2(instanceId, messageID, partID)
|
||||
updateSessionInfo(instanceId, sessionID)
|
||||
}
|
||||
|
||||
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
@@ -337,22 +450,23 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
})
|
||||
}
|
||||
|
||||
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
|
||||
const permission = event.properties
|
||||
function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void {
|
||||
const permission = event?.properties as PermissionRequestLike | undefined
|
||||
if (!permission) return
|
||||
|
||||
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
log.info(`[SSE] Permission request: ${getPermissionId(permission)} (${getPermissionKind(permission)})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void {
|
||||
const properties = event?.properties as PermissionReplyEventPropertiesLike | undefined
|
||||
const requestId = getRequestIdFromPermissionReply(properties)
|
||||
if (!requestId) return
|
||||
|
||||
log.info(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
removePermissionV2(instanceId, permissionID)
|
||||
log.info(`[SSE] Permission replied: ${requestId}`)
|
||||
removePermissionFromQueue(instanceId, requestId)
|
||||
removePermissionV2(instanceId, requestId)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -364,6 +478,7 @@ export {
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionStatus,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
import type { Session, SessionStatus, Agent, Provider } from "../types/session"
|
||||
import { deleteSession, loadMessages } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { instances } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -39,6 +40,130 @@ const [loading, setLoading] = createSignal({
|
||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
|
||||
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
|
||||
|
||||
type InstanceIndicatorCounts = {
|
||||
permission: number
|
||||
working: number
|
||||
compacting: number
|
||||
}
|
||||
|
||||
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
|
||||
|
||||
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
|
||||
if (session.pendingPermission) {
|
||||
return "permission"
|
||||
}
|
||||
const status = session.status ?? "idle"
|
||||
return status
|
||||
}
|
||||
|
||||
function adjustIndicatorCounts(
|
||||
instanceId: string,
|
||||
previous: InstanceSessionIndicatorStatus | "idle",
|
||||
next: InstanceSessionIndicatorStatus | "idle",
|
||||
): void {
|
||||
if (previous === next) return
|
||||
|
||||
const decKey = previous === "idle" ? null : previous
|
||||
const incKey = next === "idle" ? null : next
|
||||
|
||||
setInstanceIndicatorCounts((prev) => {
|
||||
const current = prev.get(instanceId) ?? { permission: 0, working: 0, compacting: 0 }
|
||||
const updated: InstanceIndicatorCounts = { ...current }
|
||||
|
||||
if (decKey) {
|
||||
updated[decKey] = Math.max(0, updated[decKey] - 1)
|
||||
}
|
||||
|
||||
if (incKey) {
|
||||
updated[incKey] = updated[incKey] + 1
|
||||
}
|
||||
|
||||
const hasAny = updated.permission > 0 || updated.working > 0 || updated.compacting > 0
|
||||
if (!hasAny) {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const nextMap = new Map(prev)
|
||||
nextMap.delete(instanceId)
|
||||
return nextMap
|
||||
}
|
||||
|
||||
const same =
|
||||
current.permission === updated.permission &&
|
||||
current.working === updated.working &&
|
||||
current.compacting === updated.compacting
|
||||
if (same && prev.has(instanceId)) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const nextMap = new Map(prev)
|
||||
nextMap.set(instanceId, updated)
|
||||
return nextMap
|
||||
})
|
||||
}
|
||||
|
||||
function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<string, Session> | undefined): void {
|
||||
if (!instanceSessions || instanceSessions.size === 0) {
|
||||
setInstanceIndicatorCounts((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let permission = 0
|
||||
let working = 0
|
||||
let compacting = 0
|
||||
|
||||
for (const session of instanceSessions.values()) {
|
||||
if (session.pendingPermission) {
|
||||
permission += 1
|
||||
continue
|
||||
}
|
||||
const status = session.status ?? "idle"
|
||||
if (status === "compacting") {
|
||||
compacting += 1
|
||||
} else if (status === "working") {
|
||||
working += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === 0 && working === 0 && compacting === 0) {
|
||||
setInstanceIndicatorCounts((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setInstanceIndicatorCounts((prev) => {
|
||||
const current = prev.get(instanceId)
|
||||
if (current && current.permission === permission && current.working === working && current.compacting === compacting) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, { permission, working, compacting })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function getInstanceSessionIndicatorStatusCached(instanceId: string): InstanceSessionIndicatorStatus {
|
||||
const counts = instanceIndicatorCounts().get(instanceId)
|
||||
if (!counts) return "idle"
|
||||
if (counts.permission > 0) return "permission"
|
||||
if (counts.compacting > 0) return "compacting"
|
||||
if (counts.working > 0) return "working"
|
||||
return "idle"
|
||||
}
|
||||
|
||||
export function syncInstanceSessionIndicator(instanceId: string, instanceSessions?: Map<string, Session>): void {
|
||||
recomputeIndicatorCounts(instanceId, instanceSessions ?? sessions().get(instanceId))
|
||||
}
|
||||
|
||||
function clearLoadedFlag(instanceId: string, sessionId: string) {
|
||||
if (!instanceId || !sessionId) return
|
||||
setMessagesLoaded((prev) => {
|
||||
@@ -130,39 +255,44 @@ function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
|
||||
})
|
||||
}
|
||||
|
||||
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
updater(session)
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
}
|
||||
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void | boolean) {
|
||||
let previousBucket: InstanceSessionIndicatorStatus | "idle" | null = null
|
||||
let nextBucket: InstanceSessionIndicatorStatus | "idle" | null = null
|
||||
let didUpdate = false
|
||||
|
||||
setSessions((prev) => {
|
||||
const instanceSessions = prev.get(instanceId)
|
||||
if (!instanceSessions) return prev
|
||||
|
||||
const current = instanceSessions.get(sessionId)
|
||||
if (!current) return prev
|
||||
|
||||
previousBucket = getIndicatorBucket(current)
|
||||
|
||||
const updatedSession: Session = { ...current }
|
||||
const result = updater(updatedSession)
|
||||
if (result === false) {
|
||||
return prev
|
||||
}
|
||||
|
||||
nextBucket = getIndicatorBucket(updatedSession)
|
||||
|
||||
instanceSessions.set(sessionId, updatedSession)
|
||||
didUpdate = true
|
||||
|
||||
const next = new Map(prev)
|
||||
const newInstanceSessions = new Map(instanceSessions)
|
||||
newInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, newInstanceSessions)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = isCompacting ? Date.now() : 0
|
||||
session.time = time
|
||||
})
|
||||
if (didUpdate && previousBucket && nextBucket) {
|
||||
adjustIndicatorCounts(instanceId, previousBucket, nextBucket)
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.pendingPermission === pending) return
|
||||
if (session.pendingPermission === pending) return false
|
||||
session.pendingPermission = pending
|
||||
})
|
||||
}
|
||||
@@ -199,6 +329,13 @@ function clearActiveParentSession(instanceId: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.status === status) return false
|
||||
session.status = status
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveParentSession(instanceId: string): Session | null {
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return null
|
||||
@@ -272,8 +409,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
|
||||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
const response = await instance.client.session.messages({ path: { id: session.id } })
|
||||
messages = response.data || []
|
||||
messages = await requestData<any[]>(
|
||||
instance.client.session.messages({ sessionID: session.id }),
|
||||
"session.messages",
|
||||
)
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
@@ -378,8 +517,8 @@ export {
|
||||
clearInstanceDraftPrompts,
|
||||
pruneDraftPrompts,
|
||||
withSession,
|
||||
setSessionCompactionState,
|
||||
setSessionPendingPermission,
|
||||
setSessionStatus,
|
||||
setActiveSession,
|
||||
|
||||
setActiveParentSession,
|
||||
|
||||
@@ -1,165 +1,23 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { MessageInfo } from "../types/message"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function isSessionCompacting(session: Session): boolean {
|
||||
const time = (session.time as (Session["time"] & { compacting?: number }) | undefined)
|
||||
const compactingFlag = time?.compacting
|
||||
if (typeof compactingFlag === "number") {
|
||||
return compactingFlag > 0
|
||||
}
|
||||
return Boolean(compactingFlag)
|
||||
}
|
||||
|
||||
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageInfo | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const id of messageIds) {
|
||||
const info = store.getMessageInfo(id)
|
||||
if (!info) continue
|
||||
if (role && info.role !== role) continue
|
||||
const timestamp = info.time?.created ?? 0
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = info
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageRecord | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const id of messageIds) {
|
||||
const record = store.getMessage(id)
|
||||
if (!record) continue
|
||||
const info = store.getMessageInfo(id)
|
||||
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = record
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
|
||||
function getInfoCreatedTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const created = info.time?.created
|
||||
if (typeof created === "number" && Number.isFinite(created)) {
|
||||
return created
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getAssistantCompletionTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (typeof completed === "number" && Number.isFinite(completed)) {
|
||||
return completed
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function isAssistantInfoPending(info?: MessageInfo): boolean {
|
||||
if (!info) {
|
||||
return false
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (completed === undefined || completed === null) {
|
||||
return true
|
||||
}
|
||||
const created = getInfoCreatedTimestamp(info)
|
||||
return completed < created
|
||||
}
|
||||
|
||||
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
|
||||
if (record.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (record.status === "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (record.status === "streaming" || record.status === "sending") {
|
||||
return true
|
||||
}
|
||||
|
||||
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
|
||||
if (completedAt !== undefined && completedAt !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(record.status === "complete" || record.status === "sent")
|
||||
}
|
||||
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
return "idle"
|
||||
}
|
||||
return session.status ?? "idle"
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user")
|
||||
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant")
|
||||
|
||||
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
|
||||
|
||||
if (!lastRecord) {
|
||||
const latestInfo = latestUserInfo ?? latestAssistantInfo
|
||||
if (!latestInfo) {
|
||||
return "idle"
|
||||
}
|
||||
if (latestInfo.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoCompleted = latestInfo.time?.completed
|
||||
return infoCompleted ? "idle" : "working"
|
||||
}
|
||||
|
||||
if (lastRecord.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
|
||||
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
if (isAssistantInfoPending(latestAssistantInfo)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const userTimestamp = getInfoCreatedTimestamp(latestUserInfo)
|
||||
const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo)
|
||||
if (userTimestamp > assistantCompletedAt) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
return "idle"
|
||||
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {
|
||||
return getInstanceSessionIndicatorStatusCached(instanceId)
|
||||
}
|
||||
|
||||
export function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
setSessionStatus,
|
||||
} from "./session-state"
|
||||
|
||||
import { getDefaultModel } from "./session-models"
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionStatus,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
} from "./session-events"
|
||||
@@ -67,6 +70,7 @@ sseManager.onSessionUpdate = handleSessionUpdate
|
||||
sseManager.onSessionCompacted = handleSessionCompacted
|
||||
sseManager.onSessionError = handleSessionError
|
||||
sseManager.onSessionIdle = handleSessionIdle
|
||||
sseManager.onSessionStatus = handleSessionStatus
|
||||
sseManager.onTuiToast = handleTuiToast
|
||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||
sseManager.onPermissionReplied = handlePermissionReplied
|
||||
@@ -82,6 +86,7 @@ export {
|
||||
createSession,
|
||||
deleteSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
@@ -107,6 +112,7 @@ export {
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
setSessionStatus,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
}
|
||||
|
||||
.selector-popover {
|
||||
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
|
||||
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
z-index: 2200;
|
||||
}
|
||||
|
||||
.selector-search-container {
|
||||
|
||||
@@ -208,6 +208,35 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.message-compaction-card {
|
||||
@apply flex flex-col gap-1 px-3 py-2 text-xs;
|
||||
background-color: var(--message-assistant-bg);
|
||||
}
|
||||
|
||||
.message-compaction-card--auto {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
color: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
.message-compaction-card--manual {
|
||||
background-color: var(--message-user-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-compaction-row {
|
||||
@apply flex items-center gap-2;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-compaction-icon {
|
||||
@apply inline-flex items-center;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-compaction-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.message-step-start {
|
||||
background-color: var(--message-assistant-bg);
|
||||
border-left: 4px solid var(--message-assistant-border);
|
||||
|
||||
@@ -5,16 +5,32 @@
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-layout--with-timeline {
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
}
|
||||
|
||||
.message-layout--with-timeline::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 64px;
|
||||
width: 1px;
|
||||
background-color: var(--border-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.message-layout--with-timeline {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
}
|
||||
|
||||
.message-layout--with-timeline::after {
|
||||
right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-stream-shell {
|
||||
@@ -22,19 +38,21 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.message-stream-shell .message-stream {
|
||||
|
||||
.message-stream-shell .message-stream {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.message-timeline-sidebar {
|
||||
}
|
||||
|
||||
.message-timeline-sidebar {
|
||||
width: 64px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.message-timeline-sidebar {
|
||||
@@ -128,6 +146,16 @@
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.message-timeline-compaction-auto {
|
||||
border-color: var(--session-status-compacting-fg);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.message-timeline-compaction-manual {
|
||||
border-color: var(--message-user-border);
|
||||
background-color: var(--message-user-bg);
|
||||
}
|
||||
|
||||
.message-timeline-segment-active {
|
||||
background-color: #0f5b44 !important;
|
||||
border-color: transparent !important;
|
||||
@@ -140,8 +168,12 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.message-timeline-label-short {
|
||||
.message-timeline-label-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-timeline-label-short {
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -152,15 +184,6 @@
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.message-timeline-label-full {
|
||||
display: none;
|
||||
}
|
||||
.message-timeline-label-short {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.message-timeline-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
@@ -6,29 +6,47 @@
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
@apply flex items-stretch gap-2 p-2;
|
||||
@apply grid items-stretch;
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prompt-input-actions {
|
||||
@apply flex flex-col items-center justify-between;
|
||||
align-self: stretch;
|
||||
height: 100%;
|
||||
padding: 0.25rem 0;
|
||||
padding: 0.5rem 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-input-field {
|
||||
.prompt-input-field-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-input-field {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
@apply flex-1 w-full min-h-[56px] max-h-[96px] pl-3 pr-10 pt-2.5 pb-4 border rounded-md text-sm resize-none outline-none transition-colors;
|
||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
border-color: var(--border-base);
|
||||
line-height: var(--line-height-normal);
|
||||
border-radius: 0;
|
||||
padding-bottom: 0;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -264,6 +282,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-input-wrapper {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.prompt-input {
|
||||
min-height: 64px;
|
||||
@@ -272,7 +296,7 @@
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,77 @@
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
@apply my-2 flex flex-col gap-1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-task-item {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-normal);
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
|
||||
border-left: 2px solid var(--border-base);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
line-height: 1.35;
|
||||
background-color: var(--surface-code);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-call-task-item::before {
|
||||
content: "∟ ";
|
||||
.tool-call-task-item + .tool-call-task-item {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.tool-call-task-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-task-item[data-task-status="completed"] {
|
||||
border-left-color: var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-task-item[data-task-status="running"] {
|
||||
border-left-color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-task-item[data-task-status="pending"] {
|
||||
border-left-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-task-item[data-task-status="error"] {
|
||||
border-left-color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-task-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-task-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-secondary);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-task-separator {
|
||||
color: var(--text-muted);
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-task-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-call-task-status {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
border-radius: 0;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--surface-secondary);
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.tool-call-todo-item-completed {
|
||||
@@ -82,9 +83,29 @@
|
||||
}
|
||||
|
||||
.tool-call-todo-heading {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
@apply flex items-start gap-3;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tool-call-todo-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 9999px;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-call-todo-text {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-todo-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
|
||||
/* Panel component utilities */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
@@ -415,7 +415,8 @@
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
@@ -438,7 +439,7 @@
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
@@ -487,6 +488,7 @@
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Session list component */
|
||||
@@ -534,17 +536,35 @@
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle--left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle--right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
@apply absolute top-0 h-full w-2;
|
||||
}
|
||||
|
||||
.session-resize-handle--left::before {
|
||||
right: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.session-resize-handle--right::before {
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Panel component shells */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
@apply rounded-lg shadow-sm border overflow-hidden min-w-0;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
@@ -53,7 +53,8 @@
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
@apply max-h-[400px] overflow-y-auto w-full min-w-0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
@@ -76,7 +77,7 @@
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-list-container {
|
||||
@@ -148,17 +149,35 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle--left {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle--right {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
@apply absolute top-0 h-full w-2;
|
||||
}
|
||||
|
||||
.session-resize-handle--left::before {
|
||||
right: 0;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.session-resize-handle--right::before {
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
|
||||
--session-status-idle-fg: #15803d;
|
||||
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
|
||||
--session-status-permission-fg: #c2410c;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
||||
--session-status-permission-fg: #b91c1c;
|
||||
--session-status-permission-bg: rgba(239, 68, 68, 0.16);
|
||||
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--list-item-highlight-bg-solid: #e5f0ff;
|
||||
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
||||
@@ -191,8 +191,8 @@
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--session-status-permission-fg: #f87171;
|
||||
--session-status-permission-bg: rgba(248, 113, 113, 0.22);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-bg-solid: #15324e;
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
@@ -345,8 +345,8 @@
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--session-status-permission-fg: #f87171;
|
||||
--session-status-permission-bg: rgba(248, 113, 113, 0.22);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-bg-solid: #15324e;
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
|
||||
@@ -133,3 +133,12 @@
|
||||
.kbd-separator {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
/* Prevent iOS Safari auto-zoom on text input focus */
|
||||
@media (pointer: coarse) {
|
||||
input[type="text"],
|
||||
input:not([type]),
|
||||
textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number
|
||||
@@ -22,12 +22,14 @@ export type RawMcpStatus = Record<string, {
|
||||
}>
|
||||
|
||||
export interface InstanceMetadata {
|
||||
project?: ProjectInfo
|
||||
mcpStatus?: RawMcpStatus
|
||||
lspStatus?: LspStatus[]
|
||||
project?: ProjectInfo | null
|
||||
mcpStatus?: RawMcpStatus | null
|
||||
lspStatus?: LspStatus[] | null
|
||||
plugins?: string[] | null
|
||||
version?: string
|
||||
}
|
||||
|
||||
|
||||
export interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user