Compare commits

..

12 Commits

Author SHA1 Message Date
Shantur Rathore
c7d4f99e48 fix(ui): prevent settings modal overflow on phones 2026-04-09 21:00:17 +01:00
Shantur Rathore
d50c00afb4 revert: remove debouncing and transparent window from zoom fix
Reverted debouncing logic and transparent window mode that were causing issues.
Kept the zoom step reduction from 0.2 to 0.1 for finer control.
2026-04-09 16:23:45 +01:00
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
27 changed files with 513 additions and 121 deletions

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number outputSizeBytes?: number
} }
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = { type BackgroundProcessOptions = {
baseDir: string baseDir: string
} }
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
args: { args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), 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"), 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) assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", { const process = await request<BackgroundProcess>("", {
method: "POST", 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}` 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 BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess { export interface BackgroundProcess {
id: string id: string
workspaceId: string workspaceId: string
@@ -388,6 +390,8 @@ export interface BackgroundProcess {
stoppedAt?: string stoppedAt?: string
exitCode?: number exitCode?: number
outputSizeBytes?: number outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
} }
export interface BackgroundProcessListResponse { export interface BackgroundProcessListResponse {

View File

@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus" import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager" import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger" 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 ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json" const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string outputPath: string
exitPromise: Promise<void> exitPromise: Promise<void>
workspaceId: string 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 { export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const enriched = await Promise.all( const enriched = await Promise.all(
records.map(async (record) => ({ records.map(async (record) => ({
...record, ...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id), outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})), })),
) )
return enriched 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) const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) { if (!workspace) {
throw new Error("Workspace not found") throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM") this.killProcessTree(child, "SIGTERM")
}) })
const record: BackgroundProcess = { const record: PersistedBackgroundProcess = {
id, id,
workspaceId, workspaceId,
title, title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid, pid: child.pid,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
outputSizeBytes: 0, 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) => { const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve)) await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id) 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.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, completion)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve() resolve()
}) })
}) })
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0 let lastPublishAt = 0
const maybePublishSize = () => { const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record) await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record) this.publishUpdate(workspaceId, record)
return record return this.toPublicProcess(record)
} }
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> { async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
} }
if (record.status === "running") { if (record.status === "running") {
record.status = "stopped" record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
} }
return record return this.toPublicProcess(record)
} }
async terminate(workspaceId: string, processId: string): Promise<void> { async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
return
} }
await this.removeFromIndex(workspaceId, processId) record.status = "stopped"
await this.removeProcessDir(workspaceId, processId) record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
this.deps.eventBus.publish({ await this.finalizeRecord(workspaceId, record, {
type: "instance.event", reason: "user_terminated",
instanceId: workspaceId, endContext: "normal",
event: { type: "background.process.removed", properties: { processId } }, removeAfterFinalize: true,
}) })
} }
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) { private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) { for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
} }
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args return args
} }
private statusFromExit(code: number | null): BackgroundProcessStatus { private completionFromExit(code: number | null): ProcessCompletion {
if (code === null) return "stopped" if (code === 0) {
if (code === 0) return "stopped" return { reason: "finished", endContext: "normal" }
return "error" }
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> { 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) 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) const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null 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) const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return [] if (!existsSync(indexPath)) return []
try { try {
const raw = await fs.readFile(indexPath, "utf-8") const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch { } catch {
return [] return []
} }
} }
private async upsertIndex(workspaceId: string, record: BackgroundProcess) { private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id) const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) { if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next) 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) const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true }) await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) 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({ this.deps.eventBus.publish({
type: "instance.event", type: "instance.event",
instanceId: workspaceId, 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 { private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex") const random = randomBytes(3).toString("hex")

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({ const StartSchema = z.object({
title: z.string().trim().min(1), title: z.string().trim().min(1),
command: 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({ 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) => { app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {}) 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) reply.code(201)
return process return process
}) })

View File

@@ -13,7 +13,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; 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::{ use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
}; };
@@ -31,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0; 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 MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0; const MAX_ZOOM_LEVEL: f64 = 5.0;
@@ -129,7 +131,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) { if should_allow_internal(url) {
return true; return true;
} }
@@ -172,7 +178,11 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?; let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id); 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) { if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone()); let _ = existing.navigate(parsed.clone());
@@ -189,12 +199,13 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
.map_err(|err| err.to_string())? .map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization()); .insert(label.clone(), parsed.origin().ascii_serialization());
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone())) let window =
.title(title) WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.inner_size(1400.0, 900.0) .title(title)
.min_inner_size(800.0, 600.0) .inner_size(1400.0, 900.0)
.build() .min_inner_size(800.0, 600.0)
.map_err(|err| err.to_string())?; .build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone(); let app_handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {

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

View File

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

View File

@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch" 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 { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header"> <div class="status-process-header">
<span class="status-process-title">{process.title}</span> <span class="status-process-title">{process.title}</span>
<div class="status-process-meta"> <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> <span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}> <Show when={typeof process.outputSizeBytes === "number"}>
<span> <span>

View File

@@ -3,7 +3,7 @@ import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart,
import MessageItem from "./message-item" import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" 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 { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
@@ -231,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file" 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) { function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -264,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
return resolved return resolved
}) })
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => { const showAgentMeta = createMemo(() => {
const current = record() const current = record()
if (!current) return false if (!current) return false
if (current.role !== "assistant") return false if (current.role !== "assistant") return false
const currentParts = parts() const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) { if (visibleParts().length === 0) {
return false return false
} }
@@ -286,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) { if (isVisibleContentPart(part)) {
return false return false
}
} }
}
return true return true
}) })
@@ -300,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem <MessageItem
record={resolvedRecord()} record={resolvedRecord()}
messageInfo={messageInfo()} messageInfo={messageInfo()}
parts={parts()} parts={visibleParts()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
@@ -621,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex() const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
// Intentionally untracked: messageInfoVersion updates should not trigger const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [ const cacheSignature = [
current.id, current.id,
current.revision, current.revision,
messageInfoVersion,
isQueued ? 1 : 0, isQueued ? 1 : 0,
props.showThinking() ? 1 : 0, props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0,
@@ -639,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return cachedBlock.block 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 { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = [] const items: MessageBlockItem[] = []
const blockContentKeys: string[] = [] const blockContentKeys: string[] = []
@@ -1100,17 +1110,23 @@ function StepCard(props: StepCardProps) {
return null return null
} }
const info = props.messageInfo 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 return null
} }
const tokens = info.tokens
return { return {
input: tokens.input ?? 0, input: tokens.input ?? 0,
output: tokens.output ?? 0, output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0, reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0, cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0, cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0, cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
} }
} }

View File

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

View File

@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false if (!part || part.type !== "text") return false
return Boolean((part as any).synthetic)
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
} }

View File

@@ -46,6 +46,33 @@ export default function MessageSection(props: MessageSectionProps) {
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) 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({ const scrollCache = useScrollCache({
instanceId: props.instanceId, instanceId: props.instanceId,
@@ -611,7 +638,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi() const api = listApi()
if (!element || !api) return if (!element || !api) return
if (props.loading) return if (props.loading) return
if (messageIds().length === 0) return if (visibleMessageIds().length === 0) return
if (didRestoreScroll()) return if (didRestoreScroll()) return
scrollCache.restore(element, { scrollCache.restore(element, {
@@ -1003,7 +1030,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()} data-scroll-buttons={scrollButtonsCount()}
> >
<VirtualFollowList <VirtualFollowList
items={messageIds} items={visibleMessageIds}
getKey={(messageId) => messageId} getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId} getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId} getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1049,7 +1076,7 @@ export default function MessageSection(props: MessageSectionProps) {
registerState={(state) => setListState(state)} registerState={(state) => setListState(state)}
renderBeforeItems={() => ( renderBeforeItems={() => (
<> <>
<Show when={!props.loading && messageIds().length === 0}> <Show when={!props.loading && visibleMessageIds().length === 0}>
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6"> <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 MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" 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 { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return "" if (!part) return ""
if (isHiddenSyntheticTextPart(part)) return ""
if (typeof (part as any).text === "string") { if (typeof (part as any).text === "string") {
return (part as any).text as string return (part as any).text as string
} }

View File

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

View File

@@ -334,7 +334,7 @@ const Field: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full"> <div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
{props.icon} {props.icon}
<input <input
type={props.type ?? "text"} type={props.type ?? "text"}
@@ -361,7 +361,7 @@ const SelectField: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="min-w-[18rem] max-w-[24rem] w-full"> <div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full"> <select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For> <For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select> </select>

View File

@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/ const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/ const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/ const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function stripCodeFence(value: string): string { function stripCodeFence(value: string): string {
const trimmed = value.trim() const trimmed = value.trim()
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
if (!normalized) return false if (!normalized) return false
return HUNK_PATTERN.test(normalized) 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

@@ -160,6 +160,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "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.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop", "instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate", "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.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "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.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener", "instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar", "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.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", "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.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter", "instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer", "instanceShell.backgroundProcesses.actions.terminate": "Terminer",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -526,14 +526,49 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-screen-frame { .settings-screen-frame {
padding: 0; padding: 0;
overflow: hidden;
} }
.modal-surface.settings-screen-shell { .modal-surface.settings-screen-shell {
width: 100%; width: 100%;
max-width: 100%;
height: 100%; height: 100%;
max-height: none; max-height: none;
min-height: 100%; min-height: 100%;
border-radius: 0; border-radius: 0;
overflow-x: hidden;
}
.modal-surface.settings-screen-shell .settings-screen-nav,
.modal-surface.settings-screen-shell .settings-screen-nav-list,
.modal-surface.settings-screen-shell .settings-screen-content,
.modal-surface.settings-screen-shell .settings-screen-scroll,
.modal-surface.settings-screen-shell .settings-section-stack,
.modal-surface.settings-screen-shell .settings-stack,
.modal-surface.settings-screen-shell .settings-card,
.modal-surface.settings-screen-shell .settings-card-content,
.modal-surface.settings-screen-shell .settings-toggle-row,
.modal-surface.settings-screen-shell .settings-toggle-row > * {
min-width: 0;
}
.modal-surface.settings-screen-shell .selector-trigger,
.modal-surface.settings-screen-shell .selector-input,
.modal-surface.settings-screen-shell .selector-button {
min-width: 0;
max-width: 100%;
}
.modal-surface.settings-screen-shell .settings-toggle-caption,
.modal-surface.settings-screen-shell .settings-inline-note,
.modal-surface.settings-screen-shell .remote-address-url,
.modal-surface.settings-screen-shell code {
overflow-wrap: anywhere;
word-break: break-word;
}
.modal-surface.settings-screen-shell .whitespace-nowrap {
white-space: normal;
} }
.settings-screen-content-header, .settings-screen-content-header,

View File

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

View File

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