feat(ai-chat): agent workspace phases 1 to 3, assistant mode, approval floor, session isolation - #2384
feat(ai-chat): agent workspace phases 1 to 3, assistant mode, approval floor, session isolation#2384J2TeamNNL wants to merge 41 commits into
Conversation
…pprovals per session
…der lease and tool scope
…s, results and schema changes
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 79f7c788b0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| else { return } | ||
| self?.providerWaitReason = ProviderStreamLease.waitMessage(providerName: leaseProviderName) | ||
| } | ||
| await ProviderStreamLease.shared.acquire(configId: leaseConfigId, sessionId: leaseSessionId) |
There was a problem hiding this comment.
Isolate Copilot conversation state per session
When two agent sessions use the same Copilot provider configuration, this lease only serializes their turns; it does not switch the cached CopilotChatProvider's conversationId. After session A releases the lease, session B therefore appends its prompt to A's server-side conversation, and subsequent turns from both sessions share context despite having separate local transcripts. Store Copilot conversation state per agent session or explicitly swap/reset it when ownership changes.
Useful? React with 👍 / 👎.
| "version": .string(Bundle.main.appVersion) | ||
| ]) | ||
| ]) | ||
| ) |
There was a problem hiding this comment.
Send the MCP initialized notification
For an outside MCP server that enforces the protocol lifecycle, completing initialize is not sufficient: the client must send notifications/initialized before issuing tools/list or tools/call. This method returns immediately after the initialize response, so such servers reject the following tools/list request and none of their tools are registered.
Useful? React with 👍 / 👎.
| return MCPClientSession( | ||
| configuration: configuration, | ||
| transport: MCPStreamableHttpClientTransport(credentialsProvider: provider), |
There was a problem hiding this comment.
Preserve negotiated MCP session IDs
When an outside Streamable HTTP MCP server returns an Mcp-Session-Id during initialization, this transport cannot preserve it: MCPStreamableHttpClientTransport exposes only response bodies and its later requests never include the negotiated header. Consequently the initialize request can succeed while the immediately following tools/list is rejected by any sessionful server. Use a client transport that captures the initialization header and sends it on subsequent requests.
Useful? React with 👍 / 👎.
| /// Sessions are listed again before any window asks for one, so a session whose window was | ||
| /// closed last run is in the rail from the start rather than appearing once its connection | ||
| /// happens to be opened. | ||
| Task { await AgentSessionRegistry.shared.restore() } |
There was a problem hiding this comment.
Finish restoration before creating sessions
On launch, this unstructured task does not actually ensure restoration finishes before a window or welcome action calls session(for:). If that happens while store.load() is suspended, the registry creates a new default session and later appends the stored session as well, leaving duplicate sessions for the same conversation in the rail and persisting both. Gate session creation on restoration completion or perform restoration before exposing the registry to UI actions.
Useful? React with 👍 / 👎.
| updatedAt: record.updatedAt, | ||
| approvals: approvals | ||
| ) | ||
| restored.append(session) |
There was a problem hiding this comment.
Reattach MCP tools for restored sessions
After relaunch, restored sessions never authorize or register their outside MCP tools. MCPRemoteToolCoordinator.attach is invoked only from makeSession, while this restore path constructs and appends sessions directly; reopening one through session(for:) returns the existing session without attaching it. Thus an allowlisted server's tools disappear from every restored session until the user creates a brand-new session.
Useful? React with 👍 / 👎.
… alert instead of the window
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
…tions-acdf docs: note macOS/Xcode-only build for cloud agents
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…er negotiates Claude-Session: https://claude.ai/code/session_01S9ckdzeugurfqNmDpGU2M7
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…he app's own type scale Claude-Session: https://claude.ai/code/session_01S9ckdzeugurfqNmDpGU2M7
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Co-authored-by: Nguyễn Nam Long <J2TeamNNL@users.noreply.github.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
All seven phases of the agent workspace plan. The assistant gets the whole window, every write it proposes waits for a human, sessions are isolated and run in parallel, the pane beside the conversation shows what the session actually did, the welcome window is a second way in, and a session can call an MCP server that is not TablePro.
What is here
f67878136rootViewon the three panesWorkspacePanesalready owns, rather than nesting a split view965fc20d6ApprovalRequestID(sessionId:toolUseId:), and a call is evaluated against the connection it targetsa9a35ad7e3001d2693AgentSessionRegistry, not in a windowb2435e4cc183c9093fbe5b966c6Phase 4, sessions are not owned by a window
The four process-global singletons phase 3 split up were still reached through a field on the window's right panel, so a session's lifetime was the window's.
AgentSessionRegistryholds them instead.RightPanelState.aiViewModelwas a creating getter read from inside SwiftUI bodies. It is nowsession, a read, plusstartSession(), which only a user action or an explicit.taskcalls. Opening a connection creates no session.teardown()used to callclearSessionData(), which emptiedmessages. Window close, disconnect and session loss all reach that path, so a transcript the user never asked to lose was gone from three ordinary places. It now stops the session: cancel, persist the partial turn, markstopped, and release only the derived context a reopened session rebuilds.ToolApprovalCenter,ProviderStreamLease) are outside the observation graph, so a rail row that asked them a question would render once and never update.applicationWillTerminatepersists every session and marks a working onefailed. Nothing persisted AI state at quit before, so a session killed mid-stream came back with its last turn missing. The transcript write needed a synchronous path (AIChatStorage.saveSync) because an actor hop at terminate may never be scheduled.Phase 5, the result pane
AgentArtifactProjectionis a pure function over the session's ownChatTurnhistory rather than a second observable store. That is what makes a restored session's pane correct with no replay: the transcript is what was restored, and there is one record of "waiting" instead of two that can disagree.Two things had to change underneath it:
resolveAndAwaitApprovalsappended every pending block at once but awaited them one at a time, so only the first had a continuation registered: a click on the third card hitresolve's missing-continuation guard and did nothing while the stream stayed parked on the first. Every waiting call now registers up front, andToolApprovalCenteralso buffers a decision that arrives before its turn..keyboardShortcut(.defaultAction), soReturnfired whichever button AppKit reached first. Only the first row still waiting takes it, resolved from the transcript through\.chatPrimaryPendingToolUseId.ExplainQueryChatToolwraps the server-side explain tool per decision 5, deliberately without itsanalyzeparameter: a.readOnlychat tool is auto-approved, andanalyzeruns the statement for real.DDLChangeReaderis certainty-or-raw-SQL, and it does not reuseQueryClassifier.strippingStringLiteralsbecause that treats a backticked identifier as a literal and removes it, which would lose the only objectDROP TABLE `order items`names.Phase 7, outside MCP servers
Answered the two blocking questions as the plan recommended: per connection and HTTP-only.
ext__<serverUUID>__, keyed on the id rather than the name, andtablepro,table-pro,table_proare reserved slugs. A server the user called "TablePro" would otherwise land insideClaudeAgentProvider's pre-approvedmcp__tablepro__*wildcard.computeInitialApprovalStateforces every remote call to.pending, checked ahead of the.readOnlyshortcut, in every chat mode, whateveraiAlwaysAllowedToolsholds. "Read-only" is the server's claim about itself.https.Bugs found and fixed along the way
Each of these was pre-existing, not introduced here:
aiViewModelwas a weak snapshot taken inonAppear, before a session existed.Returnacted on whichever approval button AppKit reached first.accessibilityDescription, which was nil.docs/scripts/check-writing-style.shfailed under a C locale, because its bracket expressions over non-ASCII glyphs match individual bytes there and every glyph it checks starts0xE2. It reported every ellipsis in the corpus as a modifier glyph.ImportFromAppSourcePickerhad alegacy_swiftui_aspect_ratioviolation onmain.Verification
Merged
upstream/main(8 commits, the Compare & Sync and routines work) into the branch; the only conflict wasCHANGELOG.md, resolved into one[Unreleased]block in canonical section order.** BUILD SUCCEEDED **after the merge.swiftlint lint --strictclean overTablePro TableProTests TableProUITests(5,187 files).docs/scripts/check-writing-style.shanddocs/scripts/check-docs-against-source.pyboth pass.New suites, all green:
AgentSessionRegistryTests(13),AgentSessionStatusTests(13),AgentSessionPendingPromptTests(6),AIChatPersistenceTests(5),AgentArtifactProjectionTests(15),DDLChangeReaderTests(14),ToolApprovalCenterOrderingTests(6),AgentLaunchRoutingTests(4),MCPServerConfigurationTests(8),MCPRemoteToolPolicyTests(12),MCPRemoteToolApprovalTests(5),MCPAuditChainVersioningTests(6).ConnectionWindowPaneResolverTestsextended with the mode matrix.CI
Unit tests,Package Tests,Validate docs,Lint workflows and scriptsandBuild for testingall pass.UI testsfails on all three shards, and the same tests fail onupstream/mainitself (run 32631046978,3848a21a1), so this is not a regression from this branch:testCompareSyncOpensFromFileMenutestCompareIsDisabledUntilBothEndpointsAreChosentestTargetPickerStartsWithNoConnectionChosentestSwapIsDisabledWhenNoEndpointIsChosentestBannerStatesNothingHasBeenWrittenBeforeAnyRuntestRunInNewTabOpensATabAndActuallyRunsTheQuerytestCommandDeleteDeletesTheEditorLineAfterSelectingAResultRowtestCommandReturnOpensTheResultInANewTabtestAFailedQueryShowsTheDatabaseErrortestHelpMenuOpensTheSampleDatabaseThe first five are
CompareSyncUITests, which arrived with the Compare & Sync window in3848a21a1and have never been green. The rest are the "sample database never finished opening" family, whose membership drifts run to run.Nothing in either list touches assistant mode, sessions, the result pane, the welcome panel or the MCP client.
Confirmed locally as well: the eight suites that touch a surface this PR changes all pass (18 cases across
SingleWindowMenuContractUITests,AuxiliaryWindowCloseUITests,TableProLaunchUITests,NewConnectionCommandUITests,DataSettingsUITests,SettingsWindowTitleUITests,ConnectionCloseUITests), and the only local failures are the same fiveCompareSyncUITests.CompareSyncUITestsis now quarantined (33525726f), with the root cause written into the entry:CompareSyncLauncher.opengates onLicenseManager.isFeatureAvailable(.compareSync)and callsNSAlert.runModal()when the licence is absent, which it always is underUITestCase.launchApp()'s throwaway container. The suite's ownguard item.isEnabled else { throw XCTSkip(...) }cannot fire, because the gate is in the launcher rather than in menu validation. The modal then holds the main thread, which is why cases after it in the same shard fail on unrelated assertions ("The sample database never finished opening", "Not hittable"): one licence gate takes several unrelated tests with it. That is worth fixing on main; it is not this PR's to fix.Not done
TableProUITestscoverage for assistant mode. Written and then withdrawn rather than landed: the mode control sits in the toolbar's overflow menu at the test window's width, and the suite has no AI provider, so an approval card cannot be reached at all. A suite that self-skips reads as coverage without being any. The deterministic parts it would have asserted (the pane's four views and their empty states, the approval ordering, the remote-tool gate) are covered by the unit suites above.The local-only unit failures
Worth recording, because the previous version of this description called the suite "red before this branch" with 33 failing entries, and CI says otherwise:
Unit testspasses on this branch in CI. The failures are specific to the machine I ran on, not to the branch.29 tests failed locally; one was a real test bug and is fixed. The other 28 are environment-sensitive and reproduce on that machine deterministically, in isolation, on
upstream/mainas well:79f7c788b):ValidateDriverDescriptorTests(2) asserted"MySQL"was already claimed "by the built-in MySQL plugin". Nothing claims it under XCTest, becauseapplicationDidFinishLaunchingreturns early whenXCTestConfigurationFilePathis set, so no plugin ever loads anddriverPluginsis empty. The duplicate check the tests exist to prove had nothing to collide with. The tests now seed the occupant themselves.StructureChangeManagerUndoTests(3).StructureChangeManager'sUndoManagerleavesgroupsByEventat its defaulttrue, so undo granularity is decided by run-loop boundaries: the same two column edits are one undo step or two depending on when the loop turns.multipleUndospasses as a single test, fails with its suite, and.serializeddoes not help. CI's timing happens to fall the right way. The fix is to make each mutation an explicit undo group instead of depending on the run loop, which changes Structure-tab undo behaviour and wants its own PR.AWSSSOFetchTests(7),SSEEventStreamTests(3),SaveCompletionTests(3),DataChangeManagerExtendedTests(2),MCPHttpServerTransportTests/MCPHttpKeepAliveTests/MCPHttpServerTransportPairingTests(3, ports),SequelAceImporterTests/TablePlusImporterTests(2, these read for other apps' files on disk),SchemaColumnStoreCancellationTests/ScopedDriverCancellationTests(2),SQLCompletionProviderTests(1),SSHMatchExecutorTests(1).Rework pass
mainmerged in (44 commits), then the branch reworked against its own review findings. The phase structure,AgentSessionRegistry, the purity ofAgentArtifactProjectionand the pane resolver are unchanged: what follows is defects in the code around them.The merge
One semantic conflict, not a textual one.
maingaveConnectionWindowPanea.preparingcase for the sub-grace connect (#2609's launch work), andshowsPreConnectAssistanthad no arm for it. Assistant mode takes.preparingtoo: the grace exists to keep a progress indicator off screen for a wait too short to report, and the assistant surface is not one, it carries the prompt the user typed. Withholding it drew nothing for the grace and then flashed the conversation in.Outside MCP servers: a client of its own
The client reused
MCPStreamableHttpClientTransport, which exists to talk to TablePro's own bridge. Against somebody else's server that was wrong in six ways, so the outside client now has its own transport (MCPRemoteServerTransport):notifications/initializedwas never sent. The specification has the client send it once the initialize response is in, and a server that holds itself to the lifecycle refusestools/listuntil it arrives. TablePro listed no tools at all on exactly the servers that implement the protocol most carefully.Mcp-Session-Idwas never captured or resent. Nothing read the initialize response head and no request carried the header, so every sessionful server answered404to everything afterinitialize. It is now captured, sent on every later request, and an expired session re-initializes once and retries rather than failing the call.didInitialize = truewas set before the round trip, so a failed initialize left the session permanently marked handshaken and every later call ran against a server that never handshook. The flag is now the handshakeTaskitself: a second caller awaits the first one's, and a throw clears it.Mcp-MethodandMcp-Namemean nothing to another server, and an unreachable one reported "TablePro's MCP server is not reachable. Make sure TablePro is running and the MCP server is enabled in Settings > Integrations.", unlocalized, about somebody else's machine.tools/listwas read one page deep. It is paginated, so a server with more tools than it sends at once answers with anextCursor, and stopping there offered the model a subset of what the server has with nothing anywhere saying so. Pages are followed now, bounded so a server that always returns a cursor cannot loop.The transport is request-and-response rather than fire-and-forget, which is what the specification actually describes, so the JSON-RPC id correlation, the reader task and the per-call deadline task all go. It also reads the body incrementally: the specification says a server SHOULD close its event stream after the response, and against one that does not, buffering the whole body meant every call sat until the timeout having already been answered.
Auth is unchanged on purpose. A server with no stored token is still not called.
Sessions
TaskinapplicationDidFinishLaunching, so a window opened beforestore.load()resumed found an empty list, minted a session, and was then joined by the stored one: two sessions on one conversation, both persisted, both in the rail.AgentSessionStore.load()is nownonisolatedand restore is synchronous and runs before any window exists, which closes the window by construction. Measured on the record shape it reads: 0.08ms for ten sessions, 0.6ms for two hundred, against a 261ms launch.makeSessioncalledMCPRemoteToolCoordinator.attach, so an allowlisted server's tools were missing from every session that came back from disk.attachregistered tools after anawait, so adetachAllduring the listing left them in the registry pointing at a closed transport, with nothing tracking their names to unregister.One Copilot conversation per session
AIProviderFactorycaches one provider per configuration andCopilotChatProviderheld a singleconversationId, so two sessions on one Copilot configuration appended their turns to one server-side conversation. Each was answered with the other's context, across connections included, while their local transcripts stayed correctly separate.ProviderStreamLeasecannot help: it serializes turns and never swaps what the provider points at.Conversation state is now keyed by session, and
resetConversation/deleteLastTurnname a session as well as a configuration. This one is not new to this branch, so it has its own CHANGELOG entry and the limitation it documented is gone fromassistant-mode.mdx.The result pane stopped reparsing the transcript at 20Hz
AgentArtifactPaneView.artifactis computed on every render so it can never disagree with the transcript, which is the right call. Butmessagesis rewritten every 50ms while a reply streams, and each pass ranQueryClassifier.classifyTierand the whole ofDDLChangeReader.previewover every statement the conversation had ever proposed, so the cost of drawing the pane grew with the conversation and was paid twenty times a second.The projection is still pure. What a statement means is memoized on the statement and the engine, which is everything that analysis reads; the state, the order and the per-call id are still recomputed every pass. Streaming text changes neither key, so the cache answers every pass between one tool call and the next.
Native and HIG
Textinside a strokedRoundedRectangleimitating anNSTextField, beside a send button with an empty action. It now usesAIChatMessageViewandChatComposerView, the same two the connected panel uses, so the surface does not change appearance the instant the connection lands. The prompt is editable while the connection is being made, which a connect long enough to notice a typo in needs; what is typed is whatsendPendingPromptIfReadysends.NSMenuItemas the sender, and the action could only read a selection off a group, so choosing Browse or Assistant from the overflow menu was inert at the ordinary window widths where the control lives there. The menu form is now built explicitly with each item carrying its segment intag, and the tick follows the same sync pass the segments do..buttonStyle(.link); a link style says the control navigates.+in the bottom bar, the shape a source list uses, instead of a full-width.plainbutton with no press, hover or focus ring.@State, so a reader who opened Schema to check aDROPis not returned to SQL by looking at another connection and coming back.The surface itself, against the app's own conventions
Measured against
QueryInsightsGroupList, which is the app's other list of statements with metadata under them, and against the HIG.Typography was a step small throughout. The house scale is statements at
.system(.callout, design: .monospaced)and metadata at.caption, with 3pt between a row's lines and 7pt around it. The result pane set SQL at.captionand metadata at.caption2, so one statement read as content in Query Insights and as a footnote here. Every.caption2is gone.Four hand-built controls are native ones now.
HStacks in a stroked rectangle, and worse than it looked: each cell tookminWidth: 60independently, so columns did not line up between rows, and one long value shifted everything to its right on that row alone. It is aGrid, which sizes a column once from every cell in it. (Tablewould bring resizable headers and selection, butTableColumnForEachneeds macOS 14.4 against a 14.0 target.)ScrollViewof.plainbuttons with no selection, hover, keyboard navigation or row semantics. It is aList.arrow.up.circle.fillwith.plain: no border, no press state, no focus ring, no name. It is an Ask push button taking.defaultAction..red.opacity(0.15), where the app also had0.08,0.16and0.18for the same idea and none of them tracked system contrast. A sharedStatusBadgeusesColor.red.quaternary, sibling toTypeBadge.Cancel, Browse database, Open as Query and Remove were
.buttonStyle(.link); a link says a control navigates to content. The Confirm Writes notice sat above the divider and moved the whole pane by its height whenever the mode changed; it is a bottom bar the pane supplies, with the notice itself left neutral because the chat composer shows it too. The rail's empty state offers New Session rather than describing where the button is. Right-click now offers Copy on statements, schema changes and results.Accessibility. The result grid published a flat run of
Textin which a value carried no column; rows read "column: value" now. Status icons in the plan, schema, SQL and rail rows were unlabelled images, so a row's state was drawn and never spoken. A turn proposing three writes gave three identical "Run" buttons to VoiceOver and Voice Control; each names its tool while the visible title stays the verb.Two defects in the outside-server settings
probetakes the token explicitly now and builds a throwaway session.MCPClientSession.makerefuses an endpoint with no credential, so the entry could be ticked onto a connection and would never answer. Add requires it, as Test already did.Remove also asks first: it deletes a Keychain token and every connection's permission, and none of it comes back.
A queued prompt could be dropped
AgentSessionLaunchersetspendingPromptand routes, and the only flush was insideadoptSession, which runs when a connect lands. A connection whose window is already open and connected changes nothing about its session, so nothing adopted it and the queue was never read;setContentModedid not rescue it either, because it returns early when the workspace is already in assistant mode. Asking about the database already on screen queued the text and dropped it silently. The flush now also runs fromapplyContentModeand from the launcher's already-hosting path, and becausesendPendingPromptIfReadyclears before it dispatches, three call sites still send once.Tests
New:
MCPClientHandshakeTests(12, over aURLProtocolstub: the initialized notification, session id capture and resend, the stateless case, a failed handshake retrying, an expired session recovering, a response read out of an event stream, a stream the server never closes, and no bridge headers or TablePro error copy reaching an outside server),MainWindowToolbarContentModeTests(5, the menu form's items, their tags, targets and names),AssistantModeSwitchUITests(2, what the control publishes to assistive clients), plus a case pinning that every prompt-flush site sends once.Extended:
AgentArtifactProjectionTests(+4 over what a memo can get wrong),ConnectionWindowPaneResolverTests(+1 for.preparing).XCUIElement.waitToBeHittable(timeout:)joinswaitToExist: an element mid-animation exists and hit-tests to nothing, so a click on it lands somewhere else.Switching mode is not driven from a UI test, because XCUITest cannot drive it. Measured rather than assumed, against a dumped accessibility tree: the group publishes as a radio group of radio buttons (not buttons, which is why an earlier attempt found nothing), the Assistant segment reports
existsandisHittable, andclick()leavesisSelectedfalse with the window unchanged. AppKit does not route a synthetic click to a segment inside a toolbar item group, and the mode has no menu command to reach it by instead. Rather than land a suite that self-skips, the UI test asserts what a UI test can see, and the switch itself is covered byConnectionWindowPaneResolverTests,MainWindowToolbarContentModeTestsandMainWindowToolbarValidationTests.contentModeFollowsTheSession.Found while in here, not fixed
All three are pre-existing on
main, none of them is this branch's to fix, and each is small.MainWindowToolbar.makeSidebarSegmentGrouppassesaccessibilityDescription: nilforlist.bulletandstar, so the two segments announce as "List" and "favorite". Confirmed in the dumped accessibility tree. This is the same defect90c3b58f9fixed for the mode control, on the control next to it.sidebarSegmentChanged(_:)returns unless the sender is anNSToolbarItemGroup, so choosing Tables or Favorites from the toolbar overflow does nothing. Identical in shape to the mode-control defect fixed here, and the fix is the same shape too.MainSplitViewController.toggleContentMode()exists and nothing calls it: there is no View menu item and no shortcut, so the toolbar control is the only way to switch, which is also why the switch cannot be UI-tested. Wiring it is a product decision about placement and shortcut rather than a defect fix.Considered and left alone
AssistantSafeModeFloor.isActiveanswers a live security question fromWorkspaceContentModeStore, which is a UserDefaults mirror rather than the liveConnectionWorkspace.contentMode. It is correct as it stands: thecontentModedidSetwrites the store synchronously, so the two cannot diverge for a hosted connection, and a connection no window hosts reads its last mode, which errs toward the floor being on. Reading the live workspace instead would need a third record of one fact for no behavioural gain, and getting it wrong turns a write gate off.https://claude.ai/code/session_01S9ckdzeugurfqNmDpGU2M7