mirror of
https://github.com/different-ai/openwork
synced 2026-05-14 02:56:24 +02:00
perf(app): add session compaction and dev-mode perf diagnostics
This commit is contained in:
BIN
packages/app/pr/screenshots/session-perf-compaction-smoke.png
Normal file
BIN
packages/app/pr/screenshots/session-perf-compaction-smoke.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
91
packages/app/src/app/lib/perf-log.ts
Normal file
91
packages/app/src/app/lib/perf-log.ts
Normal 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),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user