Skip to content

feat(workbench): switch conversation and editor tabs by worktree - #1039

Merged
xintaofei merged 17 commits into
spacering-net:mainfrom
fengpanhe:workbench
Oct 27, 2026
Merged

xintaofei merged 17 commits into
spacering-net:mainfrom
fengpanhe:workbench

Conversation

@fengpanhe

Copy link
Copy Markdown

Title: feat: add worktree-scoped Workbench mode

Summary

Add an optional Workbench mode that switches conversation and editor tabs together when selecting a worktree folder.

  • Add a Workbench toggle to the status bar, disabled by default.
  • Restore each worktree’s selected conversation and editor tab when switching back.
  • Preserve unsaved changes, editor undo history, and background conversation runtimes.
  • Scope tab closing, reordering, and preview replacement to the active worktree.
  • Keep normal mode available with all open tabs visible.

This change is client-only. Server APIs, persistence schemas, and build configuration remain unchanged.

Screenshots

Before — normal mode After — Workbench mode
image image

Validation

  • 720 test suites / 10,694 tests passed.
  • ESLint and TypeScript checks passed.
  • Next.js static export passed.
  • Regression coverage includes worktree switching, shared dirty buffers, asynchronous file opening, mobile runtime retention, and scoped tab operations.

@fengpanhe

Copy link
Copy Markdown
Author

@xintaofei 辛苦看下这个 worktree 工作台界面的改动,可以把会话 tab 和 编辑器 tab 跟随所属的 worktree 进行隔离,左下角有一个工作台的开关来切换到隔离状态。

就我来说,因为 tab 分不出是哪一个项目/worktree,我很少通过 tab 来切换会话,一般都走侧边栏,这样 tab 和 编辑器都 worktree 隔离可以避免杂乱,误操作。

…nt across worktrees

- Turning Workbench on keeps the current tab and per-group picks; only a
  worktree switch restores a remembered selection, and leaving the mode
  forgets them.
- The active folder follows the active tab inside a scope, so a chat
  conversation keeps its hidden chat folder (chat-mode chrome, terminal);
  an empty scope still owns the folder chrome.
- Closing the active tab focuses its neighbour among the worktree's own
  tabs and groups instead of jumping to the first tab.
- A scope whose folder leaves the workspace moves to the parent repo, a
  remaining tab's worktree, or the first open folder.
- The composer's folder picker moves its own draft (typed prompt and all)
  and the scope follows it once the retarget lands; "inherit from active"
  reads the tab the user is on before any scope switch.
- The tab strip offers only on-screen groups as split targets.
The separate disclosure button exists only in Workbench mode, where the
row button selects the worktree; in normal mode the row button stays the
disclosure control with its inline chevron and explanatory comment.
…tate updaters

- "Close others" / "close all" act on the worktree's references in one
  operation: buffers another worktree still holds only leave this scope,
  the rest close behind a single confirmation, and cancelling closes
  nothing.
- Scope references of closed buffers are dropped after the removal commits
  (including suspended duplicates and dormant-profile duplicates), so a
  replayed updater can no longer orphan a buffer; the selection moves to
  the scope-order neighbour without flashing the first tab.
- Restored browser tabs register their scope outside the updater.
- An open aimed at another worktree no longer takes the current scope's
  selection through a shared browser tab; turning Workbench on keeps the
  file the user is looking at.
Choosing chat mode in the composer's folder picker now carries that draft
(typed prompt and all) into the global scope, like a folder switch does,
and a new chat inherits the agent of the tab the user is on rather than
the global scope's selection.
…ving Workbench

- Restored pages join their folder's scope after the merge commits, and
  any committed buffer that no scope references is adopted, so a page
  restored while a queued close removes its duplicate is never invisible.
- Turning Workbench off keeps the file the scope was showing.
… View options

The PR named its mode "Workbench", which the app already uses for the
route surface (`WorkbenchRoute`). The mode makes a window's conversation
and file tabs follow the selected folder, so the store, helpers, tests
and stored preference now say that (`folder-tabs-store`,
`isTabInScope`, `TabScope`, `workspace:tabs-follow-folder:*`).

The switch leaves the status bar for the sidebar's View options menu,
next to the other display choices, since the sidebar is where folders
are selected while it is on.
…der again

Three file-open callbacks defaulted their scope with a call in the
parameter list (`scope = openingScope(...)`). The React Compiler cannot
reorder that, so it skipped the whole `WorkspaceProvider`, and every
compiler lint rule went quiet there. That is why two
`set-state-in-effect` suppressions had started reporting as unused and
were deleted. The default now runs in the function body, the provider
is analyzed again, and both suppressions are back where they were.
The preference is stored per backend and read once, at hydration, so a
window opened earlier kept the old mode until it was reloaded. Once a
window has hydrated it now listens for the stored key to change and
follows it, re-reading the key when the event arrives (a queued event
may carry an older value) and keeping its own folder.
…e folder

All folders shared one split tree, so a folder whose tabs filled only part
of it lost its resize handles, and unsplit-all, dissolve and orientation
changes reached into other folders' arrangements.

Each tab view now has its own group state (layout, assignments,
selection, tile flags): one for normal mode and one per folder. The view
on screen lives in the store's group fields; a folder switch parks it in
`groupViews` and loads the next. A folder's first view is the normal
layout narrowed to its tabs under the same group ids, so turning the mode
on moves no tab. Turning it off restores the normal layout. Hidden tabs
keep rendering in their own view's group shell, and shells come in a
fixed id order, so a folder switch never reparents or moves a live
conversation. Folder views persist in the group blob under `views`.
The top level stays the normal-mode view, and the views parked from the
last session are dropped if the stored mode comes back off.
A draft moved by the composer's folder or chat-mode picker kept its
prompt but could land beside the target folder's own draft, so the
folder showed two "New Conversation" tabs. Turning the mode off did the
same, putting every folder's drafts back into one group.

The moved draft now closes the blank drafts sharing its group in its new
folder, including when the user moved on before the move landed.
Turning the mode off keeps one draft per group: the active one, else one
with text, else the first. A draft whose composer holds text is never
closed, so two drafts can remain but no prompt is lost.
While tabs follow the folder, selecting a folder with no conversation
open left a blank column: no tab strip, no "+", and nothing to say which
folder was on screen, since the sidebar marked it only with
`aria-current`.

The unsplit strip now stays up for an empty folder, with its
new-conversation button (and no maximize, since there is nothing to
maximize). The column shows which folder is empty and offers a new
conversation there, or a new chat for the chat scope. Nothing is opened
unasked, so visiting folders still collects no drafts. The folder on
screen also gets the selected-row tint the conversation cards use.
…s split before its tabs load

Review follow-ups to the folder views and draft tidying:

- A draft counted as blank when it had no saved text, but the composer
  saves on a 300 ms debounce and never saves image attachments. Mounted
  composers now report unsaved content per draft key, and a draft with
  any is kept. The draft on screen is never closed either: the user may
  have switched to it while a moved draft was on its way.
- A folder view projected before the tab set was known (the first
  snapshot failed) dropped the assignments of conversations not loaded
  yet, so their split collapsed when they arrived. The projection now
  keeps everything until the set is known, and the invariant pass
  narrows it.
The composer reported what it holds from an effect, after React
committed it, and a dropped or pasted image counts only once it has
been read. So a draft could look blank to the tab store's tidying for a
moment after a file intake began, or for as long as an upload ran, and
be closed with the file still on its way.

The attachments hook now counts each intake (read, upload, native
picker, clipboard read, OS drop) the moment it starts and calls the
host's `onIntakeStart` synchronously. The composer marks its draft key
as holding content right then, and clears that mark from its committed
state once the last intake has settled.
Review follow-ups to the composer's intake reporting:

- The context-menu paste (and its Ctrl/⌘+Shift+V shortcut) reads the
  clipboard before it hands any image to the counted intake, so a draft
  looked blank while that read was pending. The whole paste is now one
  intake, through the hook's new `beginIntake` for hosts.
- An intake that starts and settles before React renders leaves
  `intakePending` false both times, so the composer's report never re-ran
  and the mark set at the start kept a blank draft protected for good.
  The hook now also bumps `intakeRevision` on every start and settle,
  and the composer re-reports on it.
@xintaofei

Copy link
Copy Markdown
Collaborator

codeg work task 439 is done — #1039 (272 files, +43116/-5167).

@xintaofei
xintaofei merged commit fa13205 into spacering-net:main Oct 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants