Compare commits

...

14 Commits

Author SHA1 Message Date
Shantur Rathore
0ef57df3bc fix(ui): show token stats and simplify context window calculation
- Track messageInfoVersion in cache signature to rebuild when tokens arrive via SSE
- Read tokens from step-finish part directly (embedded in SSE events)
- Simplify available tokens to show full context window when no explicit input limit
2026-04-08 22:19:10 +01:00
Shantur Rathore
0739ec857c Reapply "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit af6429162f.
2026-04-08 20:57:23 +01:00
Shantur Rathore
b060ab45ff Revert "feat(tauri): add zip bundle target for macOS and Windows"
This reverts commit 197898c01c.
2026-04-08 20:57:23 +01:00
Shantur Rathore
af6429162f Revert "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit 2e9ee2cde6.
2026-04-08 20:57:12 +01:00
Shantur Rathore
2e9ee2cde6 fix(ui): support unified diff patch format in session changes viewer
Session diffs now use a compact patch field instead of storing full
before/after content. Added parsePatchToBeforeAfter utility to extract
before/after from unified diff format, and updated MonacoDiffViewer to
accept patch prop as alternative to before/after strings.
2026-04-08 20:48:13 +01:00
Shantur Rathore
d45c0b9367 fix(tauri): prevent Windows zoom freeze with debouncing and transparent window
- Add 50ms debounce to zoom operations to prevent WebView2 IPC bottleneck
- Enable transparent window mode for better Windows resize/zoom performance
- Reduce zoom step from 0.2 to 0.1 for finer control
2026-04-08 20:47:49 +01:00
Shantur Rathore
197898c01c feat(tauri): add zip bundle target for macOS and Windows
- Add build scripts for platform-specific builds with zip bundles
- Update CI workflow to use --bundles flag for explicit target selection
- macOS: use app,zip (removed dmg)
- Windows: use nsis,zip
- Linux: use appimage,deb,rpm
2026-04-08 20:34:08 +01:00
Shantur Rathore
0c0cfd2d22 fix(ui): keep speech input chained and scrolled to bottom 2026-04-08 19:02:06 +01:00
Shantur Rathore
5107ac207e feat(ui): show background process notify state 2026-04-08 16:09:17 +01:00
Shantur Rathore
1130066a33 feat(background-process): notify sessions when tasks end
Send synthetic session notifications when background processes finish, fail, stop, or terminate so the originating agent can react without polling. Hide synthetic text-only prompts from the UI stream so operational notifications stay out of the visible transcript.
2026-04-08 15:48:50 +01:00
Shantur Rathore
403a3ff189 Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
2026-04-04 15:11:45 +01:00
codenomadbot[bot]
7996e514c4 fix(ui): preserve prompt text when dismissing mention picker (#285)
## Summary
- preserve the current prompt text when dismissing the `@` mention/file
picker with `Esc`
- let `Enter` fall back to normal prompt submission when the mention
picker is open but there is no selectable result

## Verification
- source inspection of the prompt input and picker flow
- local `npm run typecheck --workspace @codenomad/ui` is blocked in this
environment because workspace dependencies are not installed

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-04 00:48:37 +01:00
Pascal André
141be2cde0 perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)
## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
2026-04-03 23:01:13 +01:00
codenomadbot[bot]
259d457209 fix(desktop): launch server with unrestricted root (#283)
## Summary
- launch the Electron-managed server with `--unrestricted-root` by
default
- launch the Tauri-managed server with `--unrestricted-root` by default
- stop relying on the server's `process.cwd()` fallback for desktop
filesystem browsing

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-03 16:47:34 +01:00
40 changed files with 1296 additions and 566 deletions

View File

@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number
}
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = {
baseDir: string
}
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
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"),
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
},
async execute(args) {
async execute(args, context) {
assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

@@ -376,6 +376,8 @@ export interface ServerMeta {
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess {
id: string
workspaceId: string
@@ -388,6 +390,8 @@ export interface BackgroundProcess {
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
}
export interface BackgroundProcessListResponse {

View File

@@ -5,7 +5,7 @@ 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"
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string
exitPromise: Promise<void>
workspaceId: string
completion?: ProcessCompletion
}
interface ProcessCompletion {
reason: BackgroundProcessTerminalReason
endContext: "normal" | "workspace_cleanup"
removeAfterFinalize?: boolean
}
interface BackgroundProcessNotificationState {
sessionID: string
directory: string
sentAt?: string
}
interface PersistedBackgroundProcess extends BackgroundProcess {
notify?: BackgroundProcessNotificationState
}
interface StartOptions {
notify?: boolean
notification?: {
sessionID: string
directory: string
}
}
export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
const record: PersistedBackgroundProcess = {
id,
workspaceId,
title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
notify: options.notify && options.notification
? {
sessionID: options.notification.sessionID,
directory: options.notification.directory,
}
: undefined,
}
const runningState: RunningProcess = {
id,
child,
outputPath,
exitPromise: Promise.resolve(),
workspaceId,
}
const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
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)
await this.finalizeRecord(workspaceId, record, completion)
resolve()
})
})
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
return this.toPublicProcess(record)
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
}
if (record.status === "running") {
record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
}
return record
return this.toPublicProcess(record)
}
async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
return
}
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 } },
record.status = "stopped"
record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
await this.finalizeRecord(workspaceId, record, {
reason: "user_terminated",
endContext: "normal",
removeAfterFinalize: true,
})
}
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
private completionFromExit(code: number | null): ProcessCompletion {
if (code === 0) {
return { reason: "finished", endContext: "normal" }
}
return { reason: "failed", endContext: "normal" }
}
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
if (reason === "failed") return "error"
return "stopped"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
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[]) : []
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
})
}
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
return {
id: record.id,
workspaceId: record.workspaceId,
title: record.title,
command: record.command,
cwd: record.cwd,
status: record.status,
pid: record.pid,
startedAt: record.startedAt,
stoppedAt: record.stoppedAt,
exitCode: record.exitCode,
outputSizeBytes: record.outputSizeBytes,
terminalReason: record.terminalReason,
notifyEnabled: Boolean(record.notify),
}
}
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (this.shouldSendCompletionPrompt(record, completion)) {
try {
await this.sendCompletionPrompt(workspaceId, record)
if (record.notify) {
record.notify.sentAt = new Date().toISOString()
}
} catch (error) {
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
}
}
if (completion.removeAfterFinalize) {
await this.removeFromIndex(workspaceId, record.id)
await this.removeProcessDir(workspaceId, record.id)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId: record.id } },
})
return
}
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (completion.endContext === "workspace_cleanup") return false
if (!record.notify) return false
return !record.notify.sentAt
}
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
const notify = record.notify
if (!notify || !record.terminalReason) return
if (!this.deps.workspaceManager.get(workspaceId)) {
throw new Error("Workspace not found")
}
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
if (!port) {
throw new Error("Workspace instance is not ready")
}
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authorization) {
headers.authorization = authorization
}
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
parts: [
{
type: "text",
text: this.buildSyntheticCompletionPrompt(record),
synthetic: true,
},
],
}),
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Prompt request failed with ${response.status}`)
}
}
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
const ref = `Background process "${record.title}" (${record.id})`
switch (record.terminalReason) {
case "finished":
return `${ref} finished successfully.`
case "failed":
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
case "user_stopped":
return `${ref} was stopped by user.`
case "user_terminated":
return `${ref} was terminated by user.`
}
return `${ref} ended.`
}
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
notify: z.boolean().optional(),
notification: z
.object({
sessionID: z.string().trim().min(1),
directory: z.string().trim().min(1),
})
.optional(),
}).superRefine((value, ctx) => {
if (value.notify && !value.notification) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Notification metadata is required when notify is enabled",
path: ["notification"],
})
}
})
const OutputQuerySchema = z.object({
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
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)
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
notify: payload.notify,
notification: payload.notification,
})
reply.code(201)
return process
})

View File

@@ -963,6 +963,7 @@ impl CliEntry {
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(),
"--unrestricted-root".to_string(),
];
if dev {

View File

@@ -9,11 +9,13 @@ use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry};
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -30,10 +32,12 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
static LAST_ZOOM_TIME: Mutex<Option<Instant>> = Mutex::new(None);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
const ZOOM_DEBOUNCE_MS: u64 = 50;
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -129,7 +133,11 @@ fn should_allow_internal(url: &Url) -> bool {
}
}
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool {
fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) {
return true;
}
@@ -172,7 +180,11 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str()));
let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone());
@@ -189,12 +201,13 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
.map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization());
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let window =
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone();
window.on_window_event(move |event| {
@@ -246,6 +259,15 @@ fn clamp_zoom_level(value: f64) -> f64 {
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Ok(mut last_zoom_time) = LAST_ZOOM_TIME.lock() {
if let Some(last_time) = *last_zoom_time {
if last_time.elapsed().as_millis() < ZOOM_DEBOUNCE_MS as u128 {
return;
}
}
*last_zoom_time = Some(Instant::now());
}
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {

View File

@@ -23,6 +23,7 @@
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true

View File

@@ -1,15 +1,17 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme"
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
interface MonacoDiffViewerProps {
scopeKey: string
path: string
before: string
after: string
patch?: string
before?: string
after?: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
@@ -23,6 +25,16 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let monaco: any = null
const [ready, setReady] = createSignal(false)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => {
try {
diffEditor?.setModel(null as any)
@@ -115,11 +127,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path)
const { before, after } = resolvedContent()
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => {

View File

@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}
>
{(file) => (
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
patch={String((file() as any).patch || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)}
</Show>
</div>

View File

@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span
classList={{
"text-success": Boolean(process.notifyEnabled),
"text-tertiary": !process.notifyEnabled,
}}
aria-label={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
title={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
>
<BellRing class="h-3.5 w-3.5" />
</span>
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>

View File

@@ -1,9 +1,9 @@
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
@@ -16,6 +16,7 @@ import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
function DeleteUpToIcon() {
return (
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call"))
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file"
}
function isVisibleContentPart(part: ClientPart): boolean {
if (!part || !isContentPartType((part as any).type)) return false
if (isHiddenSyntheticTextPart(part)) return false
return partHasRenderableText(part)
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
return resolved
})
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) {
if (visibleParts().length === 0) {
return false
}
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) {
return false
if (isVisibleContentPart(part)) {
return false
}
}
}
return true
})
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={parts()}
parts={visibleParts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
const cacheSignature = [
current.id,
current.revision,
messageInfoVersion,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return cachedBlock.block
}
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}>
<Index each={resolvedBlock().items}>
{(item, index) => (
<Switch>
<Match when={item.type === "content"}>
<Match when={item().type === "content"}>
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered}
/>
</Match>
<Match when={item.type === "tool"}>
<Match when={item().type === "tool"}>
{(() => {
const toolItem = item as ToolDisplayItem
const toolItem = item() as ToolDisplayItem
return (
<div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index() === 0}
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
showDeleteMessage={index === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
)
})()}
</Match>
<Match when={item.type === "step-start"}>
<Match when={item().type === "step-start"}>
<StepCard
kind="start"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta
showDeleteMessage={index() === 0}
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "step-finish"}>
<Match when={item().type === "step-finish"}>
<StepCard
kind="finish"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
part={(item() as StepDisplayItem).part}
messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "compaction"}>
<Match when={item().type === "compaction"}>
<CompactionCard
part={(item as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor}
part={(item() as CompactionDisplayItem).part}
messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item() as CompactionDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0}
messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/>
</Match>
<Match when={item.type === "reasoning"}>
<Match when={item().type === "reasoning"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
part={(item() as ReasoningDisplayItem).part}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match>
</Switch>
)}
</For>
</Index>
</div>
)}
</Show>
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
return null
}
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null
}
const tokens = info.tokens
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
}
}
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
onContentRendered?: () => void
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
function ReasoningStreamOutput(props: {
text: Accessor<string>
scrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
onContentRendered?: () => void
ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
@@ -1312,6 +1339,17 @@ function ReasoningCard(props: ReasoningCardProps) {
})
}
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
if (followScroll.autoScroll()) {
followScroll.restoreAfterRender({ forceBottom: true })
}
notifyContentRendered()
})
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
@@ -1319,6 +1357,37 @@ function ReasoningCard(props: ReasoningCardProps) {
}
})
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
})
@@ -1393,12 +1462,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1553,9 +1616,13 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div>
<ReasoningStreamOutput
text={reasoningText}
scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
</div>
</div>
</Show>

View File

@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
.map(part => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0)
.filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
.map((part) => (part as { text?: string }).text || "")
.filter((text) => text.trim().length > 0)
.join("\n\n")
}
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
}
}
if (!isUser() && !hasContent() && !isGenerating()) {
if (!hasContent() && !isGenerating()) {
return null
}

View File

@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
return Boolean((part as any).synthetic)
}

View File

@@ -46,6 +46,33 @@ export default function MessageSection(props: MessageSectionProps) {
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const visibleMessageIds = createMemo(() => {
const resolvedStore = store()
return messageIds().filter((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return false
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
return true
}
if (record.role !== "assistant") {
return false
}
const info = resolvedStore.getMessageInfo(messageId)
if (!info || info.role !== "assistant") {
return false
}
if (info.error) {
return true
}
const timeInfo = info.time as { created: number; end?: number } | undefined
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
})
})
const scrollCache = useScrollCache({
instanceId: props.instanceId,
@@ -129,6 +156,8 @@ export default function MessageSection(props: MessageSectionProps) {
return map
})
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small.
@@ -315,15 +344,9 @@ export default function MessageSection(props: MessageSectionProps) {
}
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
for (let index = ids.length - 1; index >= 0; index--) {
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
const messageId = lastAssistantMessageId()
if (!messageId) return -1
return messageIndexById().get(messageId) ?? -1
})
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -615,7 +638,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi()
if (!element || !api) return
if (props.loading) return
if (messageIds().length === 0) return
if (visibleMessageIds().length === 0) return
if (didRestoreScroll()) return
scrollCache.restore(element, {
@@ -734,88 +757,93 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading)
const ids = messageIds()
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
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
// Wrap all iteration of the store-proxied `ids` array in untrack()
// to prevent O(n) per-element reactive subscriptions. The effect
// only needs to re-run when `messageIds` (memo) changes.
untrack(() => {
if (loading) {
handleClearTimelineSelection()
previousTimelineIds = []
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
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
})
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = [...ids]
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
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = [...ids]
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)
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
}
previousTimelineIds = ids.slice()
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = [...ids]
})
})
function clearPendingTimelinePartUpdateFrame() {
@@ -886,36 +914,49 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => {
if (props.loading) return
const ids = messageIds()
const resolvedStore = store()
// Also re-run when sessionRevision bumps (covers part additions within
// existing messages) but read individual records inside untrack() to
// avoid creating O(n) fine-grained subscriptions.
sessionRevision()
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
// Wrap the iteration in untrack() so that accessing individual elements
// of the store-proxied `ids` array does not create O(n) per-element
// reactive subscriptions. We only need to re-run when the memo
// (messageIds) or sessionRevision changes — not per-element.
untrack(() => {
const resolvedStore = store()
const idsSet = new Set(ids)
let hasChanges = false
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
// Drop tracking for ids that are no longer present.
// Use the Set for O(1) lookups instead of ids.includes() which is O(n).
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
}
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
})
createEffect(() => {
@@ -989,7 +1030,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()}
>
<VirtualFollowList
items={messageIds}
items={visibleMessageIds}
getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1035,7 +1076,7 @@ export default function MessageSection(props: MessageSectionProps) {
registerState={(state) => setListState(state)}
renderBeforeItems={() => (
<>
<Show when={!props.loading && messageIds().length === 0}>
<Show when={!props.loading && visibleMessageIds().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">

View File

@@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untra
import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
@@ -105,6 +106,7 @@ function collectReasoningText(part: ClientPart): string {
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return ""
if (isHiddenSyntheticTextPart(part)) return ""
if (typeof (part as any).text === "string") {
return (part as any).text as string
}

View File

@@ -540,6 +540,10 @@ export default function PromptInput(props: PromptInputProps) {
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
onSubmitWithoutSelection={() => {
handlePickerClose()
void handleSend()
}}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}

View File

@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
}
setShowPicker(false)
setAtPosition(null)

View File

@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const wasCursorAtEnd = end === current.length
const wasScrolledToBottom = textarea
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
: false
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const prefix = ""
const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
const cursor = before.length + prefix.length + text.length + suffix.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
if (wasCursorAtEnd || wasScrolledToBottom) {
textarea.scrollTop = textarea.scrollHeight
}
}, 0)
}
}

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus"
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
const log = getLogger("session")
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
function makeRenderCacheKey(
toolCallId?: string | null,
@@ -82,6 +81,27 @@ interface ToolCallProps {
forceCollapsed?: boolean
}
function ToolStatusIndicator(props: { status: Accessor<string> }) {
const isVisible = (value: string) => props.status() === value
return (
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
<Hourglass class="w-4 h-4" />
</span>
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
<Loader2 class="w-4 h-4 animate-spin" />
</span>
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
<Check class="w-4 h-4" />
</span>
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
<XCircle class="w-4 h-4" />
</span>
</span>
)
}
function ToolCallDetails(props: {
toolCallMemo: () => ToolCallPart
toolState: () => ToolState | undefined
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = props.scrollTopSnapshot()
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
const persistScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
lastKnownScrollTop = element.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
if (Math.abs(delta) > 1) {
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function handleScroll() {
const container = scrollContainer()
if (!container) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
})
}
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
handleScroll()
persistScrollSnapshot(event.currentTarget)
}
const handleScrollRendered = () => {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
scheduleAnchorScroll(true)
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
// Refresh our snapshot on mount (e.g. when remounting after collapse)
lastKnownScrollTop = props.scrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "tool-call-scroll-sentinel",
})
const scrollHelpers: ToolScrollHelpers = {
registerContainer: (element, options) => {
if (options?.disableTracking) return
initializeScrollContainer(element)
},
handleScroll: handleScrollEvent,
renderSentinel: (options) => {
if (options?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
followScroll.registerContainer(element, options)
},
handleScroll: followScroll.handleScroll,
renderSentinel: followScroll.renderSentinel,
restoreAfterRender: followScroll.restoreAfterRender,
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
const handleScrollRendered = () => {
scrollHelpers.restoreAfterRender()
}
createEffect(() => {
const permission = permissionDetails()
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
partVersion={options.partVersion}
instanceId={props.instanceId}
sessionId={options.sessionId}
onContentRendered={props.onContentRendered}
forceCollapsed={options.forceCollapsed}
/>
)
},
scrollHelpers,
onContentRendered: props.onContentRendered,
}
let previousPartVersion: number | undefined
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
return
}
previousPartVersion = version
scheduleAnchorScroll(true)
scrollHelpers.restoreAfterRender()
})
createEffect(() => {
if (autoScroll()) {
scheduleAnchorScroll(true)
if (followScroll.autoScroll()) {
scrollHelpers.restoreAfterRender({ forceBottom: true })
}
})
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
/>
)
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return (
<div class="tool-call-details">
<Show
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
return !current
})
}
const statusIcon = () => {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return <Hourglass class="w-4 h-4" />
case "running":
return <Loader2 class="w-4 h-4 animate-spin" />
case "completed":
return <Check class="w-4 h-4" />
case "error":
return <XCircle class="w-4 h-4" />
default:
return ""
}
}
const statusClass = () => {
const status = toolState()?.status || "pending"
return `tool-call-status-${status}`
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
/>
</Show>
<span class="tool-call-header-status" aria-hidden="true">
{statusIcon()}
</span>
<ToolStatusIndicator status={status} />
</div>
<Show when={expanded()}>

View File

@@ -1,4 +1,4 @@
import type { Accessor, JSXElement } from "solid-js"
import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils"
@@ -11,6 +11,97 @@ type CacheHandle = {
set(value: unknown): void
}
export interface StableAnsiStreamUpdater {
update: (element: HTMLElement, content: string) => void
reset: () => void
}
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
const renderer = createAnsiStreamRenderer()
let previousContent = ""
let ansiActive = false
return {
update(element: HTMLElement, content: string) {
const resetStreaming = !previousContent || !content.startsWith(previousContent)
if (resetStreaming) {
ansiActive = hasAnsi(content)
renderer.reset()
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
previousContent = content
return
}
const delta = content.slice(previousContent.length)
if (delta.length === 0) {
return
}
if (!ansiActive && hasAnsi(delta)) {
ansiActive = true
renderer.reset()
element.innerHTML = renderer.render(content)
previousContent = content
return
}
if (ansiActive) {
const htmlChunk = renderer.render(delta)
if (htmlChunk.length > 0) {
element.insertAdjacentHTML("beforeend", htmlChunk)
}
} else {
const escapedDelta = escapeHtml(delta)
if (escapedDelta.length > 0) {
element.insertAdjacentHTML("beforeend", escapedDelta)
}
}
previousContent = content
},
reset() {
previousContent = ""
ansiActive = false
renderer.reset()
},
}
}
function StreamingAnsiContent(props: {
html: string
htmlChunk?: string
updateMode: "replace" | "append" | "noop"
}) {
let preRef: HTMLPreElement | undefined
createEffect(() => {
const element = preRef
if (!element) return
if (props.updateMode === "noop") return
if (props.updateMode === "append") {
if (element.innerHTML.length === 0) {
element.innerHTML = props.html
return
}
const chunk = props.htmlChunk ?? ""
if (chunk.length > 0) {
element.insertAdjacentHTML("beforeend", chunk)
}
return
}
if (element.innerHTML !== props.html) {
element.innerHTML = props.html
}
})
onCleanup(() => {
preRef = undefined
})
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
}
export function createAnsiContentRenderer(params: {
ansiRunningCache: CacheHandle
ansiFinalCache: CacheHandle
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let updateMode: "replace" | "append" | "noop" = "replace"
let htmlChunk = ""
let nextCache: AnsiRenderCache
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) {
updateMode = "replace"
const detectedAnsi = hasAnsi(content)
if (detectedAnsi) {
runningAnsiRenderer.reset()
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
} else {
const delta = content.slice(cached.text.length)
if (delta.length === 0) {
updateMode = "noop"
nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) {
updateMode = "replace"
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 }
const appendedHtml = runningAnsiRenderer.render(delta)
updateMode = "append"
htmlChunk = appendedHtml
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
} else {
updateMode = "append"
htmlChunk = escapeHtml(delta)
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
}
}
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
return (
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
<StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)

View File

@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => {
if (!disableScrollTracking) {
params.handleScrollRendered()
}
params.handleScrollRendered()
params.onContentRendered?.()
}

View File

@@ -1,6 +1,107 @@
import type { ToolRenderer } from "../types"
import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer, ToolScrollHelpers } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n"
import { createStableAnsiStreamUpdater } from "../ansi-render"
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
}) {
let preRef: HTMLPreElement | undefined
const updater = createStableAnsiStreamUpdater()
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
})
onCleanup(() => {
preRef = undefined
updater.reset()
})
return (
<div
class="message-text tool-call-markdown"
ref={props.scrollHelpers?.registerContainer}
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
>
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
{props.scrollHelpers?.renderSentinel?.()}
</div>
)
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
}) {
const state = createMemo(() => props.toolState())
const joinedContent = createMemo(() => {
const current = state()
if (!current || current.status === "pending") return ""
const { input, metadata } = readToolStatePayload(current)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(current)
? current.output
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
? metadata.output
: undefined,
)
return [command, outputResult?.text].filter(Boolean).join("\n")
})
const finalMarkdown = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (hasAnsi(content)) {
return null
}
return ensureMarkdownContent(content, "bash", true)
})
const finalAnsiHtml = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (!hasAnsi(content)) {
return null
}
return ansiToHtml(content)
})
return (
<Show when={state() && joinedContent().length > 0}>
<Show
when={state()?.status === "running"}
fallback={
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
{(html) => (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
</div>
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
</Show>
</Show>
)
}
export const bashRenderer: ToolRenderer = {
tools: ["bash"],
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
},
renderBody({ toolState, renderMarkdown, renderAnsi }) {
const state = toolState()
if (!state || state.status === "pending") return null
const { input, metadata } = readToolStatePayload(state)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: undefined,
)
const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null
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 })
renderBody({ toolState, renderMarkdown, scrollHelpers }) {
return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
},
}

View File

@@ -1,4 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state)
return describeTaskTitle(input)
},
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) {
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
const store = messageStoreBus.getOrCreate(instanceId)
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
})
})
createEffect(() => {
const childCount = childToolKeys().length
const legacyCount = legacyItems().length
if (childCount === 0 && legacyCount === 0) return
scrollHelpers?.restoreAfterRender()
onContentRendered?.()
})
return (
<div class="tool-call-task-sections">
<Show when={promptContent()}>
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
}
>
<div class="tool-call-task-summary">
<For each={childToolKeys()}>
<Index each={childToolKeys()}>
{(key) => (
<Show when={renderToolCall}>
{(render) => (
<TaskToolCallRow
toolKey={key}
toolKey={key()}
store={store}
sessionId={childSessionId()}
renderToolCall={render()}
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
)}
</Show>
)}
</For>
</Index>
</div>
{scrollHelpers?.renderSentinel?.()}
</div>

View File

@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
restoreAfterRender(options?: { forceBottom?: boolean }): void
}
export interface ToolRendererContext {
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
forceCollapsed?: boolean
}) => JSXElement | null
scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
}
export interface ToolRenderer {

View File

@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
mode?: "mention" | "command"
onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void
onSubmitWithoutSelection?: () => void
agents: Agent[]
commands?: SDKCommand[]
instanceClient: OpencodeClient | null
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (selected) {
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action)
} else if (e.key === "Enter" && mode() === "mention") {
props.onSubmitWithoutSelection?.()
}
} else if (e.key === "Escape") {
e.preventDefault()

View File

@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function stripCodeFence(value: string): string {
const trimmed = value.trim()
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
if (!normalized) return false
return HUNK_PATTERN.test(normalized)
}
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
if (!patch || patch.trim().length === 0) {
return { before: "", after: "" }
}
const lines = patch.replace(/\r\n/g, "\n").split("\n")
const beforeLines: string[] = []
const afterLines: string[] = []
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
continue
}
if (HUNK_HEADER_PATTERN.test(line)) {
continue
}
if (line.startsWith("-") && !line.startsWith("---")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+") && !line.startsWith("+++")) {
afterLines.push(line.slice(1))
} else if (line.startsWith(" ")) {
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
} else if (line === "") {
beforeLines.push("")
afterLines.push("")
} else {
beforeLines.push(line)
afterLines.push(line)
}
}
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
beforeLines.pop()
}
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
afterLines.pop()
}
return {
before: beforeLines.join("\n"),
after: afterLines.join("\n"),
}
}

View File

@@ -0,0 +1,259 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelMarginPx: number
sentinelClassName: string
intentWindowMs?: number
intentKeys?: ReadonlySet<string>
}
export interface FollowScrollHelpers {
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
autoScroll: Accessor<boolean>
}
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = options.getScrollTopSnapshot()
let pointerInteractionActive = false
let suppressNextScrollHandling = false
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
suppressNextScrollHandling = true
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
function persistScrollSnapshot(element?: HTMLElement | null) {
if (!element) return
lastKnownScrollTop = element.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
}
function hasUserScrollIntent() {
if (pointerInteractionActive) {
return true
}
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
const handlePointerIntent = () => {
pointerInteractionActive = true
markUserScrollIntent()
}
const clearPointerIntent = () => {
pointerInteractionActive = false
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (intentKeys.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.addEventListener("pointerup", clearPointerIntent)
window.addEventListener("pointercancel", clearPointerIntent)
window.addEventListener("mouseup", clearPointerIntent)
window.addEventListener("touchend", clearPointerIntent)
window.addEventListener("touchcancel", clearPointerIntent)
}
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.removeEventListener("pointerup", clearPointerIntent)
window.removeEventListener("pointercancel", clearPointerIntent)
window.removeEventListener("mouseup", clearPointerIntent)
window.removeEventListener("touchend", clearPointerIntent)
window.removeEventListener("touchcancel", clearPointerIntent)
}
pointerInteractionActive = false
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
if (Math.abs(delta) > 1) {
suppressNextScrollHandling = true
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function isAtBottom(container: HTMLDivElement) {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
}
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
const container = containerOverride ?? scrollContainer()
if (!container) return
if (suppressNextScrollHandling) {
suppressNextScrollHandling = false
return
}
const isUserScroll = hasUserScrollIntent()
const atBottomFromScroll = isAtBottom(container)
const atBottom = atBottomFromScroll || bottomSentinelVisible()
if (isUserScroll || !atBottom) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
updateFollowModeFromScroll(event.currentTarget)
persistScrollSnapshot(event.currentTarget)
}
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
lastKnownScrollTop = options.getScrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const renderSentinel = (config?: { disableTracking?: boolean }) => {
if (config?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
}
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
const container = scrollContainerRef
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
if (autoScroll()) {
setAutoScroll(false)
}
requestAnimationFrame(() => {
restoreScrollPosition(false)
})
return
}
const shouldFollow = config?.forceBottom ?? autoScroll()
requestAnimationFrame(() => {
restoreScrollPosition(shouldFollow)
if (shouldFollow) {
scheduleAnchorScroll(true)
}
})
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return {
registerContainer,
handleScroll,
renderSentinel,
restoreAfterRender,
autoScroll,
}
}

View File

@@ -160,6 +160,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
"instanceShell.backgroundProcesses.actions.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate",

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
"instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
"instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",

View File

@@ -158,6 +158,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効",
"instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効",
"instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了",

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Уведомление о завершении включено",
"instanceShell.backgroundProcesses.notify.disabled": "Уведомление о завершении выключено",
"instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",

View File

@@ -150,6 +150,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "没有后台进程。",
"instanceShell.backgroundProcesses.status": "状态:{status}",
"instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "已启用完成通知",
"instanceShell.backgroundProcesses.notify.disabled": "已禁用完成通知",
"instanceShell.backgroundProcesses.actions.output": "输出",
"instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "终止",

View File

@@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
sessions: {},
sessionOrder: [],
messages: {},
lastAssistantMessageIds: {},
messageInfoVersion: {},
pendingParts: {},
sessionRevisions: {},
@@ -218,6 +219,7 @@ export interface InstanceMessageStore {
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[]
getLastAssistantMessageId: (sessionId: string) => string | undefined
// Index of the most recent message in the session that contains a compaction part.
// Returns -1 if there has been no compaction.
getLastCompactionMessageIndex: (sessionId: string) => number
@@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const messageInfoCache = new Map<string, MessageInfo>()
function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined {
for (let index = messageIds.length - 1; index >= 0; index -= 1) {
const messageId = messageIds[index]
if (state.messages[messageId]?.role === "assistant") {
return messageId
}
}
return undefined
}
function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) {
if (!sessionId) return
setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? []))
}
function getLastCompactionMessageIndex(sessionId: string): number {
if (!sessionId) return -1
const ids = state.sessions[sessionId]?.messageIds ?? []
@@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return state.sessionRevisions[sessionId] ?? 0
}
function getLastAssistantMessageIdValue(sessionId: string) {
return state.lastAssistantMessageIds[sessionId]
}
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
setState("usage", sessionId, (current) => {
const draft = current
@@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
recomputeLastAssistantMessageId(input.id, nextMessageIds)
bumpSessionRevision(input.id)
}
}
@@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageIds: incomingIds,
updatedAt: Date.now(),
}))
recomputeLastAssistantMessageId(sessionId, incomingIds)
Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record)
@@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id)
recomputeLastAssistantMessageId(input.sessionId)
bumpSessionRevision(input.sessionId)
}
@@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
if (state.latestTodos[sessionId]?.messageId === messageId) {
clearLatestTodoSnapshot(sessionId)
}
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId)
})
})
@@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
affectedSessions.add(session.id)
})
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
affectedSessions.forEach((sessionId) => {
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId)
})
const infoEntry = messageInfoCache.get(options.oldId)
if (infoEntry) {
@@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
removedIds.forEach((id) => removeUsageEntry(draft, id))
})
recomputeLastAssistantMessageId(sessionId, keptIds)
bumpSessionRevision(sessionId)
}
@@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next
})
setState("lastAssistantMessageIds", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("scrollState", (prev) => {
const next = { ...prev }
const prefix = `${sessionId}:`
@@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setSessionRevert,
getSessionRevert,
rebuildUsage,
getSessionUsage,
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastCompactionMessageIndex,
getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession,
clearInstance,
}
}
rebuildUsage,
getSessionUsage,
setScrollSnapshot,
getScrollSnapshot,
getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastAssistantMessageId: getLastAssistantMessageIdValue,
getLastCompactionMessageIndex,
getMessage: (messageId: string) => state.messages[messageId],
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearSession,
clearInstance,
}
}

View File

@@ -116,18 +116,11 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
// Prefer explicit input limits when provided by the API.
// This is used by the UI "Avail" chip.
contextAvailableTokens = modelInputLimit
}
if (!contextAvailableFromPrevious && contextAvailableTokens === null) {
if (contextWindow > 0) {
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextAvailableTokens = null
}
} else if (contextWindow > 0) {
// When no explicit input limit, show full context window capacity.
contextAvailableTokens = contextWindow
} else {
contextAvailableTokens = null
}
setSessionInfoByInstance((prev) => {

View File

@@ -113,6 +113,7 @@ export interface InstanceMessageState {
sessions: Record<string, SessionRecord>
sessionOrder: string[]
messages: Record<string, MessageRecord>
lastAssistantMessageIds: Record<string, string | undefined>
messageInfoVersion: Record<string, number>
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>

View File

@@ -78,6 +78,10 @@ export interface TextPart {
export type MessageInfo = SDKMessage
export function isHiddenSyntheticTextPart(part: ClientPart): boolean {
return Boolean(part && part.type === "text" && part.synthetic)
}
function hasTextSegment(segment: string | { text?: string }): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
@@ -95,6 +99,10 @@ export function partHasRenderableText(part: ClientPart): boolean {
return false
}
if (isHiddenSyntheticTextPart(part)) {
return false
}
const typedPart = part as SDKPart
if (typedPart.type === "text" && hasTextSegment(typedPart.text)) {

View File

@@ -4,8 +4,7 @@ import type {
Provider as SDKProvider,
Model as SDKModel,
} from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import type { SessionStatus as SDKSessionStatus, FileDiff } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use
export type {