diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f137208a..7a2d4663 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,6 +85,33 @@ Tauri or other native shell behavior remains the fallback or shell boundary for: If an agent needs one of the server-owned behaviors above and only a Tauri path exists, treat that as an architecture gap to close rather than a parallel capability surface to preserve. +## Reload-required flow + +OpenWork uses a single reload-required flow for changes that only take effect when OpenCode restarts. + +Key pieces: + +- `createSystemState()` owns the raw queued-reload state. +- `reloadPending()` means a reload is currently queued for the active workspace. +- `markReloadRequired(reason, trigger)` queues the reload and records the source that caused it. +- `app.tsx` exposes `reloadRequired(...sources)` as a small helper for UI filtering. It is used to decide whether the shared reload popup should show for a given trigger type. + +Use this flow when a change mutates startup-loaded OpenCode inputs, for example: + +- `opencode.json` +- `.opencode/skills/**` +- `.opencode/agents/**` +- `.opencode/commands/**` +- MCP definitions or plugin lists that OpenCode only loads at startup + +Do not invent a separate reload banner per feature. New UI that needs restart semantics should: + +1. perform the config or filesystem mutation +2. call `markReloadRequired(...)` +3. rely on the shared reload popup to explain and execute the restart path + +Current examples that should use this shared flow include MCP changes, auto context compaction, default model changes, authorized folder updates, plugin changes, and other `opencode.json` writes. + ## opencode primitives how to pick the right extension abstraction for @opencode diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 4cfc6619..2312b99d 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -38,6 +38,7 @@ import SharedBundleImportModal from "./components/shared-bundle-import-modal"; import StartWithTemplateModal from "./components/start-with-template-modal"; import RenameWorkspaceModal from "./components/rename-workspace-modal"; import McpAuthModal from "./components/mcp-auth-modal"; +import ReloadWorkspaceToast from "./components/reload-workspace-toast"; import StatusToast from "./components/status-toast"; import OnboardingView from "./pages/onboarding"; import DashboardView from "./pages/dashboard"; @@ -58,7 +59,6 @@ import { import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-log"; import { deepLinkBridgeEvent, drainPendingDeepLinks, type DeepLinkBridgeDetail } from "./lib/deep-link-bridge"; import { - AUTO_COMPACT_CONTEXT_PREF_KEY, CHROME_DEVTOOLS_MCP_ID, DEFAULT_MODEL, HIDE_TITLEBAR_PREF_KEY, @@ -1494,6 +1494,10 @@ export default function App() { const [workspaceDefaultModelReady, setWorkspaceDefaultModelReady] = createSignal(false); const [legacyDefaultModel, setLegacyDefaultModel] = createSignal(DEFAULT_MODEL); const [defaultModelExplicit, setDefaultModelExplicit] = createSignal(false); + const [autoCompactContextReady, setAutoCompactContextReady] = createSignal(false); + const [autoCompactContextDirty, setAutoCompactContextDirty] = createSignal(false); + const [autoCompactContextApplied, setAutoCompactContextApplied] = createSignal(true); + const [autoCompactContextSaving, setAutoCompactContextSaving] = createSignal(false); type PromptFocusReturnTarget = "none" | "composer"; const [sessionAgentById, setSessionAgentById] = createSignal>({}); @@ -1589,6 +1593,7 @@ export default function App() { sessionStatusById, selectedSession, selectedSessionStatus, + selectedSessionCompactionState, messages, messagesBySessionId, todos, @@ -2051,34 +2056,6 @@ export default function App() { } } - const triggerAutoCompaction = async (sessionID: string) => { - if (!autoCompactContext()) return; - if (autoCompactingSessionId() === sessionID) return; - - setAutoCompactingSessionId(sessionID); - try { - await compactCurrentSession(sessionID); - } catch { - // ignore auto-compaction failures; manual compact remains available - } finally { - setAutoCompactingSessionId((current) => (current === sessionID ? null : current)); - } - }; - - const [lastSessionStatus, setLastSessionStatus] = createSignal(null); - createEffect(() => { - const sessionID = selectedSessionId(); - const status = sessionID ? sessionStatusById()[sessionID] ?? null : null; - const previous = lastSessionStatus(); - setLastSessionStatus(status); - - if (!sessionID) return; - if (!autoCompactContext()) return; - if (status !== "idle") return; - if (!previous || previous === "idle") return; - void triggerAutoCompaction(sessionID); - }); - const messageIdFromInfo = (message: MessageWithParts) => { const id = (message.info as { id?: string | number }).id; if (typeof id === "string") return id; @@ -2784,6 +2761,7 @@ export default function App() { }, }); assertNoClientError(result); + markOpencodeConfigReloadRequired(); } catch (error) { globalSync.set("config", "disabled_providers", disabledProviders); throw error; @@ -3113,6 +3091,52 @@ export default function App() { return `${JSON.stringify(config, null, 2)}\n`; }; + const parseAutoCompactContextFromConfig = (content: string | null) => { + if (!content) return null; + try { + const parsed = parse(content) as Record | undefined; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const compaction = parsed.compaction; + if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) { + return null; + } + return typeof (compaction as Record).auto === "boolean" + ? ((compaction as Record).auto as boolean) + : null; + } catch { + return null; + } + }; + + const formatConfigWithAutoCompactContext = (content: string | null, enabled: boolean) => { + let config: Record = {}; + if (content?.trim()) { + try { + const parsed = parse(content) as Record | undefined; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + config = { ...parsed }; + } + } catch { + config = {}; + } + } + + if (!config["$schema"]) { + config["$schema"] = "https://opencode.ai/config.json"; + } + + const compaction = + typeof config.compaction === "object" && config.compaction && !Array.isArray(config.compaction) + ? { ...(config.compaction as Record) } + : {}; + + compaction.auto = enabled; + config.compaction = compaction; + return `${JSON.stringify(config, null, 2)}\n`; + }; + const getConfigSnapshot = (content: string | null) => { if (!content?.trim()) return ""; try { @@ -3133,6 +3157,11 @@ export default function App() { return value as Record; }; + const readAutoCompactContextFromRecord = (value: unknown) => { + const compaction = ensureRecord(ensureRecord(value).compaction); + return typeof compaction.auto === "boolean" ? compaction.auto : null; + }; + const normalizeAuthorizedFolderPath = (input: string | null | undefined) => { const trimmed = (input ?? "").trim(); if (!trimmed) return ""; @@ -3206,8 +3235,8 @@ export default function App() { createSignal("none"); const [showThinking, setShowThinking] = createSignal(false); + const [autoCompactContext, setAutoCompactContext] = createSignal(true); const [hideTitlebar, setHideTitlebar] = createSignal(false); - const [autoCompactContext, setAutoCompactContext] = createSignal(false); const [modelVariantMap, setModelVariantMap] = createSignal>({}); const modelVariant = () => getVariantFor(selectedSessionModel()); const getVariantFor = (ref: ModelRef) => modelVariantMap()[`${ref.providerID}/${ref.modelID}`] ?? null; @@ -3221,7 +3250,11 @@ export default function App() { }); }; const setModelVariant = (value: string | null) => updateModelVariant(selectedSessionModel(), value); - const [autoCompactingSessionId, setAutoCompactingSessionId] = createSignal(null); + const toggleAutoCompactContext = () => { + if (autoCompactContextSaving()) return; + setAutoCompactContext((value) => !value); + setAutoCompactContextDirty(true); + }; const [authorizedFolders, setAuthorizedFolders] = createSignal([]); const [authorizedFolderDraft, setAuthorizedFolderDraft] = createSignal(""); const [, setAuthorizedFolderHiddenEntries] = createSignal>({}); @@ -5239,7 +5272,7 @@ export default function App() { }); const { - reloadRequired, + reloadPending, reloadCopy, reloadTrigger, reloadBusy, @@ -5385,10 +5418,25 @@ export default function App() { await reloadWorkspaceEngine(); }; + const isActiveSessionStatus = (status: string | null | undefined) => + status === "running" || status === "retry"; + + const reloadRequired = (...sources: ReloadTrigger["type"][]) => { + if (!reloadPending()) return false; + const triggerType = reloadTrigger()?.type; + if (!triggerType) return false; + if (!sources.length) return true; + return sources.includes(triggerType); + }; + + const markOpencodeConfigReloadRequired = () => { + markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); + }; + const activeReloadBlockingSessions = createMemo(() => { const statuses = sessionStatusById(); return sessions() - .filter((session) => statuses[session.id] === "running") + .filter((session) => isActiveSessionStatus(statuses[session.id])) .map((session) => ({ id: session.id, title: session.title?.trim() || session.slug?.trim() || session.id, @@ -6173,6 +6221,8 @@ export default function App() { } } + markReloadRequired("mcp", { type: "mcp", name: "notion", action: "added" }); + await refreshMcpServers(); setNotionStatusDetail(t("mcp.connecting", currentLocale())); try { @@ -7035,18 +7085,6 @@ export default function App() { } } - const storedAutoCompactContext = window.localStorage.getItem(AUTO_COMPACT_CONTEXT_PREF_KEY); - if (storedAutoCompactContext != null) { - try { - const parsed = JSON.parse(storedAutoCompactContext); - if (typeof parsed === "boolean") { - setAutoCompactContext(parsed); - } - } catch { - // ignore - } - } - const storedVariant = window.localStorage.getItem(VARIANT_PREF_KEY); if (storedVariant && storedVariant.trim()) { try { @@ -7278,11 +7316,7 @@ export default function App() { "Authorized folders updated.", ), ); - markReloadRequired("config", { - type: "config", - name: "opencode.json", - action: "updated", - }); + markOpencodeConfigReloadRequired(); return true; } catch (error) { const message = error instanceof Error ? error.message : safeStringify(error); @@ -7461,7 +7495,7 @@ export default function App() { await openworkClient.patchConfig(openworkWorkspaceId, { opencode: { model: formatModelRef(nextModel) }, }); - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); + markOpencodeConfigReloadRequired(); return; } @@ -7475,7 +7509,7 @@ export default function App() { throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); } setLastKnownConfigSnapshot(getConfigSnapshot(content)); - markReloadRequired("config", { type: "config", name: "opencode.json", action: "updated" }); + markOpencodeConfigReloadRequired(); } catch (error) { if (cancelled) return; const message = error instanceof Error ? error.message : safeStringify(error); @@ -7490,6 +7524,152 @@ export default function App() { }); }); + createEffect(() => { + const workspaceId = workspaceStore.selectedWorkspaceId(); + if (!workspaceId) { + setAutoCompactContext(true); + setAutoCompactContextApplied(true); + setAutoCompactContextDirty(false); + setAutoCompactContextReady(false); + setAutoCompactContextSaving(false); + return; + } + + const workspace = workspaceStore.selectedWorkspaceDisplay(); + const root = workspaceStore.selectedWorkspacePath().trim(); + const activeClient = client(); + const openworkClient = openworkServerClient(); + const openworkWorkspaceId = runtimeWorkspaceId(); + const openworkCapabilities = resolvedOpenworkCapabilities(); + const canUseOpenworkServer = + openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.read; + + let cancelled = false; + setAutoCompactContextReady(false); + setAutoCompactContextDirty(false); + + const loadAutoCompactContext = async () => { + let nextValue = true; + + if (canUseOpenworkServer) { + try { + const config = await openworkClient.getConfig(openworkWorkspaceId); + nextValue = readAutoCompactContextFromRecord(config.opencode) ?? true; + } catch { + // ignore + } + } else if (workspace.workspaceType === "local" && root && isTauriRuntime()) { + try { + const configFile = await readOpencodeConfig("project", root); + nextValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; + } catch { + // ignore + } + } else if (activeClient) { + try { + const config = unwrap(await activeClient.config.get({ directory: root || undefined })); + nextValue = readAutoCompactContextFromRecord(config) ?? true; + } catch { + // ignore + } + } + + if (cancelled) return; + setAutoCompactContext(nextValue); + setAutoCompactContextApplied(nextValue); + setAutoCompactContextReady(true); + }; + + void loadAutoCompactContext(); + + onCleanup(() => { + cancelled = true; + }); + }); + + createEffect(() => { + if (!autoCompactContextReady()) return; + if (!autoCompactContextDirty()) return; + + const nextValue = autoCompactContext(); + const appliedValue = autoCompactContextApplied(); + const workspace = workspaceStore.selectedWorkspaceDisplay(); + const root = workspaceStore.selectedWorkspacePath().trim(); + const openworkClient = openworkServerClient(); + const openworkWorkspaceId = runtimeWorkspaceId(); + const openworkCapabilities = resolvedOpenworkCapabilities(); + const canUseOpenworkServer = + openworkServerStatus() === "connected" && + openworkClient && + openworkWorkspaceId && + openworkCapabilities?.config?.write; + + let cancelled = false; + setAutoCompactContextSaving(true); + + const persistAutoCompactContext = async () => { + try { + if (canUseOpenworkServer) { + const config = await openworkClient.getConfig(openworkWorkspaceId); + const currentValue = readAutoCompactContextFromRecord(config.opencode) ?? true; + if (currentValue !== nextValue) { + await openworkClient.patchConfig(openworkWorkspaceId, { + opencode: { + compaction: { + auto: nextValue, + }, + }, + }); + markOpencodeConfigReloadRequired(); + } + if (cancelled) return; + setAutoCompactContextApplied(nextValue); + setAutoCompactContextDirty(false); + return; + } + + if (workspace.workspaceType !== "local" || !root || !isTauriRuntime()) { + throw new Error( + "Auto context compaction can only be changed for a local workspace or a writable OpenWork server workspace.", + ); + } + + const configFile = await readOpencodeConfig("project", root); + const currentValue = parseAutoCompactContextFromConfig(configFile.content) ?? true; + if (currentValue !== nextValue) { + const content = formatConfigWithAutoCompactContext(configFile.content, nextValue); + const result = await writeOpencodeConfig("project", root, content); + if (!result.ok) { + throw new Error(result.stderr || result.stdout || "Failed to update opencode.json"); + } + setLastKnownConfigSnapshot(getConfigSnapshot(content)); + markOpencodeConfigReloadRequired(); + } + + if (cancelled) return; + setAutoCompactContextApplied(nextValue); + setAutoCompactContextDirty(false); + } catch (error) { + if (cancelled) return; + setAutoCompactContext(appliedValue); + setAutoCompactContextDirty(false); + const message = error instanceof Error ? error.message : safeStringify(error); + setError(addOpencodeCacheHint(message)); + } finally { + setAutoCompactContextSaving(false); + } + }; + + void persistAutoCompactContext(); + + onCleanup(() => { + cancelled = true; + }); + }); + createEffect(() => { if (!isTauriRuntime()) return; if (onboardingStep() !== "local") return; @@ -7636,15 +7816,6 @@ export default function App() { } }); - createEffect(() => { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(AUTO_COMPACT_CONTEXT_PREF_KEY, JSON.stringify(autoCompactContext())); - } catch { - // ignore - } - }); - createEffect(() => { if (typeof window === "undefined") return; try { @@ -8077,7 +8248,8 @@ export default function App() { showThinking: showThinking(), toggleShowThinking: () => setShowThinking((v) => !v), autoCompactContext: autoCompactContext(), - toggleAutoCompactContext: () => setAutoCompactContext((v) => !v), + toggleAutoCompactContext, + autoCompactContextBusy: autoCompactContextSaving(), hideTitlebar: hideTitlebar(), toggleHideTitlebar: () => setHideTitlebar((v) => !v), modelVariantLabel: getModelBehaviorCopy(defaultModel(), getVariantFor(defaultModel())).label, @@ -8182,11 +8354,6 @@ export default function App() { logoutMcpAuth, removeMcp, refreshMcpServers, - showMcpReloadBanner: - reloadRequired() && (reloadTrigger()?.type === "mcp" || reloadTrigger()?.type === "config"), - mcpReloadBlocked: activeReloadBlockingSessions().length > 0, - reloadBlocked: activeReloadBlockingSessions().length > 0, - reloadMcpEngine: () => reloadWorkspaceEngineAndResume(), language: currentLocale(), setLanguage: setLocale, }; @@ -8281,17 +8448,6 @@ export default function App() { mcpStatus: mcpStatus(), skills: skills(), skillsStatus: skillsStatus(), - showSkillReloadBanner: reloadRequired() && reloadTrigger()?.type === "skill", - reloadBannerTitle: reloadCopy().title, - reloadBannerBody: reloadCopy().body, - reloadBannerBlocked: activeReloadBlockingSessions().length > 0, - reloadBannerActiveCount: activeReloadBlockingSessions().length, - canReloadWorkspace: canReloadWorkspace(), - reloadWorkspaceEngine: reloadWorkspaceEngineAndResume, - forceStopActiveConversations: forceStopActiveSessionsAndReload, - dismissReloadBanner: clearReloadRequired, - reloadBusy: reloadBusy(), - reloadError: reloadError(), createSessionAndOpen: createSessionAndOpen, sendPromptAsync: sendPrompt, abortSession: abortSession, @@ -8314,8 +8470,7 @@ export default function App() { busyLabel: busyLabel(), developerMode: developerMode(), showThinking: showThinking(), - autoCompactContext: autoCompactContext(), - toggleAutoCompactContext: () => setAutoCompactContext((v) => !v), + sessionCompactionState: selectedSessionCompactionState(), groupMessageParts, summarizeStep, expandedStepIds: expandedStepIds(), @@ -8826,6 +8981,27 @@ export default function App() {
+
+ 0 ? "Reload & Stop Tasks" : "Reload now"} + dismissLabel="Later" + busy={reloadBusy()} + canReload={canReloadWorkspace()} + hasActiveRuns={activeReloadBlockingSessions().length > 0} + onReload={() => { + void (activeReloadBlockingSessions().length > 0 + ? forceStopActiveSessionsAndReload() + : reloadWorkspaceEngineAndResume()); + }} + onDismiss={clearReloadRequired} + /> +
+
-
+
diff --git a/apps/app/src/app/constants.ts b/apps/app/src/app/constants.ts index 5b01d3ac..2ad7080c 100644 --- a/apps/app/src/app/constants.ts +++ b/apps/app/src/app/constants.ts @@ -6,7 +6,6 @@ export const THINKING_PREF_KEY = "openwork.showThinking"; export const VARIANT_PREF_KEY = "openwork.modelVariant"; export const LANGUAGE_PREF_KEY = "openwork.language"; export const HIDE_TITLEBAR_PREF_KEY = "openwork.hideTitlebar"; -export const AUTO_COMPACT_CONTEXT_PREF_KEY = "openwork.autoCompactContext"; export const DEFAULT_MODEL: ModelRef = { providerID: "opencode", diff --git a/apps/app/src/app/context/extensions.ts b/apps/app/src/app/context/extensions.ts index 4999d85c..7e2f9ec3 100644 --- a/apps/app/src/app/context/extensions.ts +++ b/apps/app/src/app/context/extensions.ts @@ -719,6 +719,7 @@ export function createExtensionsStore(options: { try { setPluginStatus(null); await openworkClient.addPlugin(openworkWorkspaceId, pluginName); + options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "added" }); if (isManualInput) { setPluginInput(""); } @@ -816,6 +817,7 @@ export function createExtensionsStore(options: { try { setPluginStatus(null); await openworkClient.removePlugin(openworkWorkspaceId, name); + options.markReloadRequired?.("plugins", { type: "plugin", name: triggerName, action: "removed" }); await refreshPlugins("project"); } catch (e) { setPluginStatus(e instanceof Error ? e.message : "Failed to remove plugin."); diff --git a/apps/app/src/app/context/session.ts b/apps/app/src/app/context/session.ts index c3cf7bbb..55bcee45 100644 --- a/apps/app/src/app/context/session.ts +++ b/apps/app/src/app/context/session.ts @@ -14,6 +14,7 @@ import type { PlaceholderAssistantMessage, ReloadReason, ReloadTrigger, + SessionCompactionState, SessionErrorTurn, TodoItem, } from "../types"; @@ -49,6 +50,7 @@ type StoreState = { pendingPermissions: PendingPermission[]; pendingQuestions: PendingQuestion[]; events: OpencodeEvent[]; + sessionCompaction: Record; }; const sortById = (list: T[]) => @@ -198,6 +200,7 @@ export function createSessionStore(options: { pendingPermissions: [], pendingQuestions: [], events: [], + sessionCompaction: {}, }); const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false); const [messageLimitBySession, setMessageLimitBySession] = createSignal>({}); @@ -206,6 +209,7 @@ export function createSessionStore(options: { const [loadedScopeRoot, setLoadedScopeRoot] = createSignal(""); const reloadDetectionSet = new Set(); const invalidToolDetectionSet = new Set(); + const pendingCompactionModeBySession = new Map(); const syntheticContinueEventTimesBySession = new Map(); const syntheticTaskSummaryEventTimesBySession = new Map(); const syntheticContinueLoopLastWarnAtBySession = new Map(); @@ -1183,6 +1187,68 @@ export function createSessionStore(options: { }); }; + const setSessionCompaction = (sessionID: string, next: SessionCompactionState) => { + setStore("sessionCompaction", sessionID, next); + }; + + const stopSessionCompaction = (sessionID: string) => { + const current = store.sessionCompaction[sessionID]; + pendingCompactionModeBySession.delete(sessionID); + if (!current?.running) return; + setSessionCompaction(sessionID, { + ...current, + running: false, + messageID: null, + }); + }; + + const startSessionCompaction = (sessionID: string, messageID: string) => { + const current = store.sessionCompaction[sessionID]; + if (current?.running && current.messageID === messageID) return; + const startedAt = Date.now(); + const mode = pendingCompactionModeBySession.get(sessionID) ?? current?.mode ?? null; + pendingCompactionModeBySession.delete(sessionID); + setSessionCompaction(sessionID, { + running: true, + startedAt, + finishedAt: null, + mode, + messageID, + }); + if (options.developerMode()) { + appendDebugEvent({ + type: "session.compaction.started", + properties: { sessionID, messageID, mode, startedAt }, + }); + } + }; + + const finishSessionCompaction = (sessionID: string) => { + const current = store.sessionCompaction[sessionID]; + const finishedAt = Date.now(); + pendingCompactionModeBySession.delete(sessionID); + setSessionCompaction(sessionID, { + running: false, + startedAt: current?.startedAt ?? null, + finishedAt, + mode: current?.mode ?? null, + messageID: null, + }); + if (options.developerMode()) { + appendDebugEvent({ + type: "session.compaction.finished", + properties: { + sessionID, + mode: current?.mode ?? null, + startedAt: current?.startedAt ?? null, + finishedAt, + durationMs: + typeof current?.startedAt === "number" ? Math.max(0, finishedAt - current.startedAt) : null, + }, + }); + } + }; + const compactDebugEvent = (event: OpencodeEvent) => { if (event.type === "message.part.updated") { const record = event.properties as Record | undefined; @@ -1281,9 +1347,11 @@ export function createSessionStore(options: { syntheticContinueLoopLastWarnAtBySession.delete(info.id); syntheticLoopLastAbortAtByKey.delete(`task-summary:${info.id}`); syntheticLoopLastAbortAtByKey.delete(`compaction-continue:${info.id}`); + pendingCompactionModeBySession.delete(info.id); setStore( produce((draft: StoreState) => { delete draft.sessionInfoById[info.id]; + delete draft.sessionCompaction[info.id]; }), ); setStore("sessions", (current) => removeSession(current, info.id)); @@ -1303,6 +1371,9 @@ export function createSessionStore(options: { if (sessionID) { const normalized = normalizeSessionStatus(record.status); setStore("sessionStatus", sessionID, normalized); + if (normalized === "idle") { + stopSessionCompaction(sessionID); + } if (sessionID === options.selectedSessionId() && normalized !== "idle") { options.setError(null); } @@ -1316,6 +1387,7 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, "idle"); + stopSessionCompaction(sessionID); const c = options.client(); if (c) { try { @@ -1340,6 +1412,7 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, "idle"); + stopSessionCompaction(sessionID); } const errorObj = record.error as Record | undefined; if (errorObj) { @@ -1374,6 +1447,7 @@ export function createSessionStore(options: { const record = event.properties as Record; if (record.info && typeof record.info === "object") { const info = record.info as Message; + const messageRecord = info as Message & Record; const model = modelFromUserMessage(info as MessageInfo); if (model) { options.setSessionModelState((current) => ({ @@ -1390,6 +1464,14 @@ export function createSessionStore(options: { } setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info)); + + if ( + messageRecord.role === "assistant" && + messageRecord.mode === "compaction" && + messageRecord.summary === true + ) { + startSessionCompaction(info.sessionID, info.id); + } } } } @@ -1414,6 +1496,13 @@ export function createSessionStore(options: { const delta = typeof record.delta === "string" ? record.delta : null; const partUpdatedStartedAt = perfNow(); + if (part.type === "compaction") { + pendingCompactionModeBySession.set( + part.sessionID, + (part as Part & { auto?: unknown }).auto === true ? "auto" : "manual", + ); + } + setStore( produce((draft: StoreState) => { const list = draft.messages[part.sessionID] ?? []; @@ -1511,6 +1600,16 @@ export function createSessionStore(options: { } } + if (event.type === "session.compacted") { + if (event.properties && typeof event.properties === "object") { + const record = event.properties as Record; + const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; + if (sessionID) { + finishSessionCompaction(sessionID); + } + } + } + if (event.type === "permission.asked" || event.type === "permission.replied") { try { await refreshPendingPermissions(); @@ -1747,8 +1846,13 @@ export function createSessionStore(options: { sessionStatusById, selectedSession, selectedSessionStatus, + selectedSessionCompactionState: createMemo(() => { + const sessionID = options.selectedSessionId(); + return sessionID ? store.sessionCompaction[sessionID] ?? null : null; + }), messages, messagesBySessionId, + sessionCompactionById: (sessionID: string | null) => (sessionID ? store.sessionCompaction[sessionID] ?? null : null), todos, pendingPermissions, permissionReplyBusy, diff --git a/apps/app/src/app/pages/dashboard.tsx b/apps/app/src/app/pages/dashboard.tsx index 9233121c..854cff09 100644 --- a/apps/app/src/app/pages/dashboard.tsx +++ b/apps/app/src/app/pages/dashboard.tsx @@ -257,9 +257,6 @@ export type DashboardViewProps = { authorizeMcp: (entry: McpServerEntry) => void; logoutMcpAuth: (name: string) => Promise | void; removeMcp: (name: string) => void; - showMcpReloadBanner: boolean; - mcpReloadBlocked: boolean; - reloadMcpEngine: () => void; createSessionAndOpen: () => void; setPrompt: (value: string) => void; selectSession: (sessionId: string) => Promise | void; @@ -270,6 +267,7 @@ export type DashboardViewProps = { toggleShowThinking: () => void; autoCompactContext: boolean; toggleAutoCompactContext: () => void; + autoCompactContextBusy: boolean; hideTitlebar: boolean; toggleHideTitlebar: () => void; opencodeEnableExa: boolean; @@ -1391,9 +1389,6 @@ export default function DashboardView(props: DashboardViewProps) { authorizeMcp={props.authorizeMcp} logoutMcpAuth={props.logoutMcpAuth} removeMcp={props.removeMcp} - showMcpReloadBanner={props.showMcpReloadBanner} - reloadBlocked={props.mcpReloadBlocked} - reloadMcpEngine={props.reloadMcpEngine} canEditPlugins={props.canEditPlugins} canUseGlobalScope={props.canUseGlobalPluginScope} accessHint={props.pluginsAccessHint} @@ -1512,6 +1507,7 @@ export default function DashboardView(props: DashboardViewProps) { toggleShowThinking={props.toggleShowThinking} autoCompactContext={props.autoCompactContext} toggleAutoCompactContext={props.toggleAutoCompactContext} + autoCompactContextBusy={props.autoCompactContextBusy} hideTitlebar={props.hideTitlebar} toggleHideTitlebar={props.toggleHideTitlebar} modelVariantLabel={props.modelVariantLabel} @@ -1629,14 +1625,11 @@ export default function DashboardView(props: DashboardViewProps) { selectedMcp={props.selectedMcp} setSelectedMcp={props.setSelectedMcp} quickConnect={props.quickConnect} - connectMcp={props.connectMcp} - authorizeMcp={props.authorizeMcp} - logoutMcpAuth={props.logoutMcpAuth} - removeMcp={props.removeMcp} - showMcpReloadBanner={props.showMcpReloadBanner} - mcpReloadBlocked={props.mcpReloadBlocked} - reloadMcpEngine={props.reloadMcpEngine} - createSessionAndOpen={props.createSessionAndOpen} + connectMcp={props.connectMcp} + authorizeMcp={props.authorizeMcp} + logoutMcpAuth={props.logoutMcpAuth} + removeMcp={props.removeMcp} + createSessionAndOpen={props.createSessionAndOpen} setPrompt={props.setPrompt} canReloadWorkspace={props.canReloadWorkspace} reloadWorkspaceEngine={props.reloadWorkspaceEngine} diff --git a/apps/app/src/app/pages/extensions.tsx b/apps/app/src/app/pages/extensions.tsx index a50330d2..269b53fd 100644 --- a/apps/app/src/app/pages/extensions.tsx +++ b/apps/app/src/app/pages/extensions.tsx @@ -144,9 +144,6 @@ export default function ExtensionsView(props: ExtensionsViewProps) { authorizeMcp={props.authorizeMcp} logoutMcpAuth={props.logoutMcpAuth} removeMcp={props.removeMcp} - showMcpReloadBanner={props.showMcpReloadBanner} - reloadBlocked={props.reloadBlocked} - reloadMcpEngine={props.reloadMcpEngine} />
diff --git a/apps/app/src/app/pages/mcp.tsx b/apps/app/src/app/pages/mcp.tsx index 9168c8fb..02e5b53d 100644 --- a/apps/app/src/app/pages/mcp.tsx +++ b/apps/app/src/app/pages/mcp.tsx @@ -30,7 +30,6 @@ import { MonitorSmartphone, Plug2, Plus, - RefreshCw, Settings, Settings2, Unplug, @@ -56,9 +55,6 @@ export type McpViewProps = { authorizeMcp: (entry: McpServerEntry) => void; logoutMcpAuth: (name: string) => Promise | void; removeMcp: (name: string) => void; - showMcpReloadBanner: boolean; - reloadBlocked: boolean; - reloadMcpEngine: () => void; }; /* ── Status helpers ─────────────────────────────────── */ @@ -327,29 +323,6 @@ export default function McpView(props: McpViewProps) {
- {/* ── Reload banner ────────────────────────────── */} - -
-
-
{tr("mcp.finish_setup")}
-
- {props.reloadBlocked - ? tr("mcp.reload_banner_description_blocked") - : tr("mcp.finish_setup_hint")} -
-
- -
-
- {/* ── Status message ───────────────────────────── */}
diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index c720dd46..b0492ea4 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -19,6 +19,7 @@ import type { PendingPermission, PendingQuestion, ProviderListItem, + SessionCompactionState, SettingsTab, SkillCard, TodoItem, @@ -45,7 +46,6 @@ import { getOpenWorkDeployment } from "../lib/openwork-deployment"; import { createWorkspaceShellLayout } from "../lib/workspace-shell-layout"; import { - AlertTriangle, Check, Circle, FolderOpen, @@ -191,6 +191,7 @@ export type SessionViewProps = { busyLabel: string | null; developerMode: boolean; showThinking: boolean; + sessionCompactionState: SessionCompactionState | null; groupMessageParts: (parts: Part[], messageId: string) => MessageGroup[]; summarizeStep: (part: Part) => { title: string; detail?: string }; expandedStepIds: Set; @@ -210,17 +211,6 @@ export type SessionViewProps = { mcpStatus: string | null; skills: SkillCard[]; skillsStatus: string | null; - showSkillReloadBanner: boolean; - reloadBannerTitle: string; - reloadBannerBody: string; - reloadBannerBlocked: boolean; - reloadBannerActiveCount: number; - canReloadWorkspace: boolean; - reloadWorkspaceEngine: () => Promise; - forceStopActiveConversations: () => Promise; - dismissReloadBanner: () => void; - reloadBusy: boolean; - reloadError: string | null; busy: boolean; prompt: string; setPrompt: (value: string) => void; @@ -1914,6 +1904,37 @@ export default function SessionView(props: SessionViewProps) { }); const showRunIndicator = createMemo(() => runPhase() !== "idle"); + const showCompactionIndicator = createMemo( + () => props.sessionCompactionState?.running === true, + ); + const compactionStatusDetail = createMemo(() => { + if (!showCompactionIndicator()) return ""; + return props.sessionCompactionState?.mode === "auto" + ? "OpenCode is auto-compacting this session" + : "OpenCode is compacting this session"; + }); + + createEffect( + on( + () => props.sessionCompactionState?.startedAt ?? null, + (startedAt, previous) => { + if (!startedAt || startedAt === previous) return; + if (props.sessionCompactionState?.mode === "manual") return; + setToastMessage("OpenCode started compacting the session context."); + }, + ), + ); + + createEffect( + on( + () => props.sessionCompactionState?.finishedAt ?? null, + (finishedAt, previous) => { + if (!finishedAt || finishedAt === previous) return; + if (props.sessionCompactionState?.mode === "manual") return; + setToastMessage("OpenCode finished compacting the session context."); + }, + ), + ); const latestRunPart = createMemo(() => { if (!showRunIndicator()) return null; @@ -4244,96 +4265,6 @@ export default function SessionView(props: SessionViewProps) {
- -
-
-
- -
- -
-
-
-
- {props.reloadBannerTitle} - - - Active tasks - - -
- -
-
- - {props.reloadError} - - } - > - Reloading will stop active tasks. - -
- - -
- - - {`Reloading stops ${props.reloadBannerActiveCount} active conversation${props.reloadBannerActiveCount === 1 ? "" : "s"}.`} - -
-
-
-
- - -
- -
- - -
-
-
-
-
-
diff --git a/apps/app/src/app/pages/settings.tsx b/apps/app/src/app/pages/settings.tsx index 1bc73ef8..c0762135 100644 --- a/apps/app/src/app/pages/settings.tsx +++ b/apps/app/src/app/pages/settings.tsx @@ -147,6 +147,7 @@ export type SettingsViewProps = { toggleShowThinking: () => void; autoCompactContext: boolean; toggleAutoCompactContext: () => void; + autoCompactContextBusy: boolean; hideTitlebar: boolean; toggleHideTitlebar: () => void; modelVariantLabel: string; @@ -283,9 +284,6 @@ export type SettingsViewProps = { authorizeMcp: (entry: McpServerEntry) => void; logoutMcpAuth: (name: string) => Promise | void; removeMcp: (name: string) => void; - showMcpReloadBanner: boolean; - mcpReloadBlocked: boolean; - reloadMcpEngine: () => void; createSessionAndOpen: () => void; setPrompt: (value: string) => void; connectRemoteWorkspace: (input: { @@ -1886,25 +1884,6 @@ export default function SettingsView(props: SettingsViewProps) {
-
-
-
- Auto context compaction -
-
- Automatically compact after a run completes. -
-
- -
-
Model behavior
@@ -1924,6 +1903,23 @@ export default function SettingsView(props: SettingsViewProps) { Configure
+ +
+
+
Auto context compaction
+
+ Controls OpenCode compaction.auto for this workspace. Reload the engine after changing it. +
+
+ +
@@ -2181,9 +2177,6 @@ export default function SettingsView(props: SettingsViewProps) { authorizeMcp={props.authorizeMcp} logoutMcpAuth={props.logoutMcpAuth} removeMcp={props.removeMcp} - showMcpReloadBanner={props.showMcpReloadBanner} - reloadBlocked={props.mcpReloadBlocked} - reloadMcpEngine={props.reloadMcpEngine} canEditPlugins={props.canEditPlugins} canUseGlobalScope={props.canUseGlobalPluginScope} accessHint={props.pluginsAccessHint} diff --git a/apps/app/src/app/system-state.ts b/apps/app/src/app/system-state.ts index 12b9e6c1..c1f56815 100644 --- a/apps/app/src/app/system-state.ts +++ b/apps/app/src/app/system-state.ts @@ -73,7 +73,10 @@ export function createSystemState(options: { setError: (value: string | null) => void; notion?: NotionState; }) { - const [reloadRequired, setReloadRequired] = createSignal(false); + const isActiveSessionStatus = (status: string | null | undefined) => + status === "running" || status === "retry"; + + const [reloadPending, setReloadPending] = createSignal(false); const [reloadReasons, setReloadReasons] = createSignal([]); const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal(null); const [reloadLastFinishedAt, setReloadLastFinishedAt] = createSignal(null); @@ -109,7 +112,7 @@ export function createSystemState(options: { const anyActiveRuns = createMemo(() => { const statuses = options.sessionStatusById(); - return options.sessions().some((s) => statuses[s.id] === "running"); + return options.sessions().some((s) => isActiveSessionStatus(statuses[s.id])); }); function clearOpenworkLocalStorage(mode: ResetOpenworkMode) { @@ -179,7 +182,7 @@ export function createSystemState(options: { } function markReloadRequired(reason: ReloadReason, trigger?: ReloadTrigger) { - setReloadRequired(true); + setReloadPending(true); setReloadLastTriggeredAt(Date.now()); setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason])); if (trigger) { @@ -201,7 +204,7 @@ export function createSystemState(options: { } function clearReloadRequired() { - setReloadRequired(false); + setReloadPending(false); setReloadReasons([]); setReloadError(null); setReloadTrigger(null); @@ -265,7 +268,7 @@ export function createSystemState(options: { }); const canReloadEngine = createMemo(() => { - if (!reloadRequired()) return false; + if (!reloadPending()) return false; if (reloadBusy()) return false; const override = options.canReloadWorkspaceEngine?.(); if (override === true) return true; @@ -276,7 +279,7 @@ export function createSystemState(options: { // Keep this mounted so the reload banner UX remains in the app. createEffect(() => { - reloadRequired(); + reloadPending(); }); async function reloadEngineInstance() { @@ -607,7 +610,7 @@ export function createSystemState(options: { } return { - reloadRequired, + reloadPending, reloadReasons, reloadLastTriggeredAt, reloadLastFinishedAt, diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 14fe222c..dba9eeeb 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -142,6 +142,14 @@ export type OpencodeEvent = { properties?: unknown; }; +export type SessionCompactionState = { + running: boolean; + startedAt: number | null; + finishedAt: number | null; + mode: "auto" | "manual" | null; + messageID: string | null; +}; + export type View = "onboarding" | "dashboard" | "session" | "proto"; export type StartupPreference = "local" | "server";