Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
83 KiB
83 KiB
Troubleshooting: Workbench And Renderer
Renderer Vite cannot resolve a workspace package subpath
- Symptom:
The Electron development overlay reports
[plugin:vite:import-analysis] Failed to resolve importfor a public workspace package subpath, while Node can import that same subpath and its packageexportsentry exists. - Quick checks:
Inspect
apps/desktop/electron.vite.config.tsfor a source alias on the package root, then compare its explicit child aliases with the import that failed. A string root alias also matches subpath prefixes, so Vite may rewrite@scope/package/featureassrc/index.ts/featurebefore package exports can resolve it. - Root cause: The public child entry is missing from a consumer alias map that already redirects the package root to a source file.
- Fix:
Add the public child entry to the Electron Vite alias map before the
package-root alias and add the matching source path to
apps/desktop/tsconfig.json. Keep the package'sexportsandpublishConfig.exportsdeclarations aligned; do not bypass the public subpath with a consumer-relative source import. - Validation: Run the desktop renderer build, which exercises Vite import analysis for every entry, including secondary windows such as screenshot capture.
Tabbed standalone Browser remains blank with a cold lifecycle
- Symptom:
A standalone Browser shows its default URL and tab title, but the guest area
stays blank after multi-tab support is enabled. Renderer diagnostics keep the
active tab in the internal
coldlifecycle even though activation was requested. The same Browser may work in an OS-mode Workbench window. - Quick checks:
Compare the surface node ID with the node ID in Browser runtime events. A
tabbed surface owns a parent such as
browser:surfacewhile its controller and guest emit events for child IDs such asbrowser:surface:tab:1. - Root cause:
A host event adapter still accepts only exact parent-node matches. Activation
succeeds for the child guest, but its returned
activeevent is discarded, leaving the renderer runtime at its default cold lifecycle. The address bar can still show the configured default URL, which makes this look like a webview loading failure rather than an event-scope mismatch. - Fix:
Use the Browser Node package-owned surface-event predicate. It accepts the
exact parent ID and the parent's
:tab:*children while rejecting sibling Browser surfaces. Do not restore a second manual activation path or duplicate the child-ID convention in the host. - Validation:
Cover parent and child state events, sibling rejection, and
open-urlevents whose ownership comes fromsourceNodeId. Then run Browser Node tests and the host's focused Browser lifecycle test. - References: eventScope.ts standaloneAgentToolWorkbench.ts browser-node-package.md
Final Browser tab close leaves the Workbench window open
- Symptom: A Browser Workbench node has one tab. Its tab close control is visible, but clicking it leaves the Browser window open. The native Workbench close control may still work.
- Quick checks:
Inspect the callbacks passed to
BrowserNodeWorkbenchHeader. Confirm that the final-tab callback reacheswindowActions.close()and is not implemented asBrowserNodeHostApi.close({ nodeId: surfaceNodeId }). The Workbench node ID is a parent surface ID; registered Browser guests use:tab:*child IDs. - Root cause: Browser guest cleanup and Workbench surface closure are different lifecycle boundaries. Closing a parent ID through the Browser host API emits a guest close event but cannot remove the Workbench node. The native window control can mask this mismatch because its own click handler separately closes the Workbench window, while the final-tab control has no such fallback.
- Fix:
Keep the native action and final-tab request separate. Bind the dedicated
final-tab request to
windowActions.close()and let Browser tab-surface lease release close the remaining child guests after unmount. Do not add a second Workbench close call to the native control's capture path. - Validation: Exercise the Browser Workbench close-request adapter and assert that its final-tab request runs the Workbench close action exactly once. Keep the package tab intent tests to prove that the dedicated Workbench request wins while the ordinary standalone-host callback remains a supported fallback.
- References: BrowserNodeChrome.tsx Browser Workbench adapter Browser Workbench adapter test
Inline custom-header menu is clipped to the Workbench title bar
- Symptom: A shared header menu works in a standalone surface but appears empty, only a few pixels tall, or completely hidden when the same header renders inside an OS-mode Workbench window. Dialogs opened from the menu may be unreachable because the menu item that opens them is clipped.
- Quick checks:
Confirm both shells render the same menu component, then inspect ancestor
boxes between the trigger and the Workbench node body. In particular, check
.workbench-window__header--custom, whose defaultoverflow: hiddenkeeps ordinary custom-header content inside the title-bar row. - Root cause: An inline menu extends below the custom-header row, but the Workbench row clips descendants before stacking order can place the menu over the node body. Raising the menu z-index cannot escape ancestor overflow clipping.
- Fix:
Keep the shared inline menu and declare the owning node's header presentation
as
window.header: { overflow: "visible" }(plus its explicitheightPxwhen it owns a non-default header height). Workbench projects that contract todata-window-header-overflow="visible"on.workbench-windowand allows overflow only for that custom-header row. Do not copy the menu into the OS shell or globally disable clipping for every custom header. The outer.workbench-windowremains the window-bounds clip. - Validation: Run the Browser Node and Workbench Surface package tests, typecheck the affected packages, and build the desktop renderer. In both Agent-only and OS modes, open the Browser three-dot menu and verify the same nested actions and Browser settings dialog are usable above the guest webview.
- References: BrowserNodeChrome.tsx workbench.css browser-node-package.md
Overflowing custom header widens the Workbench body
- Symptom: A resizable Workbench node remains visually inside its frame, but after a custom header gains enough non-wrapping content, its body and embedded webview retain a much wider layout viewport. Narrowing the node clips the page instead of triggering its responsive layout.
- Quick checks:
Compare the bounding widths of
.workbench-window,.workbench-window__header--custom, and.workbench-window__body, then inspect the computed Grid columns. If the outer window keeps its saved width while the implicit column, header, and body all resolve wider, trace the header's min-content contribution rather than changing the webview width. - Root cause:
A Grid that defines only rows leaves its single implicit column sized as
auto. A custom header withoverflow: visibleretains a content-based automatic minimum, so non-wrapping tabs or controls can enlarge that column. The body shares the enlarged track, and awidth: 100%webview then receives the wrong layout viewport even though the outer window clips the result. - Fix:
Define the Workbench window's single column as
minmax(0, 1fr). Keep the header's intentional visible overflow for inline overlays; the explicit zero-minimum track prevents horizontal min-content from resizing the shared body column. Addingmin-width: 0only inside the custom header does not change the Grid item's automatic minimum contribution. - Validation: Open enough fixed-minimum-width tabs to overflow a Browser node, then resize the node narrower and wider. Confirm the tab list scrolls, the header and body widths remain equal to the Workbench frame, and the guest page responds to each available width instead of being clipped at its previous viewport.
- References: workbench.css BrowserNodeChrome.tsx
Standalone Agent dev window stays black during cold startup
- Symptom: A local development launch creates the standalone Agent native window, but only the window chrome is visible for several seconds before the Agent header, rail, conversation, and composer appear. A related failure leaves the window black permanently because the renderer root throws before AgentGUI mounts.
- Quick checks:
Check
tutti-desktop.logforreact.uncaughtbefore profiling cold startup. If the error isagent_gui_workbench.invalid_provider, compare the encoded Agent window intent with the standalone route's launch-provider resolution. A primary standalone Agent startup may legitimately omit both provider and Agent Target metadata while the directory is still loading. Compare desktop-ready, first renderer diagnostic, standalone route mount, AgentGUI body mount, and composer-ready timestamps. Time the daemon workspace, session-list, rail, target, and provider-status endpoints independently. If normal workspace/session calls finish in milliseconds while the first renderer diagnostics arrive seconds later, the delay is in renderer module transformation/evaluation rather than SQLite or workspace hydration. Also time provider statuses per provider; one slow CLI probe can dominate a serial all-provider scan. For provider-status startup, correlate the samesession_idacrosstutti-desktop.logandtuttid.log. Renderer eventsagent_provider_status.request.started,.resolved,.failed,.cache_hit, and.reusedshow request scope, provider IDs, request ID, and total elapsed time. Daemon eventtutti.agent_provider.status_list.completedshows the batch total; per-providertutti.agent_provider.status_detection.completedevents split runtime resolution, adapter probe, auth, CLI version, and post-check time. Concurrent step times overlap, so compare the largest step with the provider total rather than summing every step. - Root cause:
For the permanent-black variant, an optional startup provider can be passed
directly to the strict workbench provider normalizer. The generic primary
Agent window starts with workspace identity only, so normalizing that absent
value throws during React render even while the daemon and provider probes
remain healthy.
Development Vite transforms source modules on demand. An Agent-only route can
therefore remain on a black Suspense fallback while nested lazy boundaries
discover large dependency graphs. In the desktop renderer, enabling Babel
React Compiler during
servemakes every cold TSX request substantially more expensive; a body import that reaches hundreds of TSX modules can spend several seconds compiling even though all source files are local. A warm request completing quickly distinguishes this from disk or loopback HTTP throughput. Static imports for Browser, Terminal, File Manager, App Center, Message Center, settings/import panels, or account UI enlarge the shell graph even when those surfaces are closed. Starting Workspace App polling at mount can also prepare every app runtime during the same cold compile. Separately, a single global in-flight provider-status promise makes the active provider wait behind a slow all-provider scan. - Fix:
Resolve the absent startup provider to the existing workbench default at the
standalone route boundary, then use the strict normalizer only for a supplied
provider. Keep malformed non-empty values as errors, and keep Agent Target
directory resolution authoritative once it loads.
Keep workspace and standalone Agent routes separate. Let both already-lazy
routes statically own the full AgentGUI body so neither adds a second import
waterfall beneath its route fallback. Render
the same structured shell at the route Suspense, workspace hydration,
host-session binding, and AgentGUI-body boundaries; a plain background at any
one of those boundaries brings the apparent black screen back. Keep the
reusable body shell in the narrow
@tutti-os/agent-gui/startup-shellentry; let desktop compose standalone window chrome around it. Keep React Compiler settings aligned between development and production; do not hide a cold transform bottleneck by changing compiler semantics only in development. Reduce the initial module graph, precompile a stable package boundary, or schedule non-blocking preload work instead. For desktop development, wait for Vite to transform the statically reachable startup graphs beforeelectron-vitelaunches Electron. Treat the top-level workspace and standalone Agent lazy route modules as explicit warmup entries; do not follow every dynamic import, because that compiles unopened tools and diagnostics. Keep the right side shaped like the empty-home/new-conversation hero, not a selected conversation timeline with a bottom dock. Keep the fallback hero composer non-interactive until the real controller owns its draft. Load tool bodies on first open, show a panel-local busy state while they load, defer non-critical panel hosts until after the first frame, and start Workspace App polling only for an explicit Apps/app open. Key provider-status requests by request scope, prioritize the selected provider, merge responses per provider, and ignore stale results for a provider already refreshed by a newer request. - Validation:
Keep a focused regression test for an Agent window intent with no provider;
it must reach the startup shell without weakening extension-provider
validation.
Run focused provider concurrency and standalone tool-lifecycle tests, desktop
typecheck, renderer boundary checks, and a production desktop build. Inspect
the generated chunks to confirm the standalone shell does not statically
import the full AgentGUI body and that heavy optional App Center, Message
Center, settings, import, and account presentation modules stay in separate
async chunks. Keep a source-level regression test that verifies every
pre-controller return path renders the structured startup shell and every
deferred tool body has a non-empty loading fallback.
Finally cold-start local dev and compare the same timestamp landmarks; this
manual renderer verification requires explicit user approval. If the dynamic
import still dominates, compare cold and warm module-graph timings before
investigating daemon hydration or provider discovery. The dev server must log
renderer warmup completedbeforestart electron app. When a provider-status request is slow, compare RendererdurationMswith the daemon batchdurationMs. A large daemon total points to provider detection; a large Renderer-only gap points to transport, timeout handling, or Renderer runtime-probe fallback. Within the daemon, compare each provider total and its largest phase. Logs intentionally record provider IDs, counts, outcomes, and durations, but not executable paths, command output, environment values, or error messages. - References: agent-gui-node.md WorkspaceWindow.tsx electron.vite.config.ts renderer-dev-warmup.mjs StandaloneAgentToolSidebar.tsx desktopAgentProviderStatusService.ts desktopAgentProviderStatusDiagnostics.ts service.go service_status.go
Renderer body requests fail with ERR_H2_OR_QUIC_REQUIRED
- Symptom:
Renderer
POSTorPUTcalls to the local daemon fail withnet::ERR_H2_OR_QUIC_REQUIRED, while nearbyGETcalls still succeed. Agent provider options or model lists may remain loading, and Workbench or tracking writes can fail at the same time. - Quick checks:
In DevTools, compare a failed body-bearing request with a successful
GETto the same current daemon origin. Confirm the daemon listener port and bearer token have rotated correctly before treating this as stale endpoint recovery. - Root cause:
Rebuilding a request with
new Request(rewrittenUrl, originalRequest)carries the original body forward as aReadableStream. Chromium treats that as a streaming upload and requires HTTP/2 or QUIC, but the managed loopback daemon serves HTTP/1.1. - Fix: Materialize the already-serialized request body before rebuilding the request, then explicitly preserve method, headers, cancellation signal, and other request metadata. Continue resolving the current daemon origin and bearer token for every request.
- Validation: Exercise an actual body-bearing daemon call from Chromium, not only Node's fetch implementation, and confirm it returns a normal HTTP response. Keep unit coverage for JSON and binary bytes, custom headers, query parameters, cancellation, and rotating endpoint/auth configuration.
- References: createRestartAwareFetch.ts desktop-transport.md
Renderer fetch() rejects an Electron image protocol that <img> can load
- Symptom:
An image rendered from a Desktop custom protocol remains visible, but
renderer code that inlines or resizes the same image logs
Fetch API cannot loadandURL scheme ... is not supported. Catching the rejected promise does not suppress Chromium's console message. - Quick checks:
Find the scheme passed to
fetch(). Confirm its privileged registration enables bothsupportFetchAPIandcorsEnabled, runs before Electronready, and its handler is installed on the renderer's exactSession. A working<img>only proves the no-CORS subresource path and protocol handler; it does not prove that renderer JavaScript may read the response. - Root cause:
The renderer page and custom protocol have different origins. Electron
permits
<img>to load a no-CORS custom-protocol response, but blocks a cross-originfetch()from reading it when the scheme is not CORS-enabled. - Fix:
For fixed, non-sensitive image routes that renderer code must inline, register
the scheme with
supportFetchAPI: trueandcorsEnabled: true. Register all privileged Desktop schemes together in the single pre-readycall, then install handlers on every intended Session. Do not enable cross-origin reads for protocols that expose arbitrary or sensitive local files. - Validation: Keep a contract test over every fetchable image scheme, run the Desktop Electron boundary checks and typecheck, and build the production Desktop bundle. Verify the renderer can read the response body, not only display the URL in an image element.
- References: desktopCustomProtocolSchemes.ts tuttiAssetProtocol.ts workspaceFileIconProtocol.ts
AgentGUI Mermaid flowcharts render shapes without labels
- Symptom: Mermaid flowcharts in AgentGUI show node borders and edges, but node and edge labels are blank.
- Quick checks:
Inspect Mermaid's raw SVG before the transcript sanitizer. If labels are
children of
<foreignObject>while the sanitized SVG keeps shapes but has no<foreignObject>or equivalent<text>, the missing content is an SVG/HTML label-mode mismatch rather than a color or layout problem. - Root cause:
Mermaid enables HTML labels by default and renders them inside SVG
<foreignObject>elements. AgentGUI's defense-in-depth SVG sanitizer removes those elements, including their text, while preserving native SVG shapes and paths. - Fix:
Configure Mermaid to emit native SVG text with
htmlLabels: false. AddhtmlLabelsto Mermaid's secure configuration keys so diagram-level directives cannot turn HTML labels back on. Keep the post-render SVG sanitizer in place. - Validation:
Render a real flowchart containing multiline, CJK, decision, and edge labels,
including an
initdirective that requests HTML labels. Assert the sanitized result contains every label as native SVG text and no<foreignObject>. - References: AgentMessageMermaid.tsx AgentMessageMermaid.integration.spec.tsx
AgentGUI carousel owner avatar stays a solid badge
- Symptom: The DOM owner avatar and other identity surfaces show the expected image, but the WebGL empty-home carousel keeps its solid programmatic owner marker until the component remounts.
- Quick checks:
Confirm the host projects a non-empty
owner.avatarUrl, the same URL renders in a normal anonymous<img>, and the asset response permits anonymous CORS. If a restart or remount makes the carousel image appear without changing the directory projection, inspect the carousel image loader rather than adding another profile or daemon request. - Root cause:
A transient first network failure can be latched as a decoded
nullimage. Because the authoritative URL did not change, the carousel has no reason to create a new image generation and the solid fallback remains. - Fix: Keep one carousel image-load owner and retry anonymous owner badges a small, bounded number of times. Cancellation must clear both the active image source and any pending retry timer. Continue using the host's authoritative owner projection; renderer code must not fetch a second avatar source.
- Validation: Inject one failed owner-image attempt, advance through the first retry delay, and assert that the next anonymous image resolves while icon and cover loading remain unchanged. Also assert that canceling a generation resolves it empty and clears every active source.
Renderer tile memory warnings from hidden autoplay animation
- Symptom:
Electron or Chromium logs repeatedly print
tile memory limits exceeded, some content may not draw. DevTools performance traces show continuousFireAnimationFrame,Layerize, andCommitactivity while the visible UI looks mostly idle. - Quick checks:
In the trace, group
FunctionCallorv8.callFunctionevents byurlandfunctionName. Hidden animation players often still appear as repeatedrequestAnimationFramecallbacks even when their DOM node hasopacity: 0. Also inspectanimationiterationvolume and completed CSS entry animations whosefill-mode: bothleaves an identitytransformon every mounted window. - Root cause: CSS-hidden animation elements are still live renderers. An autoplay/looping Lottie, canvas, or WebGL player can keep scheduling frames and force layer updates across every mounted instance. CSS can produce the same pressure: inactive windows may keep infinite transform/opacity animations running, and a completed entry animation with forwards fill can retain a compositor layer long after its visual work ends.
- Fix:
Mount animation players only while the animation is actually visible, and
defer loading third-party animation runtimes until an active state needs
them. When a workspace restores many heavy bodies, render the active body
immediately but hydrate inactive bodies sequentially after idle, one
animation frame at a time; keep their shells and saved geometry visible
throughout. Once mounted, derive visual exposure from Workbench geometry and
z-order, not keyboard focus. Pause descendant CSS animations only while a
window is fully occluded. A partially exposed window remains fully painted;
only a window whose frame is completely covered may use
content-visibility: hidden. Release imperative resources such as WebGL scenes, decoded carousel images, observers, and non-passive listeners while fully occluded. Avoid running a body's entry animation when its host shell already owns the appearance transition. For delayed entry animations whose normal styles already match the final keyframe, use backwards fill so the initial keyframe covers the delay and the final identity transform is released. Do not rely onopacity,visibility, or off-screen placement to stop playback. - Validation:
Re-record a short DevTools trace after the fix. Idle UI should no longer show
the hidden player's function as a high-frequency
requestAnimationFramesource, and Chromium tile memory warnings should stop during idle. For multi-window AgentGUI changes, runpnpm perf:agent-gui -- --scenario workbench-window-drag; it moves one window while at least three are mounted and rejects tile-memory warnings or excessive background animation iterations. Use--scenario workbench-fifty-window-stressfor the 50-window startup, background-focus, retained-DOM, geometric exposure, drag, and 50 ms renderer-task budgets.
IME composition breaks fuzzy search or controlled search inputs
- Symptom: Chinese, Japanese, or Korean input cannot be committed in a fuzzy search, mention picker, or a controlled name dialog (for example Files → New folder / New file). Pressing Enter to accept an IME candidate may select a highlighted result, submit a search or create dialog, or clear/replace the partially composed text.
- Quick checks:
Inspect any
keydownhandler that consumesEnterorTabwhile a menu is open. Also inspect controlled text/input[type="search"]fields whosevaluecomes from async search/controller or dialog store state and whoseonChangecommits on every keystroke without composition handlers. - Root cause:
IME candidate confirmation is delivered through composition-aware keyboard
events. If menu shortcuts do not check
isComposingor thekeyCode/which229fallback, the app treats candidate confirmation as a command. If a controlled search or name input pushes every composition update through async search/controller/dialog state, stale parent values can overwrite the local composing buffer. - Fix:
In fuzzy/menu key handlers, return before command handling when
event.isComposing,event.nativeEvent.isComposing,keyCode === 229, orwhich === 229. For controlled search or name inputs, keep a local value duringcompositionstart/compositionend(preferuseComposedInputValue), commit to the controller oncompositionend, and ignore stale parent values until the parent catches up. Guard form submit while composition is active. - Validation: Add a unit test for the IME guard or input sync state, then manually type a Chinese query/name and confirm Enter accepts the candidate instead of selecting a result or submitting the field.
- References: richTextIme.ts useComposedInputValue.ts WorkspaceFileManagerMenus.tsx WorkspaceFileReferencePickerTree.tsx IssueManagerSidebarSections.tsx
Controlled list input loses focus after every edit
- Symptom: Typing or deleting one character in a controlled input inside a rendered list immediately ends the input state or clears focus.
- Quick checks:
Inspect the nearest mapped row's React
key. Confirm the key does not include the input value or another field that changes in the input'sonChangepath. - Root cause: Each edit changes the row key, so React treats the row as a different element and unmounts the focused input before mounting its replacement.
- Fix: Build list-row keys only from stable row identity. For append/remove-only drafts without a persisted row ID, a stable parent identity plus the row position is acceptable; do not include editable values merely to make the key look unique.
- Validation: Keep a regression test that rejects editable values in the row key. Manually type and backspace repeatedly in each affected input and confirm that focus and selection remain in the same field.
- References: WorkspaceSettingsPanel.tsx
External-store snapshots churn because derived reads lose reference stability
- Symptom:
useSyncExternalStoreconsumers re-render continuously, lose memoization wins, or behave as if external state changed even when the underlying store snapshot did not. - Quick checks:
If the issue starts in a React component or shared React hook, look for a
direct
useSyncExternalStorecall or an ad hoc subscription wrapper and route it through@tutti-os/ui-react-hooks. If the issue starts in a non-React adapter that exposesgetSnapshot(), check whether it rebuilds objects or arrays on every read instead of reusing a derived snapshot while the source snapshot is unchanged. - Root cause:
A subscription boundary reads from a source that returns a fresh derived
object or array on each
getSnapshot()call. This can happen either in a React subscription wrapper or in the adapter that owns the derived snapshot. The type signature allows this, but the runtime contract requires referential stability while the source snapshot is unchanged. - Fix:
In React consumers and shared frontend packages, prefer
@tutti-os/ui-react-hooksand useuseExternalStoreSnapshotoruseExternalStoreSelectorinstead of handwrittenuseSyncExternalStorewrappers. In adapter-level or non-React derived stores, reuse the derived snapshot until the source snapshot reference changes. In@tutti-os/workbench-surface, preferpackages/workbench/surface/src/store/createDerivedSnapshotGetter.tsfor that boundary instead of rebuilding a fresh object inline. - Validation:
Add or update a regression test that asserts repeated
getSnapshot()calls return the same reference before a real state change. Then run the affected package tests,pnpm typecheck, and the relevant renderer build checks when the subscriber is consumed by desktop UI. - References: packages/ui/react-hooks/src/useExternalStoreSnapshot.ts packages/ui/react-hooks/src/useExternalStoreSelector.ts packages/workbench/surface/src/store/createDerivedSnapshotGetter.ts packages/workbench/surface/src/host/missionControlAdapter.ts packages/workbench/surface/src/host/missionControlAdapter.test.ts
React Compiler removes a manual identity memo
- Symptom:
React profiling reports a grouped prop as referentially unequal but deeply
equal on every render even though source code wraps it in
useMemo. - Quick checks:
Inspect the renderer's dev transform and production bundle. A source pattern
such as
useMemo(() => nextValue, [nextValue.field])may compile toconst value = nextValue, restoring the fresh input reference. - Root cause: The memo callback returns an existing input object while its dependency list intentionally describes selected fields. React Compiler infers the input object as the value dependency and may remove this identity-only memo.
- Fix:
Build an explicit projection object from every semantic field and let React
Compiler cache that allocation by those fields. Do not use a component ref or
useMemo(() => freshInput)to absorb upstream reference churn. - Validation: Add a compiler regression test for the projection, run the desktop production build, and inspect the emitted cache conditions. They must compare semantic fields rather than assign the fresh input object directly. Re-record a React performance trace to verify deeply-equal grouped-prop changes disappear.
- References: useStableDesktopAgentGUIHostProps.ts useStableDesktopAgentGUIHostProps.test.ts
Workbench host rebuilds when dock business status changes
- Symptom: Clicking a dock action such as local agent login opens a browser or starts a backend command, but the expected terminal or agent node disappears, is not created, or loses context. The UI can look like the action ran in the background while the Workbench session was rebuilt underneath it.
- Quick checks:
Search the workspace shell for
useSyncExternalStoresubscriptions, revision values, or React state that feedcreateHostInput(...). If provider status, quota, sync, installation, or authentication state is in that dependency list, inspect whether status changes are recreatingWorkbenchHostprops, node definitions, or contribution objects. Also check whetherdockEntriesinclude live business fields that change on every status refresh. - Root cause: High-churn business status was modeled as host input state instead of dock presentation state. Each status revision rebuilt the Workbench host input and could tear down or replace the active host/session while an action still needed the old host handle.
- Fix:
Keep
dockEntriesand Workbench host input stable for static workspace wiring. Route live dock presentation throughWorkbenchHostDockEntryStateSourceor an equivalent service-backed getter plus subscription. The dynamic source may expose disabled/loading state, badges, hover actions, attention tokens, and temporary visibility, but it should not own node definitions or launch wiring. Dock action callbacks should receive the currentWorkbenchHostHandlefrom the dock interaction instead of reading a host from stale outer React state. - Validation: Add a regression test for the dynamic state source that proves one source object reads updated service snapshots without recreating host input. Then run desktop typecheck and relevant tests. For runtime verification, start the desktop or web renderer, trigger a login/install dock action, and confirm the terminal or agent node remains stable while dock status updates.
- References: docs/architecture/workbench-dock-model.md packages/workbench/surface/src/host/types.ts packages/workbench/surface/src/host/WorkbenchHostDock.tsx useWorkspaceWorkbenchShellRuntime.tsx
Dock entry is open but its state indicator is missing
- Symptom: A Dock icon is visible and its application window is open or minimized, but the state dot is absent. The problem may affect one migrated node family or every application in one Dock placement.
- Quick checks:
Inspect the slot's
data-node-state. If it isclosed, compare the node's persisteddockEntryIdwith the rendered entry id before inspecting CSS. If it isopenorminimized, inspect placement selectors for rules that hide or clip the shared::beforeindicator. Reproduce with both an internal entry and aworkspace-app:<appId>entry to separate identity from presentation. - Root cause:
dockEntryIdis exact durable affinity. A historical or provider-specific value does not match a newer aggregate entry and therefore resolves toclosed. Separately, a placement-specific CSS override can suppress a correctly resolved indicator for every application in that layout. - Fix:
Normalize stale durable affinity through an idempotent daemon migration and
make all new launch paths write the canonical entry id. Keep Workbench exact
matching intact. Render the shared indicator for both
openandminimizedin every supported placement, changing only its position. - Validation:
Cover migrated snapshots, canonical new launches, third-party Workspace App
affinity, and bottom/left indicator selectors. Verify
closedhas no dot and bothopenandminimizeddo. - References: docs/architecture/workbench-dock-model.md packages/workbench/surface/src/host/dockEntries.ts packages/workbench/surface/src/styles/workbench.css
Effect cleanup leaves mounted refs false in React development
- Symptom:
A React component works far enough to start async work, but later promise
continuations silently skip state updates behind an
isMountedRef.currentguard. In development, the UI can remain permanently stuck in a loading state even though the backend request succeeded. - Quick checks:
Search the component for an effect cleanup that sets an
isMountedRefor similar lifecycle ref tofalse. If the effect body returns the cleanup directly, verify the setup path also sets the ref back totrue. - Root cause: React development and StrictMode can run an effect cleanup followed by setup while the component continues to be used for validation. If setup does not restore the mounted ref, later async callbacks treat the live component as unmounted and drop state updates.
- Fix:
Use an effect body that sets the mounted ref to
truebefore returning the cleanup that sets it tofalse. - Validation: Run the affected React package tests and cold-start the consuming desktop UI, because hot reload can preserve the stale ref value from before the fix.
- References: useAgentGUINodeController.ts
Dock popup stays on skeletons after preview capture succeeds
- Symptom:
Dock popup cards remain as skeletons even though renderer diagnostics report
dock_preview_capture.succeeded. - Quick checks:
Compare
dock.popup.preview_capture.startedanddock.popup.preview_capture.resolved. If capture starts once but the popup effect runs twice in development, inspect whether the first cleanup fences the result while the replayed effect skips the same pending capture. - Root cause: React StrictMode replays effect setup and cleanup. Electron capture cannot be canceled, so a module-level pending marker can outlive the effect invocation that started it. Treating that invocation as canceled drops its successful result, while the replay cannot start a replacement.
- Fix: Keep the pending marker until the native capture settles. Commit the result when the popup is still mounted and the item's semantic preview identity is still current, regardless of which equivalent effect invocation issued the capture.
- Validation:
Render the popup under
StrictMode, defer the capture promise until effect replay completes, and assert one native capture plus a rendered image. - References: docs/architecture/workbench-dock-model.md WorkbenchHostDockPopup.tsx
Some background Dock previews remain as skeletons
- Symptom: A multi-window Dock popup renders foreground or previously cached previews, but windows that have never been foreground remain visually identical to loading skeletons.
- Quick checks:
Correlate
dock.popup.preview_capture.startedwithdock.popup.preview_capture.resolved. An immediatehasPreview: falsewithout a nativedock_preview_capture.startedevent means the host rejected native capture before IPC. Check whether the node is background and whether its revision has a persisted preview. - Root cause:
Electron's rectangular capture reads the currently composited foreground
pixels. The desktop host correctly rejects a background node because its
rectangle contains another window. AgentGUI may also be unhydrated or have
inactive imperative resources when
surface.isVisible=false, so a fresh DOM snapshot can produce a blank image. Treating an unavailable result as a reusable cache entry also prevents a later foreground attempt for the same revision. - Fix: Keep native capture as the foreground high-fidelity path. Background and minimized popup nodes first reuse a successful memory or persistent image. Treat the bounded Dock PNG as one Node-level latest artifact shared by popup and minimize capture: read shared memory first, then read the unrevisioned persistent latest identity and exact revision in parallel. Prefer the shared latest entry and promote an exact-revision fallback when used. If those sources miss, serialize DOM-cloned snapshots for non-minimized background nodes, yield the renderer task queue between clones, deduplicate them by preview identity and revision, and write successful images to the one shared-latest identity. Keep the full-resolution Genie animation texture separate. Do not DOM-capture minimized nodes because their content may be unhydrated. Keep an unavailable result local to the mounted popup so reopening can retry after the node becomes foreground. Show a static terminal placeholder instead of a loading skeleton when no successful image exists.
- Validation: Cover native-null plus exact-cache promotion, shared memory and unrevisioned persisted-cache reuse before DOM capture, single shared-latest writes after memory or DOM success, and reopen the popup to prove that a prior unavailable result does not block a later successful capture.
- References: docs/architecture/workbench-dock-model.md WorkbenchHostDockPopup.tsx
AgentGUI crashes while unmounting a Monaco diff
- Symptom:
AgentGUI's React subtree crashes while a file diff is being hidden, replaced,
or removed. Renderer logs repeatedly report
TextModel got disposed before DiffEditorWidget model got reset. Long histories with many expanded edit tools make the failure easier to trigger. - Quick checks:
Confirm the exception occurs during a diff component cleanup rather than
while parsing the patch. Inspect whether either Monaco text model is disposed
while the diff editor still returns it from
getModel(). Repeatedly mounting and unmounting one diff is a focused reproduction. - Root cause:
A diff wrapper disposed its owned original and modified text models before
detaching them from
DiffEditorWidget. Monaco listens for model disposal and deliberately throws while a disposed model is still attached. Keeping the models alive avoids the exception but leaks one pair per diff. - Fix:
Let the Agent GUI package own the diff editor and both models. On cleanup,
call
diffEditor.setModel(null)first, dispose the diff editor second, and dispose the owned original and modified models last. Cancel asynchronous Monaco loading on unmount so a late module resolution cannot create detached resources. - Validation: Keep a component test that records the exact detach/editor/model disposal order, a test for unmount before Monaco finishes loading, and a test proving content changes reuse the mounted editor. Run the Agent GUI package test and typecheck lanes.
- References: AgentMonacoDiffViewer.tsx monaco-react issue 647 monaco-editor issue 4779
Workbench node body warns about updating WorkbenchNodeLayer during render
- Symptom:
Opening a workbench node shows React's warning that
WorkbenchNodeLayeris updated while rendering a different node body component. The node may stay on a loading surface even though the backing request succeeds. A retained request controller can also work for the restored item but silently ignore later selections: for example, AgentGUI shows the conversation rail while every newly selected conversation has a blank timeline and the daemon receives no message-list request. - Quick checks:
Inspect controller construction paths called from React render or
useMemo. If the constructor callssetActiveFile, subscribes with an immediate callback, publishes node runtime state, or calls any host setter, it can synchronously update the workbench layer during render. Also inspect effect cleanups that callcontroller.dispose(): React StrictMode can run an immediate cleanup/setup cycle in development, so disposing the same retained controller during that validation pass can make later async responses look stale forever. - Root cause:
Workbench node bodies can create controllers while rendering. Any synchronous
controller side effect that calls
context.setNodeRuntimeState,context.setSnapshotNodeState, or a React state setter escapes into the parent layer before React has finished rendering the body. - Fix:
Keep controller construction side-effect free. Start active-file work,
subscribe snapshots, and perform the initial snapshot sync from
useEffect. If a subscriber must receive the current snapshot immediately, subscribe and then invoke the listener from the effect body. Dispose retained controllers with a StrictMode-safe delayed cleanup that can be canceled if the same controller is set up again immediately. - Validation: Verify construction does not call host state publishers, then run the affected desktop tests and open the node in development with DevTools visible. For request-owning controllers, switch between at least two uncached items after the initial render and confirm that each selection reaches the backend.
- References: workspaceFilePreviewNodeController.ts WorkspaceFilePreviewNodeBody.tsx useAgentConversationMessagePaging.ts
Renderer component repeatedly re-renders without visible changes
- Symptom:
The desktop renderer feels stuck, text flickers, or React reports
Maximum update depth exceeded, but the current stack only points at the component that calledsetState. - Quick checks:
First inspect state-sync diagnostics. Enable the renderer-wide React Profiler
only when its render-storm diagnostics are needed by launching with
VITE_TUTTI_REACT_PROFILER=1; leave it off for Chrome Performance captures on large workspaces because React dev component tracks can make trace initialization stall. For prop identity churn, opt in to why-did-you-render withVITE_TUTTI_WHY_DID_YOU_RENDER=1 make dev-gui, or setlocalStorage.tuttiWhyDidYouRender = "1"in DevTools and reload the renderer. Do not leave it enabled during normal development: it tracks every component and hook, and restoring a large AgentGUI session directory can then block the renderer long enough to keep Workbench hydration and the Dock non-interactive. For AgentGUI render storms, trace the fullengine -> selector -> projection -> controller -> sectionchain. Separate real summary-field changes from reference-only array, object, or callback changes; a memoized leaf cannot contain churn created at the selector boundary. On a Rail click, count updated sections and rows, then inspect whether a global active ID, the full provider-dependent label object, or scope-dependent action callbacks changed on every section. Thousands of Tooltip, Popper, Dropdown, or ContextMenu component renders usually amplify that upstream fan-out rather than identify its owner. React DevTools component tracks add profiling cost, so use them to locate the chain but recapture without the profiler for timing. When the stack starts insetRef, inspect RadixasChildcomposition before changing business state. In particular, check whether Tooltip and Dropdown triggers both clone the same DOM child and merge callback refs, or whether a transient status row mounts a Tooltip trigger while its message changes. Also reject Tooltip/Select nesting whereTooltipTriggerdirectly wraps aSelectTrigger; both primitives install a stateful Popper anchor on the same button. - Root cause:
React StrictMode can intentionally replay setup/cleanup in development, but a
continuously increasing render count usually means a parent is passing a new
object/function every render or an effect writes state from a dependency that
changes on every render. An external-store selector can also project a fresh
list for every unrelated engine event, after which a container rebuilds command
callbacks and fans one update out to every list section. A render-budget test
that injects an already-stable view model bypasses this production chain and
cannot detect that regression. A container-owned relative-time interval can
cause the same fan-out when its timestamp is passed through every section and
row instead of being consumed at the timestamp leaf. A globally threaded
selection ID, a provider-dependent full label object passed into Rail, or
callbacks rebuilt for each target scope can likewise invalidate every
section even though only one or two rows changed visually. If every section
owns closed Tooltip/Popper/Dropdown/ContextMenu content, that upstream
invalidation also executes thousands of invisible primitive components. A
changing lock, drag-disabled, or batch-disabled prop on one memoized section
header has the same effect: React must execute the whole header and its
mounted Radix trigger tree even when only one native attribute or one open
menu item changes. A combined Context object merely moves that fan-out from
props to every Context consumer. Keeping those Context providers inside the
memoized section also executes item projection before the update reaches the
narrow consumer. A project reorder can show a valid insertion indicator but
never commit when only the section owns
drop: releasing over a gap bypasses that handler and global cleanup clears the valid drag state. A measurement effect that includes the state it writes in its dependencies can repeat the resulting layout read once more. - Fix:
Stabilize the value at the ownership boundary, or remove derived presentation
values from bidirectional state. For external/workbench state, only sync
canonical identifiers and derive display text from the owning service. In
AgentGUI, select the narrow render projection with a render-field equality
function, keep command callbacks stable, and separate Rail render equality
from active-session semantic equality. Stabilize usage, commands, prompt
queue, quota, session-chrome, and host callback projections at their owning
selector/controller boundary; do not clone canonical arrays while assembling
the view model. For a paged Rail, project only canonical sessions referenced
by current section, search-result, or reconciliation ids, then structurally
share unchanged summary items. Let time-label consumers subscribe directly
to a shared renderer-realm relative-time external store. The store starts one
timer for its first subscriber and clears it after its last unsubscribe.
Project selection into the section that owns the active canonical or overlay
row, passing
nullto unrelated sections. Give Rail a dedicated locale-bound label projection instead of the provider-dependent full view labels. Keep shared section actions referentially stable and read the latest scope at event time. Split stable section header/action chrome from changing item data: pass scalar presentation fields and stable event-time actions, never the section object. Keep menu root and trigger mounted, while rendering portaled menu content only during its view-local open state. Do not move disclosure into a controller/store or copy Session/project semantics to obtain this isolation. Split large headers into stable identity, create-action, menu, and frame render islands. Project frequently changing derived booleans through separate primitive view Contexts owned outside the memoized Section so project drag state reaches only the native draggable frame, project action lock reaches only the forwarded-ref button leaf and open project menu, and batch deletion state reaches only open menu content. Keep event-time lock readers as the action-delivery guard; Context is only the current presentation projection. Closed menus should have no batch-state consumer. Keep the project header as the drag source and let each project section update insertion position across its full area. Let the Rail scroll viewport own the final drop so section gaps commit the last visible valid position. Remove a measured state value from an effect dependency when the effect only writes, but never reads, that value. During Rail reconciliation, expose a stable lock reader so portaled menu actions can check current state without passing a changing boolean through every section. For composed menu actions, attach the Tooltip trigger to a stable wrapper and the Dropdown trigger to the actual forwarded-ref button. Do not nest bothasChildtriggers onto the same element: their ref callbacks can repeatedly detach and attach each other until React aborts the renderer tree. For truncated, non-interactive status text, prefer a nativetitleon the text element; it preserves access to the full message without introducing a stateful anchor ref during session transitions. Select triggers should likewise keep their nativetitleand must not be wrapped by a second Tooltip trigger. - Validation: With why-did-you-render enabled, reproduce once and confirm the noisy component lists the expected prop or hook difference. Then disable the tool and run the affected renderer tests plus desktop typecheck. AgentGUI budget tests must dispatch a real engine update and assert the unrelated Rail subtree stays at zero renders; do not replace this with a manual view-model rerender that reuses the Rail reference by construction. For relative-time clocks, assert multiple time-label consumers share one interval, the last unmount clears it, and a tick updates labels without rerendering the parent rows. Add identity tests for locale-bound Rail labels and scope-bound actions, including invoking a callback retained before a scope switch. Assert active selection projects only into its owning section. Then recapture the same interaction without React Profiler instrumentation before claiming timing improvement. Add a render-budget test proving item replacement does not rerender stable section chrome, including an item-empty transition that changes batch-action availability. While a menu is open, assert lock changes still update its trigger and disabled items. For lazy menu content, test pointer/context-menu opening, keyboard-origin focus, Escape dismissal, action delivery, and event-time lock rejection. Add a composition regression test for shared Tooltip/Dropdown actions and manually create a new conversation, since an empty-to-populated Rail transition can be the first time the faulty trigger mounts.
- References: main.tsx whyDidYouRender.ts useAgentGUIConversationRailQuery.ts useAgentGUIConversationRailQuery.search.spec.tsx agentGuiConversationRailQuerySnapshot.spec.ts AgentGUIConversationRailClock.spec.tsx agentGUIConversationRailLabels.ts useAgentGUIConversationRailViewState.ts AgentGUIConversationRailSection.tsx AgentGUIConversationRailSectionHeader.tsx AgentGUIConversationRailItem.tsx AgentSessionChrome.tsx
Provider Rail tile drags but does not reorder
- Symptom: A Provider Rail tile shows the native drag image, but the insertion indicator does not follow rail gaps and dropping does not persist a new order.
- Quick checks:
Inspect each
[data-provider-tile="true"]element. Confirm the rendereddata-*identity and its camel-caseddatasetreader name match exactly. - Root cause: A terminology migration can rename the dataset reader without renaming the DOM attribute. Container-level hit testing then discards every tile because each target ID appears empty, while native dragging still makes the feature look partially functional.
- Fix:
Keep the DOM identity attribute and dataset reader aligned. Cover the Rail
container path, not only a tile's own
dragover: simulate dragging over a gap, assert the insertion indicator, drop, and verify persisted order. - Validation: Reorder through a rail gap, reload, and confirm the new order remains.
- References: AgentGUIProviderRail.tsx AgentGUIProviderRail.spec.tsx
Dense list panel stutters when mounted or resized
- Symptom: Opening a card or row-heavy Workbench panel pauses before it becomes interactive, or resizing the panel produces repeated layout work even though the visible content is simple.
- Quick checks:
Record a Chrome Performance trace and inspect the opening interval for
repeated
ResizeObservercallbacks, animation-frame callbacks, layout reads, and React commits. Search repeated item components for per-item observers, globalresizelisteners, and reads such asscrollWidth,clientWidth,scrollHeight, orclientHeight. Count these subscriptions per rendered item instead of evaluating only one card in isolation. For a floating Workbench node, also check whether every intermediate frame update rerenders the complete node body. For wallpaper-aware chrome, count canvas draws and pixel readbacks while resizing. - Root cause:
A text-overflow tooltip or similar decoration can create an observer and an
initial layout measurement for every repeated text node. Mounting the whole
list then schedules many layout reads and state updates together. Permanent
will-changehints on every item can add avoidable compositing work at the same time. A host adapter can also treat every drag or resize frame as a body data change even though the Workbench shell already owns live geometry. Recreating a canvas and reading the same static wallpaper pixels on each resize frame adds independent main-thread work. - Fix:
When overflow state is needed only to decide whether an interaction tooltip
should open, measure on pointer or focus interaction and reuse a pure overflow
predicate. Keep continuous observation only when the UI must react while it
remains visible; in that case prefer one owner-level observer over one
observer per repeated child. Do not leave
will-changeon idle list items. Let the outer Workbench shell apply live frame geometry; expensive body adapters may gate frame-only renders with body-contextisDraggingandisResizing, then consume the final frame when the interaction ends. Cache immutable wallpaper image samples and read cached RGBA bytes instead of repeatingdrawImageorgetImageDataduring resize. - Validation: Verify the panel mounts without item-level observer callbacks, then confirm truncated and non-truncated text still show the correct tooltip after a resize. Run the owning package tests, renderer boundary checks, and the desktop production build.
- References: AppCard.tsx appCardTextOverflow.ts hostNodeContext.ts dockWallpaperSampling.ts
Adjacent sidebar animation repeatedly reflows its content and message flow
- Symptom: Opening or closing a right sidebar stutters for the full duration of its slide animation. A Performance trace shows repeated layout and paint work in both the sidebar and its adjacent message flow even when the panel body was mounted lazily.
- Quick checks:
Inspect the flex or grid boundary shared by the main content and sidebar.
Search the animated shell for
transition-[width],flex-basis, layout-bound keyframes, permanentwill-changehints, and native window bounds animation. If a sidebar contains responsive grids, confirm its available width is not changing on every animation frame. - Root cause: Animating a sidebar's layout width makes the browser recompute both sibling layout trees every frame. Running an Electron native bounds animation at the same time changes the renderer viewport too, so the two animations can cause additional message-flow reflow even when they have matching durations.
- Fix:
Commit the final sidebar width and native window bounds once. Keep the panel
beside the main content in normal layout, isolate its subtree with layout and
paint containment, and use only
transformoropacityfor the optional fixed-size inner-panel entrance. Delay expensive first-use content until that compositor entrance completes, then retain it while hidden. - Validation: Add a structural regression test that rejects layout-property transitions and native bounds animation. Re-record the opening trace and confirm the interval no longer contains a layout task for every animation frame, then run desktop tests, typecheck, renderer boundaries, and the production build.
- References: StandaloneAgentToolSidebar.tsx standaloneAgentWindowBounds.ts
Header divider drifts from a resizable sidebar
- Symptom: A Workbench node's custom header divides its chrome at the sidebar boundary, but that divider moves ahead of or behind the body sidebar while the resize handle is dragged.
- Quick checks:
Identify where the header and body read their widths. If the body owns a
descendant CSS variable while an effect copies the value to a Workbench
ancestor for the header, there are two update paths. Also check whether
grid-template-columns,width, or the resize handle's position keeps a transition active during pointer movement. - Root cause: A CSS variable declared inside the body cannot inherit upward into a sibling header. Copying React state to an ancestor in a passive effect makes the header update in a different phase, while a persistent layout transition makes one boundary chase each pointer position. Rapid pointer movement exposes the divergence even when both paths eventually settle on the same number.
- Fix:
Put the live width variable on the lowest DOM scope shared by header and body,
and update that single variable directly from
pointermove. Keep the in-progress width in the interaction ref, update ARIA imperatively, and commit React state when the resize ends. Mark the resize lifecycle explicitly and disable layout and handle-position transitions untilpointerup,pointercancel, or lost pointer capture. A standalone surface without a shared Workbench ancestor can own the same variable on its layout root. - Validation: Unit-test that the layout publisher selects the shared Workbench scope, updates it in place, and cleans it up. Run the owning package tests, typecheck, and renderer/UI boundary checks, then visually drag in both directions and confirm the header and body dividers remain coincident.
- References: IssueManagerSidebarLayout.ts useIssueManagerShellView.ts useAgentGUIConversationRailResizePointerMove.ts
Renderer services initialize twice and consume one event twice
- Symptom: One daemon lifecycle transition produces duplicate renderer work, such as two identical completion toasts, repeated reconcile requests, or two service instance IDs applying the same state transition.
- Quick checks:
Compare daemon and renderer logs by workspace, session, turn, and event time.
Confirm whether the daemon emitted one settled transition while the renderer
applied the same payload twice. Check
workspace_runtime.created,workspace_runtime.committed, andworkspace_runtime.duplicate_detectedbyrendererInstanceIdandruntimeInstanceIdbefore blaming the transport. - Root cause: A renderer-window service graph was constructed from React render instead of an explicit renderer bootstrap owner. A discarded render or remounted host could leave its subscriptions alive because cleanup belonged only to the committed component tree and several services did not retain their unsubscribe handles.
- Fix:
Dynamically load and create one workspace-window runtime before
createRoot().render, then pass it through props and DI context. Give that runtime one idempotentdispose()that releases controllers, service subscriptions, analytics leases, host listeners, DI services, and the shared event-stream client. Keep a stable workspace/session/turn toast ID only as a presentation-boundary defense, not as the ownership fix. - Validation: Assert one active runtime per renderer realm, zero subscriptions after disposal, and one notification for repeated delivery of the same turn. Run targeted service tests, TypeScript lint and typecheck, changed-aware checks, and the production desktop build.
- References: main.tsx createWorkspaceWindowContainer.ts workspaceAgentActivityReconcileBridge.ts workspaceAgentOutcomeNotification.ts
Dialog action reacts to Enter but ignores pointer clicks
- Symptom: A dialog action succeeds from an input's Enter handler, but clicking its visible action button does nothing. No request, caught error, or busy state is produced.
- Quick checks:
Trace
pointerdown,pointerup,click, and the command boundary without logging field contents. If both pointer events arrive butclickand the command do not, stop debugging the daemon or persistence layer. - Root cause:
Electron, a modal interaction layer, or surrounding Workbench chrome can
suppress the synthesized
clickeven though the button receives the pointer sequence. A handler wired only toonClicktherefore never runs. - Fix:
Handle
pointeruponly after a matching primary-buttonpointerdown; clear the armed action onpointerleaveandpointercancel. If the button instead establishes pointer capture explicitly, also clear on lost capture and validate that the release coordinates remain inside the action before executing it. Preserve keyboard activation explicitly, retain an assistive-technology click-only path, and guard the async action with a synchronous in-flight ref so multiple event paths cannot dispatch the command twice. - Validation: Cover pointer activation, the following synthesized mouse click, keyboard activation, assistive click-only activation, unmatched pointerup, canceled pointer sequences, blank input, and cancellation. Assert the command runs exactly once for each accepted action.
- References: AgentGUIRenameConversationDialog.tsx
Daemon validation error appears as untranslated developer text
- Symptom: A renderer action shows an English daemon message such as a validation failure while the UI locale is not English.
- Quick checks:
Inspect the protocol error's
code,reason, andparams. If the reason is generic and the UI falls through todeveloperMessage, the transport lost the stable domain identity needed by i18n. - Root cause: The daemon classified a specific business validation error as a generic request failure. The renderer then had no stable key and exposed diagnostic text as user-facing copy.
- Fix:
Define a stable daemon error identity, publish a documented protocol
reasonwith interpolation-onlyparams, then translate that reason in the owning UI package. Never infer user-facing errors by matching developer-message text. - Validation:
Test service error identity, protocol classification and params, every locale
dictionary, and renderer mapping while an English
developerMessageis present. Run API-generation and i18n consistency checks. - References: apierrors.go agentGuiController.errors.ts
Mask-backed icon renders as a solid color block
- Symptom: A monochrome icon has the expected size and color but renders as a solid square or rectangle. Other icons backed by the same packaged SVG still render normally.
- Quick checks:
Confirm the SVG import resolves to a CSS-safe self-contained data URL, then
inspect how that URL reaches the element. If the icon background applies but
the mask does not, look for a dynamic URL passed through a custom property
into the
maskor-webkit-maskshorthand. If both mask-image longhands are present, verify that their source is the dedicated monochromemaskIconUrl, not the target's primary coloriconUrl. - Root cause: Either the dynamic mask source crossed two parsing boundaries and the composed declaration was rejected, or the renderer conflated two Agent Directory roles by using the primary identity image as a monochrome mask. An opaque primary image produces a full-box alpha mask even when the CSS is valid.
- Fix:
Keep the dynamic image source on the element through the explicit
maskImageandWebkitMaskImagelonghands. Keep static position, repeat, and size declarations in CSS longhands. Do not route dynamic image URLs through a custom property into a shorthand. Preserve Agent Directory icon roles: rendermaskIconUrlthrough the mask element and render aniconUrlwithout a matching mask as a normal image. - Validation: Assert the rendered element owns both mask-image longhands and that the packaged SVG remains a CSS-safe data URL. Verify the affected icon in the consuming renderer rather than inferring success from unrelated icon paths.
- References: AgentGUIConversationRailItem.tsx agentactivity.css
Restored fullscreen window overflows after the host surface becomes smaller
- Symptom: A Workbench node was fullscreen before restart. After the room or workspace reopens on a smaller non-fullscreen host surface, exiting the node's fullscreen mode restores a window wider or taller than the Workbench desktop.
- Quick checks:
Inspect the persisted node's
displayMode,frame, andrestoreFrame. Compare both frames with the current Workbench safe layout, not the native Electron window bounds. Reproduce by dispatchingexitFullscreendirectly; if the first invalid state appears in the reducer, CSS clipping is only the final symptom. - Root cause:
Historical snapshots stored absolute node frames without the surface and safe
layout that produced them. Fullscreen restore recomputed the visible
fullscreen frame but kept a stale hidden
restoreFrame, then copied it directly back toframeon exit. - Fix:
Persist an additive snapshot
layoutBasis, map all durable frame-bearing state from the saved safe layout into the current safe layout during initial host restore, and clamp theexitFullscreentransition as a final invariant. Keep snapshots without a basis on a conservative bounds-only compatibility path. - Validation:
Cover a fullscreen snapshot restored from a larger basis to a smaller
surface, an old snapshot with no basis, and direct reducer exit with a stale
restore frame. Verify the OpenAPI and generated Go contracts retain
layoutBasisthrough the daemon boundary. - References: schema.json snapshotLayout.ts session.ts reducer.ts
Hidden workspace owner loads but its first IPC request times out
- Symptom:
A launcher creates a hidden standalone workspace or Agent window and Electron
reports
did-finish-load, but the first request to the renderer times out. Repeated launcher shortcuts may appear inert while the temporary surface is still waiting on that request. - Quick checks:
Compare the hidden window's load/ready timestamps with registration of the
React/preload request handler. A fixed timeout after
did-finish-loadwith no matching renderer response indicates that the request was sent before the application handler existed, not that the daemon or Agent provider was slow. - Root cause: Browser-window load readiness is a document boundary, while the request owner is installed later by a React effect. IPC delivery is not replayed for a listener registered after the message, so a one-shot request can be lost.
- Fix:
Have the preload announce readiness only after installing the request
listener, clear readiness when it detaches, reloads, crashes, or is destroyed,
and make the main-process caller await that signal before sending. Do not use
sleeps or
did-finish-loadas a substitute for capability readiness. - Validation: Assert that a pending main-process request remains blocked before the ready signal, proceeds afterward, and returns to pending when the listener detaches or the renderer reloads. Cover StrictMode's setup/cleanup replay so a listener removed in the same turn never announces stale readiness.
- References: workspaceAppRendererReadiness.ts workspaceAppExternal.ts
Embedded Composer loses model controls or locks the whole draft while options load
- Symptom: A launch surface built from Quick Composer has no model or reasoning control, or loading those options disables prompt editing, attachments, and project selection together with send.
- Quick checks:
Inspect the exact target/project composer-options response at the host
boundary. If it contains models and reasoning but Quick Composer receives
only a reduced image-capability flag, the adapter discarded presentation
authority. If the response is pending and
presentationEditorDisabledis true, loading was promoted into the whole-draft gate. - Root cause:
Quick Composer intentionally owns no option-loading lifecycle. A host that
drops
AgentActivityComposerOptionsmust fabricate an empty settings VM, and a host that maps its refresh flag to the top-leveldisabledprop also locks unrelated canonical Composer controls. - Fix: Pass authoritative options, controlled sparse draft settings, their change callback, and the loading flag as one all-or-nothing Quick Composer capability. Reuse the canonical Composer settings projection and menus, and expose only fields the embedding activation adapter preserves. During refresh, disable settings controls and submit only; reserve the whole-draft disabled gate for an actual host-level lock.
- Validation: Cover visible model and reasoning values, settings patch delivery, editable rich text and references during refresh, and disabled settings/send controls.
- References: AgentGUIQuickComposer.tsx DesktopCaptureWindow.tsx
Screenshot selection appears stuck and later opens duplicate floating Composers
- Symptom: The first valid screenshot region remains on the full-screen selector for several seconds. Retrying the shortcut or selection eventually produces two compact capture windows.
- Quick checks:
Correlate
screenshot shortcut activated,screenshot selection requested,screenshot composer presented, andscreenshot composer metadata readybycaptureId. One selection request followed by a longagent.composer_options.loadproves that pointer delivery succeeded and the native transition was incorrectly coupled to metadata. Two different IDs reaching presentation prove a cross-capture lifecycle race; repeated requests for one ID are an input single-flight failure. If the Composer appears only after Escape, verify thatcomposer presentedincludesfullscreenExit: "event"; logging presentation before that native event is a false-positive lifecycle boundary. - Root cause: The selector renderer waited for a selection IPC response, while Main waited for workspace-owned Agent metadata before leaving native full-screen. A user retry could create or revive another capture while the old asynchronous continuation had no active-window identity fence. Input coalescing alone cannot prevent that older continuation from presenting late. Independently, macOS simple-fullscreen exit is asynchronous: setting it to false and then immediately applying compact bounds leaves those bounds suppressed until the native transition completes, which an Escape key can accidentally trigger.
- Fix:
Enter a compact preparing stage on the first valid pointer-up and perform the
native full-screen-to-floating transition before awaiting metadata. Give each
capture a unique ID, coalesce selection within that capture, reuse a visible
active capture on repeated shortcuts, and require every asynchronous window
continuation to prove it still owns the active live capture. Destroying or
replacing a capture must invalidate that proof. On macOS, wait for the
leave-full-screenevent before applying compact bounds; do not treat the synchronous setter call or immediate fullscreen getter as completion. - Validation:
Defer composer metadata and assert that native presentation occurs first.
Supersede the capture both during the native transition and while metadata is
pending, and assert that neither path can continue. At runtime, one shortcut
and one selection should log one ID in the order
selection requested→composer presented→composer metadata ready, with presentation reportingfullscreenExit: "event"on macOS. - References: captureSelectionTransition.ts desktopCaptureService.ts desktopCaptureWindowController.ts
Frameless capture close action is inert and the next shortcut appears stuck
- Symptom: The floating capture Composer ignores its close button and Escape. A later global shortcut may focus nothing or appear to do no work.
- Quick checks:
Inspect Electron app-region ownership around the close control and record
keydown listeners in capture and bubble phases. Then inspect whether the main
process marked the active capture
closingwithout receivingclosed. - Root cause: Native drag-region hit testing can consume a descendant action before React, while portaled dropdowns can consume a bubble-phase Escape first. If cancellation then uses a preventable close path, the main process can retain a live window already marked as closing.
- Fix:
Make the expanding title strip the drag region and keep close as a sibling
no-drag action. Observe Escape at the window capture phase. Destroy the
ephemeral capture BrowserWindow on cancellation, clear ownership from its
closedevent, and replace any closing, destroyed, or hidden window on the next shortcut. - Validation: Verify close and Escape with model, Agent, project, and mention portals open, then invoke the global shortcut again and confirm a new capture window owns the request.
- References: DesktopCaptureWindow.tsx desktopCaptureService.ts