Skip to content

fix(desktop): isolate full composer state per tab (#4133)#4147

Open
JesonChou wants to merge 1 commit into
esengine:main-v2from
JesonChou:fix/4133-per-tab-composer-draft
Open

fix(desktop): isolate full composer state per tab (#4133)#4147
JesonChou wants to merge 1 commit into
esengine:main-v2from
JesonChou:fix/4133-per-tab-composer-draft

Conversation

@JesonChou

Copy link
Copy Markdown
Contributor

Problem

Fixes #4133. When multiple tabs are open, the composer input box content was shared across all tabs instead of being independent. Editing text (or attaching files, adding @-refs, pasting blocks, selecting past:chats sessions) in Tab A was visible
in Tab B and vice versa.

Root cause: The <Composer> component was a single instance without a key prop, so React preserved its local state across tab switches. The per-tab State map (TabStates) did not track any composer draft state.

Solution

Three-part fix:

  1. Per-tab draft storageState interface gains a draft: ComposerDraft field that persists all user-facing composer state (text, attachments, workspace @-refs, pasted blocks, session refs) keyed by tab ID.

  2. Force remount on tab switch<Composer key={activeTabId}> ensures React unmounts the old tab's composer and mounts a fresh instance for the new tab.

  3. Save on unmount, restore on mount — A useEffect cleanup saves the full ComposerDraft to per-tab state when the component unmounts. useState initial values read from the draft prop on mount. A reference-equality guard skips the save when nothing changed, avoiding wasted re-renders.

Design decisions

Decision Rationale
key={activeTabId} instead of useEffect on tabId Guarantees clean isolation — no state leaks between tabs. The saved draft is the only bridge.
ComposerDraft aggregate type Single field is simpler than 5 separate draft fields; setDraft is one dispatch call per unmount.
Reference-equality guard in cleanup cur.text === draft.text && cur.attachments === draft.attachments … — since every state is initialised from draft.*, untouched arrays share the same reference. Skipping the save avoids setDraft → bump() → re-render.
UI-only state (menus, drag, height) not persisted These are transient and cheap to reset; composerHeight already has its own layout-preference persistence.
setDraft passed directly (not inline arrow) useCallback-wrapped → stable reference → no effect re-trigger loop.

Files changed

File +/− Notes
desktop/frontend/src/lib/types.ts +28 New Attachment, WorkspaceReference, PastedBlock, ComposerDraft types
desktop/frontend/src/lib/useController.ts +15/−1 draft field, set_draft action, setDraft() export, reset guard
desktop/frontend/src/components/Composer.tsx +60/−23 Import types from types.ts, restore/save full draft, skip-noop guard
desktop/frontend/src/App.tsx +4 key={activeTabId}, draft={state.draft}, onDraftChange={setDraft}

Verification

  • ✅ 190 frontend unit tests pass (zero failures)
  • ✅ TypeScript compiles cleanly (tsc --noEmit)
  • ✅ Manually tested on Windows: multiple tabs each retain independent composer state (text, attachments, @-refs, pasted blocks) across tab switches

Previously only text was isolated per tab. This extends the draft mechanism to
all user-facing composer state: attachments, workspace @-refs, pasted blocks,
and past:chats session refs.

Changes:
- types.ts: Add Attachment, WorkspaceReference, PastedBlock, ComposerDraft
- useController.ts: draft field is now ComposerDraft (was string);
  setDraft accepts full draft object; emptyDraft constant for init;
  reset action explicitly spreads emptyDraft to avoid shared refs
- Composer.tsx: import types from types.ts (remove local definitions);
  restore all state from draft on mount; save all state on unmount
  via draftRef synced every render; skip save when nothing changed
  (reference equality against mount-time draft prop avoids wasted
  set_draft → bump() → re-render cycle on no-op tab switches)
- App.tsx: pass draft={state.draft} (was draftText)

key={activeTabId} forces clean remount per tab — all state is now
properly saved before unmount and restored on the next mount for that tab.

All 190 frontend tests pass, TypeScript compiles cleanly.
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) labels Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 多标签页并行开发时输入框内容共享而非独立

1 participant