perf(app): add session compaction and dev-mode perf diagnostics

This commit is contained in:
Benjamin Shafii
2026-02-18 17:08:38 -08:00
parent 218188bfc3
commit b9e1ab16ef
8 changed files with 599 additions and 122 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -41,11 +41,13 @@ import { createClient, unwrap, waitForHealthy, type OpencodeAuth } from "./lib/o
import {
abortSession as abortSessionTyped,
abortSessionSafe,
compactSession as compactSessionTyped,
revertSession,
unrevertSession,
shellInSession,
listCommands as listCommandsTyped,
} from "./lib/opencode-session";
import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-log";
import {
DEFAULT_MODEL,
HIDE_TITLEBAR_PREF_KEY,
@@ -589,6 +591,11 @@ export default function App() {
const [developerMode, setDeveloperMode] = createSignal(false);
const [documentVisible, setDocumentVisible] = createSignal(true);
createEffect(() => {
if (developerMode()) return;
clearPerfLogs();
});
const [selectedSessionId, setSelectedSessionId] = createSignal<string | null>(
null
);
@@ -847,6 +854,15 @@ export default function App() {
const c = client();
if (!c) return;
const compactShortcut = /^\/compact(?:\s+.*)?$/i.test(content);
const compactCommand = resolvedDraft.command?.name === "compact" || compactShortcut;
const commandName = compactCommand ? "compact" : (resolvedDraft.command?.name ?? null);
if (compactCommand && !selectedSessionId()) {
setError("Select a session with messages before running /compact.");
return;
}
let sessionID = selectedSessionId();
if (!sessionID) {
await createSessionAndOpen();
@@ -859,8 +875,24 @@ export default function App() {
setBusyStartedAt(Date.now());
setError(null);
const perfEnabled = developerMode();
const startedAt = perfNow();
const visible = messages();
const visibleParts = visible.reduce((total, message) => total + message.parts.length, 0);
recordPerfLog(perfEnabled, "session.prompt", "start", {
sessionID,
mode: resolvedDraft.mode,
command: commandName,
charCount: content.length,
attachmentCount: resolvedDraft.attachments.length,
messageCount: visible.length,
partCount: visibleParts,
});
try {
setLastPromptSent(content);
if (!compactCommand) {
setLastPromptSent(content);
}
setPrompt("");
const model = selectedSessionModel();
@@ -869,7 +901,22 @@ export default function App() {
if (resolvedDraft.mode === "shell") {
await shellInSession(c, sessionID, content);
} else if (resolvedDraft.command) {
} else if (resolvedDraft.command || compactCommand) {
if (compactCommand) {
await compactCurrentSession(sessionID);
finishPerf(perfEnabled, "session.prompt", "done", startedAt, {
sessionID,
mode: resolvedDraft.mode,
command: commandName,
});
return;
}
const command = resolvedDraft.command;
if (!command) {
throw new Error("Command was not resolved.");
}
// Slash command: route through session.command() API
const selected = selectedSessionModel();
const modelString = `${selected.providerID}/${selected.modelID}`;
@@ -879,8 +926,8 @@ export default function App() {
unwrap(
await c.session.command({
sessionID,
command: resolvedDraft.command.name,
arguments: resolvedDraft.command.arguments,
command: command.name,
arguments: command.arguments,
agent: agent ?? undefined,
model: modelString,
variant: modelVariant() ?? undefined,
@@ -910,7 +957,19 @@ export default function App() {
return copy;
});
}
finishPerf(perfEnabled, "session.prompt", "done", startedAt, {
sessionID,
mode: resolvedDraft.mode,
command: commandName,
});
} catch (e) {
finishPerf(perfEnabled, "session.prompt", "error", startedAt, {
sessionID,
mode: resolvedDraft.mode,
command: commandName,
error: e instanceof Error ? e.message : safeStringify(e),
});
const message = e instanceof Error ? e.message : safeStringify(e);
setError(addOpencodeCacheHint(message));
} finally {
@@ -942,6 +1001,52 @@ export default function App() {
});
}
async function compactCurrentSession(sessionIdOverride?: string) {
const c = client();
if (!c) {
throw new Error("Not connected to a server");
}
const sessionID = (sessionIdOverride ?? selectedSessionId() ?? "").trim();
if (!sessionID) {
throw new Error("Select a session before compacting.");
}
const visible = messages();
if (!visible.length) {
throw new Error("Nothing to compact yet.");
}
const model = selectedSessionModel();
const startedAt = perfNow();
const modelLabel = `${model.providerID}/${model.modelID}`;
recordPerfLog(developerMode(), "session.compact", "start", {
sessionID,
messageCount: visible.length,
model: modelLabel,
variant: modelVariant() ?? null,
});
try {
await compactSessionTyped(c, sessionID, model, {
directory: workspaceProjectDir().trim() || undefined,
});
finishPerf(developerMode(), "session.compact", "done", startedAt, {
sessionID,
messageCount: visible.length,
model: modelLabel,
});
} catch (error) {
finishPerf(developerMode(), "session.compact", "error", startedAt, {
sessionID,
messageCount: visible.length,
model: modelLabel,
error: error instanceof Error ? error.message : safeStringify(error),
});
throw error;
}
}
const messageIdFromInfo = (message: MessageWithParts) => {
const id = (message.info as { id?: string | number }).id;
if (typeof id === "string") return id;
@@ -1144,10 +1249,21 @@ export default function App() {
return list.filter((agent) => !agent.hidden && agent.mode !== "subagent");
}
const BUILTIN_COMPACT_COMMAND = {
id: "builtin:compact",
name: "compact",
description: "Summarize this session to reduce context size.",
source: "command" as const,
};
async function listCommands(): Promise<{ id: string; name: string; description?: string; source?: "command" | "mcp" | "skill" }[]> {
const c = client();
if (!c) return [];
return listCommandsTyped(c, workspaceStore.activeWorkspaceRoot().trim() || undefined);
const list = await listCommandsTyped(c, workspaceStore.activeWorkspaceRoot().trim() || undefined);
if (list.some((entry) => entry.name === "compact")) {
return list;
}
return [BUILTIN_COMPACT_COMMAND, ...list];
}
function setSessionAgent(sessionID: string, agent: string | null) {
@@ -3208,13 +3324,19 @@ export default function App() {
}
async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) {
console.log("[connectMcp] called with entry:", entry);
const startedAt = perfNow();
const isRemoteWorkspace =
workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" ||
(!isTauriRuntime() && openworkServerStatus() === "connected");
const projectDir = workspaceProjectDir().trim();
console.log("[connectMcp] projectDir:", projectDir);
const entryType = entry.type ?? "remote";
recordPerfLog(developerMode(), "mcp.connect", "start", {
name: entry.name,
type: entryType,
workspaceType: isRemoteWorkspace ? "remote" : "local",
projectDir: projectDir || null,
});
const openworkClient = openworkServerClient();
let openworkWorkspaceId = openworkServerWorkspaceId();
@@ -3238,26 +3360,30 @@ export default function App() {
openworkCapabilities?.mcp?.write;
if (isRemoteWorkspace && !canUseOpenworkServer) {
console.log("[connectMcp] ❌ openwork server unavailable");
setMcpStatus("OpenWork server unavailable. MCP config is read-only.");
finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "openwork-server-unavailable",
});
return;
}
if (!canUseOpenworkServer && !isTauriRuntime()) {
console.log("[connectMcp] ❌ not Tauri runtime");
setMcpStatus(t("mcp.desktop_required", currentLocale()));
finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "desktop-required",
});
return;
}
console.log("[connectMcp] ✓ runtime ready");
if (!isRemoteWorkspace && !projectDir) {
console.log("[connectMcp] ❌ no projectDir");
setMcpStatus(t("mcp.pick_workspace_first", currentLocale()));
finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "missing-workspace",
});
return;
}
let activeClient = client();
console.log("[connectMcp] activeClient:", activeClient ? "exists" : "null");
if (!activeClient) {
const openworkBaseUrl = openworkServerBaseUrl().trim();
const auth = openworkServerAuth();
@@ -3268,8 +3394,10 @@ export default function App() {
}
}
if (!activeClient) {
console.log("[connectMcp] ❌ no activeClient");
setMcpStatus(t("mcp.connect_server_first", currentLocale()));
finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "no-active-client",
});
return;
}
@@ -3288,24 +3416,24 @@ export default function App() {
}
}
if (!resolvedProjectDir) {
console.log("[connectMcp] ❌ no projectDir after lookup");
setMcpStatus(t("mcp.pick_workspace_first", currentLocale()));
finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, {
reason: "missing-workspace-after-discovery",
});
return;
}
const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const entryType = entry.type ?? "remote";
console.log("[connectMcp] slug:", slug);
try {
setMcpStatus(null);
setMcpConnectingName(entry.name);
console.log("[connectMcp] connecting name set to:", entry.name);
const mcpEntryConfig: Record<string, unknown> = {
type: entryType,
enabled: true,
};
if (entryType === "remote") {
if (!entry.url) {
throw new Error("Missing MCP URL.");
@@ -3315,62 +3443,52 @@ export default function App() {
mcpEntryConfig["oauth"] = {};
}
}
if (entryType === "local") {
if (!entry.command?.length) {
throw new Error("Missing MCP command.");
}
mcpEntryConfig["command"] = entry.command;
}
if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) {
await openworkClient.addMcp(openworkWorkspaceId, {
name: slug,
config: mcpEntryConfig,
});
console.log("[connectMcp] added MCP via OpenWork server");
} else {
// Step 1: Read existing opencode.json config
console.log("[connectMcp] reading opencode config for projectDir:", projectDir);
const configFile = await readOpencodeConfig("project", resolvedProjectDir);
console.log("[connectMcp] config file result:", configFile);
// Step 2: Parse and merge the MCP entry into the config
let existingConfig: Record<string, unknown> = {};
if (configFile.exists && configFile.content?.trim()) {
try {
existingConfig = parse(configFile.content) ?? {};
console.log("[connectMcp] parsed existing config:", existingConfig);
} catch (parseErr) {
console.warn("[connectMcp] failed to parse existing config, starting fresh:", parseErr);
recordPerfLog(developerMode(), "mcp.connect", "config-parse-failed", {
error: parseErr instanceof Error ? parseErr.message : String(parseErr),
});
existingConfig = {};
}
}
// Ensure base structure
if (!existingConfig["$schema"]) {
existingConfig["$schema"] = "https://opencode.ai/config.json";
}
// Ensure mcp object exists
const mcpSection = (existingConfig["mcp"] as Record<string, unknown>) ?? {};
existingConfig["mcp"] = mcpSection;
// Add the new MCP server entry
mcpSection[slug] = mcpEntryConfig;
console.log("[connectMcp] merged MCP config:", existingConfig);
// Step 3: Write the updated config back
const writeResult = await writeOpencodeConfig(
"project",
resolvedProjectDir,
`${JSON.stringify(existingConfig, null, 2)}\n`
);
console.log("[connectMcp] writeOpencodeConfig result:", writeResult);
if (!writeResult.ok) {
throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json");
}
}
// Step 4: Call SDK mcp.add to update runtime state
const mcpAddConfig =
entryType === "remote"
? {
@@ -3385,25 +3503,18 @@ export default function App() {
enabled: true,
};
const mcpAddPayload = {
directory: resolvedProjectDir,
name: slug,
config: mcpAddConfig,
};
console.log("[connectMcp] calling activeClient.mcp.add with:", mcpAddPayload);
const rawResult = await activeClient.mcp.add(mcpAddPayload);
console.log("[connectMcp] mcp.add raw result:", rawResult);
const status = unwrap(rawResult);
console.log("[connectMcp] mcp.add unwrapped status:", status);
const status = unwrap(
await activeClient.mcp.add({
directory: resolvedProjectDir,
name: slug,
config: mcpAddConfig,
}),
);
setMcpStatuses(status as McpStatusMap);
await refreshMcpServers();
// Step 5: If OAuth, open the auth modal (modal handles the auth flow)
if (entry.oauth) {
console.log("[connectMcp] entry has OAuth, opening auth modal for:", entry.name);
setMcpAuthEntry(entry);
setMcpAuthModalOpen(true);
} else {
@@ -3411,13 +3522,20 @@ export default function App() {
}
await refreshMcpServers();
console.log("[connectMcp] ✓ done");
finishPerf(developerMode(), "mcp.connect", "done", startedAt, {
name: entry.name,
type: entryType,
slug,
});
} catch (e) {
console.error("[connectMcp] ❌ error:", e);
setMcpStatus(e instanceof Error ? e.message : t("mcp.connect_failed", currentLocale()));
finishPerf(developerMode(), "mcp.connect", "error", startedAt, {
name: entry.name,
type: entryType,
error: e instanceof Error ? e.message : safeStringify(e),
});
} finally {
setMcpConnectingName(null);
console.log("[connectMcp] finally block, connecting name cleared");
}
}
@@ -3555,35 +3673,46 @@ export default function App() {
}
async function createSessionAndOpen() {
console.log("[DEBUG] createSessionAndOpen");
console.log("[DEBUG] current baseUrl:", baseUrl());
console.log("[DEBUG] engine info:", engine());
console.log("[DEBUG] creating session");
const c = client();
if (!c) {
console.log("[DEBUG] no client available!");
return;
}
const perfEnabled = developerMode();
const startedAt = perfNow();
const runId = (() => {
const key = "__openwork_create_session_run__";
const w = window as typeof window & { [key]?: number };
w[key] = (w[key] ?? 0) + 1;
return w[key];
})();
const mark = (event: string, payload?: Record<string, unknown>) => {
const elapsed = Math.round((perfNow() - startedAt) * 100) / 100;
recordPerfLog(perfEnabled, "session.create", event, {
runId,
elapsedMs: elapsed,
...(payload ?? {}),
});
};
mark("start", {
baseUrl: baseUrl(),
workspace: workspaceStore.activeWorkspaceRoot().trim() || null,
});
// Abort any in-flight refresh operations to free up connection resources
console.log("[DEBUG] aborting in-flight refreshes");
abortRefreshes();
// Small delay to allow pending requests to settle
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("[DEBUG] client found");
setBusy(true);
console.log("[DEBUG] busy set");
setBusyLabel("status.creating_task");
console.log("[DEBUG] busy label set");
setBusyStartedAt(Date.now());
console.log("[DEBUG] busy started at set");
setError(null);
console.log("[DEBUG] error set");
setCreatingSession(true);
console.log("[DEBUG] with timeout defined");
const withTimeout = async <T,>(
promise: Promise<T>,
ms: number,
@@ -3605,55 +3734,39 @@ export default function App() {
}
};
const runId = (() => {
const key = "__openwork_create_session_run__";
const w = window as typeof window & { [key]?: number };
w[key] = (w[key] ?? 0) + 1;
return w[key];
})();
const mark = (() => {
const start = Date.now();
return (label: string, payload?: unknown) => {
const elapsedMs = Date.now() - start;
if (payload === undefined) {
console.log(`[run ${runId}] ${label} (+${elapsedMs}ms)`);
} else {
console.log(`[run ${runId}] ${label} (+${elapsedMs}ms)`, payload);
}
};
})();
try {
// Quick health check to detect stale connection
mark("checking health");
mark("health:start");
try {
const healthResult = await withTimeout(c.global.health(), 3_000, "health");
mark("health ok", healthResult);
await withTimeout(c.global.health(), 3_000, "health");
mark("health:ok");
} catch (healthErr) {
mark("health FAILED", healthErr);
mark("health:error", {
error: healthErr instanceof Error ? healthErr.message : safeStringify(healthErr),
});
throw new Error(t("app.connection_lost", currentLocale()));
}
let rawResult: Awaited<ReturnType<typeof c.session.create>>;
try {
mark("creating session");
mark("session:create:start");
rawResult = await c.session.create({
directory: workspaceStore.activeWorkspaceRoot().trim(),
});
mark("session created");
mark("session:create:ok");
} catch (createErr) {
mark("session create error", createErr);
mark("session:create:error", {
error: createErr instanceof Error ? createErr.message : safeStringify(createErr),
});
throw createErr;
}
mark("raw result received");
const session = unwrap(rawResult);
mark("session unwrapped");
// Immediately select and show the new session before background list refresh.
setBusyLabel("status.loading_session");
mark("session:select:start", { sessionID: session.id });
await selectSession(session.id);
mark("selectSession (immediate)");
mark("session selected");
mark("session:select:ok", { sessionID: session.id });
// Inject the new session into the reactive sessions() store so
// the createEffect bridge (sessions → sidebar) will always include it,
@@ -3662,7 +3775,6 @@ export default function App() {
if (!currentStoreSessions.some((s) => s.id === session.id)) {
setSessions([session, ...currentStoreSessions]);
}
mark("session injected into store");
const newItem: SidebarSessionItem = {
id: session.id,
@@ -3683,9 +3795,7 @@ export default function App() {
[wsId]: "ready",
}));
}
mark("sidebar injected");
mark("view set to session");
// setSessionViewLockUntil(Date.now() + 1200);
goToSession(session.id);
@@ -3695,10 +3805,16 @@ export default function App() {
// race with the store injection — the server may not have indexed the
// session yet, so reconcile() would wipe it from the store, causing
// the sidebar to flash and the route guard to bounce back.
mark("done (SSE will sync)");
finishPerf(perfEnabled, "session.create", "done", startedAt, {
runId,
sessionID: session.id,
});
return session.id;
} catch (e) {
mark("error caught", e);
finishPerf(perfEnabled, "session.create", "error", startedAt, {
runId,
error: e instanceof Error ? e.message : safeStringify(e),
});
const message = e instanceof Error ? e.message : t("app.unknown_error", currentLocale());
setError(addOpencodeCacheHint(message));
return undefined;
@@ -4771,6 +4887,7 @@ export default function App() {
sessionRevertMessageId: selectedSession()?.revert?.messageID ?? null,
undoLastUserMessage: undoLastUserMessage,
redoLastUserMessage: redoLastUserMessage,
compactSession: compactCurrentSession,
lastPromptSent: lastPromptSent(),
retryLastPrompt: retryLastPrompt,
newTaskDisabled: newTaskDisabled(),

View File

@@ -6,6 +6,7 @@ import { Check, ChevronDown, ChevronRight, Copy, Eye, File, FileEdit, FolderSear
import type { MessageGroup, MessageWithParts } from "../../types";
import { groupMessageParts, summarizeStep } from "../../utils";
import PartView from "../part-view";
import { perfNow, recordPerfLog } from "../../lib/perf-log";
export type MessageListProps = {
messages: MessageWithParts[];
@@ -195,6 +196,7 @@ export default function MessageList(props: MessageListProps) {
});
const messageBlocks = createMemo<MessageBlockItem[]>(() => {
const startedAt = perfNow();
const blocks: MessageBlockItem[] = [];
for (const message of props.messages) {
@@ -237,6 +239,15 @@ export default function MessageList(props: MessageListProps) {
});
}
const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100;
if (props.developerMode && (elapsedMs >= 8 || props.messages.length >= 120)) {
recordPerfLog(true, "session.render", "message-blocks", {
messageCount: props.messages.length,
blockCount: blocks.length,
ms: elapsedMs,
});
}
return blocks;
});

View File

@@ -25,6 +25,7 @@ import {
safeStringify,
} from "../utils";
import { unwrap } from "../lib/opencode";
import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log";
export type SessionModelState = {
overrides: Record<string, ModelRef>;
@@ -500,10 +501,17 @@ export function createSessionStore(options: {
w[key] = (w[key] ?? 0) + 1;
return w[key];
})();
const mark = (() => {
const start = Date.now();
return (label: string) => console.log(`[selectSession run ${runId}] ${label} (+${Date.now() - start}ms)`);
})();
const perfEnabled = options.developerMode();
const startedAt = perfNow();
const mark = (event: string, payload?: Record<string, unknown>) => {
const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100;
recordPerfLog(perfEnabled, "session.select", event, {
runId,
sessionID,
elapsedMs,
...(payload ?? {}),
});
};
mark("start");
options.setSelectedSessionId(sessionID);
@@ -513,8 +521,10 @@ export function createSessionStore(options: {
try {
await withTimeout(c.global.health(), 3000, "health");
mark("health ok");
} catch {
mark("health FAILED");
} catch (error) {
mark("health FAILED", {
error: error instanceof Error ? error.message : safeStringify(error),
});
throw new Error("Server connection lost. Please reload.");
}
@@ -555,8 +565,10 @@ export function createSessionStore(options: {
return;
}
setStore("todos", sessionID, list);
} catch {
mark("session.todo failed/timeout");
} catch (error) {
mark("session.todo failed/timeout", {
error: error instanceof Error ? error.message : safeStringify(error),
});
setStore("todos", sessionID, []);
}
@@ -568,11 +580,18 @@ export function createSessionStore(options: {
mark("aborting: selection changed before permissions applied");
return;
}
} catch {
mark("permission.list failed/timeout");
} catch (error) {
mark("permission.list failed/timeout", {
error: error instanceof Error ? error.message : safeStringify(error),
});
}
mark("selectSession complete");
finishPerf(perfEnabled, "session.select", "complete", startedAt, {
runId,
sessionID,
messageCount: msgs.length,
todoCount: (store.todos[sessionID] ?? []).length,
});
}
async function respondPermission(requestID: string, reply: "once" | "always" | "reject") {
@@ -930,12 +949,26 @@ export function createSessionStore(options: {
if (eventsToApply.length === 0) return;
last = Date.now();
const startedAt = perfNow();
let applied = 0;
batch(() => {
for (const event of eventsToApply) {
if (!event) continue;
applied += 1;
void applyEvent(event);
}
});
const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100;
const dropped = eventsToApply.length - applied;
if (sessionDebugEnabled() && (elapsedMs >= 12 || applied >= 40 || dropped >= 20)) {
recordPerfLog(true, "session.sse", "flush", {
queued: eventsToApply.length,
applied,
dropped,
ms: elapsedMs,
});
}
};
const schedule = () => {
@@ -951,6 +984,7 @@ export function createSessionStore(options: {
// Reset reconnect counter on successful connection
reconnectAttempt = 0;
recordPerfLog(sessionDebugEnabled(), "session.sse", "connected");
for await (const raw of sub.stream) {
if (cancelled) break;
@@ -978,6 +1012,7 @@ export function createSessionStore(options: {
// Stream ended normally - attempt reconnect unless cancelled
if (!cancelled) {
options.setSseConnected(false);
recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-ended");
scheduleReconnect(controller);
}
} catch (e) {
@@ -988,6 +1023,9 @@ export function createSessionStore(options: {
// Mark SSE as disconnected and schedule reconnect
options.setSseConnected(false);
recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-error", {
error: message,
});
scheduleReconnect(controller);
}
};
@@ -999,6 +1037,10 @@ export function createSessionStore(options: {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
reconnectAttempt++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), 30000);
recordPerfLog(sessionDebugEnabled(), "session.sse", "reconnect-scheduled", {
attempt: reconnectAttempt,
delayMs: delay,
});
reconnectTimer = setTimeout(() => {
if (cancelled) return;

View File

@@ -8,7 +8,7 @@
* (e.g. `shellAsync`) that may not be present in older SDK versions.
*/
import type { Session } from "@opencode-ai/sdk/v2/client";
import type { Client } from "../types";
import type { Client, ModelRef } from "../types";
import { unwrap } from "./opencode";
// ---------------------------------------------------------------------------
@@ -54,6 +54,45 @@ export async function unrevertSession(
return unwrap(await client.session.unrevert({ sessionID })) as Session;
}
/**
* Compact/summarize a long session to reduce context size.
* Uses `session.summarize` when available and falls back to `/compact` command.
*/
export async function compactSession(
client: Client,
sessionID: string,
model: ModelRef,
options?: { directory?: string },
): Promise<void> {
const session = client.session as { summarize?: (input: {
sessionID: string;
directory?: string;
providerID: string;
modelID: string;
}) => Promise<unknown> };
if (typeof session.summarize === "function") {
const result = await session.summarize({
sessionID,
directory: options?.directory,
providerID: model.providerID,
modelID: model.modelID,
});
assertNoClientError(result);
return;
}
const modelString = `${model.providerID}/${model.modelID}`;
const result = await client.session.command({
sessionID,
command: "compact",
arguments: "",
model: modelString,
directory: options?.directory,
});
assertNoClientError(result);
}
// ---------------------------------------------------------------------------
// Shell execution
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,91 @@
export type PerfLogRecord = {
id: number;
at: string;
ts: number;
scope: string;
event: string;
payload?: Record<string, unknown>;
};
type PerfRoot = typeof globalThis & {
__openworkPerfSeq?: number;
__openworkPerfLogs?: PerfLogRecord[];
};
const PERF_LOG_LIMIT = 500;
export const perfNow = () => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
};
const round = (value: number) => Math.round(value * 100) / 100;
export const recordPerfLog = (
enabled: boolean,
scope: string,
event: string,
payload?: Record<string, unknown>,
) => {
if (!enabled) return;
const root = globalThis as PerfRoot;
const id = (root.__openworkPerfSeq ?? 0) + 1;
root.__openworkPerfSeq = id;
const entry: PerfLogRecord = {
id,
at: new Date().toISOString(),
ts: Date.now(),
scope,
event,
payload,
};
const logs = root.__openworkPerfLogs ?? [];
logs.push(entry);
if (logs.length > PERF_LOG_LIMIT) {
logs.splice(0, logs.length - PERF_LOG_LIMIT);
}
root.__openworkPerfLogs = logs;
try {
if (payload === undefined) {
console.log(`[OWPERF] ${scope}:${event}`);
return;
}
console.log(`[OWPERF] ${scope}:${event}`, payload);
} catch {
// ignore
}
};
export const readPerfLogs = (limit = 120) => {
const root = globalThis as PerfRoot;
const logs = root.__openworkPerfLogs ?? [];
if (limit <= 0) return [];
if (logs.length <= limit) return logs.slice();
return logs.slice(logs.length - limit);
};
export const clearPerfLogs = () => {
const root = globalThis as PerfRoot;
root.__openworkPerfLogs = [];
root.__openworkPerfSeq = 0;
};
export const finishPerf = (
enabled: boolean,
scope: string,
event: string,
startedAt: number,
payload?: Record<string, unknown>,
) => {
if (!enabled) return;
recordPerfLog(enabled, scope, event, {
...(payload ?? {}),
ms: round(perfNow() - startedAt),
});
};

View File

@@ -1,6 +1,7 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { isTauriRuntime } from "../utils";
import { readPerfLogs } from "../lib/perf-log";
import Button from "../components/button";
import TextInput from "../components/text-input";
@@ -140,6 +141,7 @@ export default function ConfigView(props: ConfigViewProps) {
const urlOverride = props.openworkServerSettings.urlOverride?.trim() ?? "";
const token = props.openworkServerSettings.token?.trim() ?? "";
const host = hostInfo();
const perfLogs = props.developerMode ? readPerfLogs(80) : [];
return {
capturedAt: new Date().toISOString(),
runtime: {
@@ -178,6 +180,10 @@ export default function ConfigView(props: ConfigViewProps) {
hostConnectUrl: hostConnectUrl() || null,
hostConnectUrlUsesMdns: hostConnectUrlUsesMdns(),
},
performance: {
retainedEntries: perfLogs.length,
recent: perfLogs,
},
};
});

View File

@@ -69,6 +69,7 @@ import {
normalizeDirectoryPath,
parseTemplateFrontmatter,
} from "../utils";
import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log";
import browserSetupTemplate from "../data/commands/browser-setup.md?raw";
import soulSetupTemplate from "../data/commands/give-me-a-soul.md?raw";
@@ -134,6 +135,7 @@ export type SessionViewProps = {
sessionRevertMessageId: string | null;
undoLastUserMessage: () => Promise<void>;
redoLastUserMessage: () => Promise<void>;
compactSession: () => Promise<void>;
lastPromptSent: string;
retryLastPrompt: () => void;
newTaskDisabled: boolean;
@@ -223,7 +225,10 @@ const SOUL_SETUP_TEMPLATE = (() => {
})();
const INITIAL_MESSAGE_WINDOW = 140;
const INITIAL_PART_WINDOW = 700;
const MESSAGE_WINDOW_LOAD_CHUNK = 120;
const MAX_SEARCH_MESSAGE_CHARS = 4_000;
const MAX_SEARCH_HITS = 2_000;
export default function SessionView(props: SessionViewProps) {
let messagesEndEl: HTMLDivElement | undefined;
@@ -250,8 +255,9 @@ export default function SessionView(props: SessionViewProps) {
const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false);
const [searchOpen, setSearchOpen] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [searchQueryDebounced, setSearchQueryDebounced] = createSignal("");
const [activeSearchHitIndex, setActiveSearchHitIndex] = createSignal(0);
const [historyActionBusy, setHistoryActionBusy] = createSignal<"undo" | "redo" | null>(null);
const [historyActionBusy, setHistoryActionBusy] = createSignal<"undo" | "redo" | "compact" | null>(null);
const [messageWindowStart, setMessageWindowStart] = createSignal(0);
const [messageWindowSessionId, setMessageWindowSessionId] = createSignal<string | null>(null);
const [messageWindowExpanded, setMessageWindowExpanded] = createSignal(false);
@@ -297,39 +303,68 @@ export default function SessionView(props: SessionViewProps) {
const messageTextForSearch = (message: MessageWithParts) => {
const chunks: string[] = [];
let used = 0;
const push = (value: string) => {
const next = value.trim();
if (!next) return;
if (used >= MAX_SEARCH_MESSAGE_CHARS) return;
const remaining = MAX_SEARCH_MESSAGE_CHARS - used;
if (next.length > remaining) {
chunks.push(next.slice(0, Math.max(0, remaining)));
used = MAX_SEARCH_MESSAGE_CHARS;
return;
}
chunks.push(next);
used += next.length;
};
for (const part of message.parts) {
if (part.type === "text") {
const text = (part as { text?: string }).text ?? "";
if (text) chunks.push(text);
push(text);
continue;
}
if (part.type === "agent") {
const name = (part as { name?: string }).name ?? "";
if (name) chunks.push(`@${name}`);
push(name ? `@${name}` : "");
continue;
}
if (part.type === "file") {
const file = part as { label?: string; path?: string; filename?: string };
const label = file.label ?? file.path ?? file.filename ?? "";
if (label) chunks.push(label);
push(label);
continue;
}
if (part.type === "tool") {
const state = (part as { state?: { title?: string; output?: string; error?: string } }).state;
if (state?.title) chunks.push(state.title);
if (state?.output) chunks.push(state.output);
if (state?.error) chunks.push(state.error);
push(state?.title ?? "");
push(state?.output ?? "");
push(state?.error ?? "");
}
}
return chunks.join("\n");
};
createEffect(() => {
const value = searchQuery();
if (typeof window === "undefined") {
setSearchQueryDebounced(value);
return;
}
const id = window.setTimeout(() => setSearchQueryDebounced(value), 90);
onCleanup(() => window.clearTimeout(id));
});
const searchHits = createMemo<SearchHit[]>(() => {
const query = searchQuery().trim().toLowerCase();
if (!searchOpen()) return [];
const query = searchQueryDebounced().trim().toLowerCase();
if (!query) return [];
const startedAt = perfNow();
const hits: SearchHit[] = [];
for (const message of props.messages) {
let capped = false;
outer: for (const message of props.messages) {
const messageId = messageIdFromInfo(message);
if (!messageId) continue;
const haystack = messageTextForSearch(message).toLowerCase();
@@ -337,9 +372,25 @@ export default function SessionView(props: SessionViewProps) {
let index = haystack.indexOf(query);
while (index !== -1) {
hits.push({ messageId });
if (hits.length >= MAX_SEARCH_HITS) {
capped = true;
break outer;
}
index = haystack.indexOf(query, index + Math.max(1, query.length));
}
}
const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100;
if (props.developerMode && (elapsedMs >= 8 || capped)) {
recordPerfLog(true, "session.search", "scan", {
queryLength: query.length,
messageCount: props.messages.length,
hitCount: hits.length,
capped,
ms: elapsedMs,
});
}
return hits;
});
@@ -368,6 +419,28 @@ export default function SessionView(props: SessionViewProps) {
});
const searchActive = createMemo(() => searchOpen() && searchQuery().trim().length > 0);
const totalPartCount = createMemo(() => props.messages.reduce((total, message) => total + message.parts.length, 0));
const computeWindowStart = (messages: MessageWithParts[]) => {
const total = messages.length;
if (!total) return 0;
let count = 0;
let parts = 0;
for (let index = total - 1; index >= 0; index -= 1) {
const nextCount = count + 1;
const nextParts = parts + (messages[index]?.parts.length ?? 0);
if (nextCount > INITIAL_MESSAGE_WINDOW || nextParts > INITIAL_PART_WINDOW) {
return Math.min(total - 1, index + 1);
}
count = nextCount;
parts = nextParts;
}
return 0;
};
const renderedMessages = createMemo(() => {
if (messageWindowExpanded() || searchActive()) return props.messages;
@@ -393,12 +466,50 @@ export default function SessionView(props: SessionViewProps) {
const hidden = hiddenMessageCount();
if (hidden <= 0) return;
const nextStart = Math.max(0, messageWindowStart() - MESSAGE_WINDOW_LOAD_CHUNK);
if (props.developerMode) {
recordPerfLog(true, "session.window", "reveal", {
sessionID: props.selectedSessionId,
hiddenBefore: hidden,
nextStart,
});
}
setMessageWindowStart(nextStart);
if (nextStart === 0) {
setMessageWindowExpanded(true);
}
};
let lastWindowPerfSignature = "";
createEffect(() => {
if (!props.developerMode) {
lastWindowPerfSignature = "";
return;
}
const signature = [
props.selectedSessionId ?? "",
props.messages.length,
totalPartCount(),
renderedMessages().length,
hiddenMessageCount(),
messageWindowExpanded() ? "1" : "0",
searchActive() ? "1" : "0",
].join("|");
if (signature === lastWindowPerfSignature) return;
lastWindowPerfSignature = signature;
recordPerfLog(true, "session.window", "state", {
sessionID: props.selectedSessionId,
messageCount: props.messages.length,
renderedMessageCount: renderedMessages().length,
hiddenMessageCount: hiddenMessageCount(),
partCount: totalPartCount(),
expanded: messageWindowExpanded(),
searchActive: searchActive(),
});
});
const canUndoLastMessage = createMemo(() => {
if (!props.selectedSessionId) return false;
const revert = props.sessionRevertMessageId;
@@ -412,11 +523,17 @@ export default function SessionView(props: SessionViewProps) {
return false;
});
const hasUserMessages = createMemo(() =>
props.messages.some((message) => (message.info as { role?: string }).role === "user"),
);
const canRedoLastMessage = createMemo(() => {
if (!props.selectedSessionId) return false;
return Boolean(props.sessionRevertMessageId);
});
const canCompactSession = createMemo(() => Boolean(props.selectedSessionId) && hasUserMessages());
const touchedFiles = createMemo(() => {
const out: string[] = [];
const seen = new Set<string>();
@@ -633,7 +750,7 @@ export default function SessionView(props: SessionViewProps) {
createEffect(
on(
() => [props.selectedSessionId, props.messages.length] as const,
() => [props.selectedSessionId, props.messages.length, totalPartCount()] as const,
([sessionId, count], previous) => {
const previousSessionId = previous?.[0] ?? null;
if (sessionId !== previousSessionId) {
@@ -646,7 +763,7 @@ export default function SessionView(props: SessionViewProps) {
if (messageWindowExpanded()) return;
if (count === 0) return;
const targetStart = count > INITIAL_MESSAGE_WINDOW ? count - INITIAL_MESSAGE_WINDOW : 0;
const targetStart = computeWindowStart(props.messages);
if (messageWindowSessionId() !== sessionId) {
setMessageWindowStart(targetStart);
setMessageWindowSessionId(sessionId);
@@ -980,6 +1097,7 @@ export default function SessionView(props: SessionViewProps) {
() => {
setSearchOpen(false);
setSearchQuery("");
setSearchQueryDebounced("");
setActiveSearchHitIndex(0);
},
),
@@ -1068,7 +1186,7 @@ export default function SessionView(props: SessionViewProps) {
() => [
props.messages.length,
props.todos.length,
props.messages.reduce((acc, m) => acc + m.parts.length, 0),
totalPartCount(),
],
(current, previous) => {
if (!previous) return;
@@ -1182,6 +1300,7 @@ export default function SessionView(props: SessionViewProps) {
const closeSearch = () => {
setSearchOpen(false);
setSearchQueryDebounced("");
};
const moveSearchHit = (offset: number) => {
@@ -1231,6 +1350,35 @@ export default function SessionView(props: SessionViewProps) {
}
};
const compactSessionHistory = async () => {
if (historyActionBusy()) return;
if (!canCompactSession()) {
setToastMessage("Nothing to compact yet.");
return;
}
const sessionID = props.selectedSessionId;
const startedAt = perfNow();
setHistoryActionBusy("compact");
setToastMessage("Compacting session context...");
try {
await props.compactSession();
setToastMessage("Session compacted.");
finishPerf(props.developerMode, "session.compact", "ui-done", startedAt, {
sessionID,
});
} catch (error) {
const message = error instanceof Error ? error.message : props.safeStringify(error);
setToastMessage(message || "Failed to compact session");
finishPerf(props.developerMode, "session.compact", "ui-error", startedAt, {
sessionID,
error: message,
});
} finally {
setHistoryActionBusy(null);
}
};
const triggerFlyout = (
sourceEl: Element | null,
@@ -2318,6 +2466,18 @@ export default function SessionView(props: SessionViewProps) {
<Loader2 size={16} class="animate-spin" />
</Show>
</button>
<button
type="button"
class="h-9 w-9 flex items-center justify-center rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
onClick={compactSessionHistory}
disabled={!canCompactSession() || historyActionBusy() !== null}
title="Compact session context"
aria-label="Compact session context"
>
<Show when={historyActionBusy() === "compact"} fallback={<Maximize2 size={16} />}>
<Loader2 size={16} class="animate-spin" />
</Show>
</button>
<div ref={(el) => (sessionMenuRef = el)} class="relative">
<button
type="button"
@@ -2336,9 +2496,20 @@ export default function SessionView(props: SessionViewProps) {
<Show when={sessionMenuOpen() && props.selectedSessionId}>
<div
class="absolute right-0 top-[calc(100%+4px)] z-20 w-44 rounded-lg border border-dls-border bg-dls-surface shadow-lg p-1"
class="absolute right-0 top-[calc(100%+4px)] z-20 w-52 rounded-lg border border-dls-border bg-dls-surface shadow-lg p-1"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover disabled:opacity-60"
onClick={() => {
setSessionMenuOpen(false);
void compactSessionHistory();
}}
disabled={!canCompactSession() || historyActionBusy() !== null}
>
Compact session context
</button>
<button
type="button"
class="w-full text-left px-2 py-1.5 text-sm rounded-md hover:bg-dls-hover"