feat(mac): native macOS app scaffold — SwiftUI + Liquid Glass, t3 server sidecar architecture#4
feat(mac): native macOS app scaffold — SwiftUI + Liquid Glass, t3 server sidecar architecture#4SergeSerb2 wants to merge 7 commits into
Conversation
SwiftPM executable + hand-assembled .app bundle (no Xcode required). Includes UIState shim: macOS 27 beta SDK makes @State a SwiftUIMacros compiler macro that Command Line Tools cannot expand; the underlying State<Value> struct is still public, so the shim delegates to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BackendService protocol decouples SwiftUI layer from transport so UI and T3Kit/SidecarKit can be built in parallel; AppModel is the single observable store consuming the backend event stream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generated from packages/contracts + packages/client-runtime + vendored effect rpc source; every claim cited to file:line. Covers envelopes, Ack backpressure, heartbeat, auth (wsTicket), all 66 RPC methods, encoding conventions, and reconnect semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts, settings Six feature modules against MockBackend: NavigationSplitView shell with glass toolbar/sidebar, chat timeline (streaming markdown, tool events, approval cards), glass composer, unified diff viewer with line gutters, checkpoint restore, settings scenes. Includes review fixes: approval resolve now clears the timeline card (double-submit guard), backend start no longer blocks event consumption, autoscroll pin resets on thread switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports DesktopBackendManager semantics to a Swift actor: bootstrap envelope over stdin (--bootstrap-fd 0; stdin closed after the line, matching Effect's endOnDone behavior confirmed in vendored source), readiness polling of /.well-known/t3/environment, exponential backoff restart (500ms→10s), SIGTERM+2s grace shutdown, rotating logs, node binary discovery with engines-range gating. Review fixes: readiness timeout now terminates the stalled child before restart (was orphaning it and crash-looping on the held port), start/stop reentrancy guarded by generation counter, multi-subscriber state stream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the wire protocol per docs/wire-protocol.md: _tag-discriminated envelopes with batch-frame decode, mandatory per-Chunk Ack backpressure, 5s app-level ping heartbeat, Interrupt, auth chain (bootstrap token -> access token -> wsTicket), typed models + T3Client facade for the v1 method subset, reconnect with fresh ticket and sequence dedup hooks. Review fixes: token exchange now form-urlencoded per HttpApiSchema.asFormUrlEncoded (was JSON — client could never connect), ClientProtocolError frames fail pending requests instead of being dropped, lenient Ack on undecodable Chunk frames so the server latch never wedges, forward-compat .other cases on stream event unions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RpcConnection->T3RpcTransport adapter, LiveBackend composing SidecarKit and T3Kit behind BackendService (--mock/SERGECODE_MOCK=1 keeps the mock), and a SERGECODE_LIVE_E2E-gated integration test that spawns the real server, runs the full auth chain, and creates a project + thread with the claudeAgent provider over dispatchCommand. Quit handling: raw SIGTERM/SIGINT route through NSApp.terminate via DispatchSourceSignal, and applicationShouldTerminate blocks on a detached backend.stop() bounded at 6s instead of .terminateLater — AppKit's reply-wait modal run loop services neither the main dispatch queue nor MainActor tasks, so an async reply can never be delivered (verified empirically; documented in AppDelegate). Review fixes: timelineReset event on re-subscribe so reconnects don't silently drop gap messages, authoritative final text on assistantCompleted, snapshot/tail dedup for activities and checkpoints, backoff on clean subscription termination. Verified: 47 T3KitTests + 13 SidecarKitTests green (incl. live E2E); app launches, reaches ready against the real server, and SIGTERM tears down both app and sidecar with no leaked processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dbed2f88db
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case .snapshot(let snapshot): | ||
| for shell in snapshot.projects { | ||
| projectsByID[shell.id] = mapProject(shell) | ||
| } |
There was a problem hiding this comment.
Propagate project updates from shell snapshots
When the shell subscription delivers its initial snapshot or later project changes, this path only mutates projectsByID inside LiveBackend and never emits anything that updates AppModel.projects. Because .ready is emitted before runSubscriptions starts consuming subscribeShell, AppModel.refreshAll() can run before this snapshot arrives; in that startup race the sidebar/New Session project list stays empty or stale until some unrelated manual refresh. Please notify the UI (or refresh after applying project shell changes) whenever projects are upserted/removed.
Useful? React with 👍 / 👎.
| for shell in snapshot.threads { | ||
| let thread = mapThread(shell) | ||
| threadsByID[thread.id] = thread | ||
| emit(.threadUpserted(thread)) |
There was a problem hiding this comment.
Remove threads absent from reconnect snapshots
On a fresh subscribeShell snapshot after a socket reconnect, the snapshot is the current thread list, but this code only upserts the threads it contains. If a thread was deleted/archived-away while the socket was down, no live threadRemoved event is replayed and the old threadsByID entry remains in the UI indefinitely. Reconcile threadsByID against the snapshot and emit removals for entries that are no longer present.
Useful? React with 👍 / 👎.
| case .threadReverted(let payload): | ||
| emit(.diffInvalidated(threadID: threadID)) |
There was a problem hiding this comment.
Reset diff state when a thread is reverted
When a checkpoint revert succeeds, this handler invalidates the diff but leaves currentTurnCount, checkpointsByThread, and checkpointRoutes pointing at the pre-revert highest checkpoint. In the common flow where the user restores an earlier checkpoint and the inspector refreshes, diff(threadID:) still calls getFullThreadDiff with the old turn count and the UI can keep showing stale checkpoints/diffs from turns that were reverted away. Update the tracked turn/checkpoint state from payload.turnCount before invalidating.
Useful? React with 👍 / 👎.
| for provider in providers { | ||
| providersByInstanceId[provider.instanceId] = provider |
There was a problem hiding this comment.
Drop provider instances missing from snapshots
The server-config snapshot/providerStatuses payloads are full provider lists, but this merge-only update leaves any provider instance that disappeared from the server in providersByInstanceId. After a provider instance is removed from settings or after reconnecting to a sidecar with a smaller config, Settings/New Session can still show and route modelSelection to the stale instance. Replace the dictionary from the snapshot, or explicitly remove ids absent from providers before emitting.
Useful? React with 👍 / 👎.
| guard !(seenActivityIDs[threadID]?.contains(activity.id) ?? false) else { return } | ||
| seenActivityIDs[threadID, default: []].insert(activity.id) | ||
| let at = WireDate.parse(activity.createdAt) ?? Date() | ||
| if activity.tone == .approval { |
There was a problem hiding this comment.
Only create approval cards for requested approvals
This treats every activity whose tone is .approval as an actionable approval request, but the server also records resolved/failed approval activities with the same tone. In threads that receive approval.resolved after a response, or when a snapshot already contains that resolved activity, the UI can recreate an approval card and route for a request that is no longer pending. Gate this on activity.kind == "approval.requested" before creating ApprovalRequest/approvalRoutes.
Useful? React with 👍 / 👎.
| case .diffInvalidated(let threadID): | ||
| Task { await refreshDiff(threadID: threadID) } |
There was a problem hiding this comment.
Refresh checkpoints on checkpoint-producing events
The live backend emits only .diffInvalidated when a turn creates a checkpoint, and AppModel responds by refreshing just the diff. If the Checkpoints inspector is already open while a turn completes, model.checkpoints[threadID] remains stale and the new restore point will not appear until the user manually refreshes or recreates the view. Refresh checkpoints here too, or add a dedicated checkpoint update event.
Useful? React with 👍 / 👎.
| } catch { | ||
| currentClient = nil | ||
| currentConnection = nil |
There was a problem hiding this comment.
Disconnect failed socket sessions before retrying
When getConfig or any subscription throws for a non-transport reason such as an RPC/decode error, this catch path drops currentConnection but never closes the local conn created for that attempt. The receive/ping loops and WebSocket can remain alive while the retry loop opens another authenticated socket, which can leak resources and leave old subscriptions running. Disconnect the failed connection before sleeping unless it is already closed.
Useful? React with 👍 / 👎.
| threadsByID[threadID] = thread | ||
| return thread |
There was a problem hiding this comment.
Emit the created thread before selecting it
After a successful thread.create, the live backend stores the new thread internally but does not emit .threadUpserted, while AppModel.createThread only sets selectedThreadID from the returned value. Until the shell subscription later echoes the thread, selectedThread is nil and the detail pane falls back to the empty state; if that echo is delayed or missed during reconnect, the newly created session appears broken. Emit the returned thread or have AppModel insert it immediately.
Useful? React with 👍 / 👎.
| guard let chosen = matching.first(where: { $0.isAvailable }) ?? matching.first else { | ||
| return nil | ||
| } | ||
| let model = chosen.models.first?.slug ?? "" | ||
| return ModelSelection(instanceId: chosen.instanceId, model: model) |
There was a problem hiding this comment.
Filter out unavailable provider selections
This selection path considers only isAvailable, ignoring the same installed and auth checks used for the provider list, and then falls back to "" when the provider has no models. If the user picks a provider kind whose only instance is missing, unauthenticated, or has an empty model list, createThread sends an invalid/unusable ModelSelection and the server rejects or creates a thread that cannot run. Choose only .available providers with a non-empty model, or surface noProviderForKind for the unavailable cases.
Useful? React with 👍 / 👎.
What
Native macOS app for SergeCode (
apps/mac): SwiftUI with Liquid Glass on macOS 26+, no Electron, no web view. The Nodet3server runs unchanged as a supervised sidecar. Electron app remains for Windows/Linux.Commits (reviewable per-component)
UIStateshim for the macOS 27 beta SDK's Xcode-only@State/@Entry/@AnimatablemacrosBackendServiceprotocol decoupling UI from transport--mock)node bin.mjs: stdin bootstrap handoff, readiness poll, backoff restart, SIGTERM+grace shutdownLiveBackend+ quit-path teardown (SIGTERM routing;.terminateLateris unusable — AppKit's reply-wait starves the main queue, documented in AppDelegate)Verification
SERGECODE_LIVE_E2E=1): spawns real server, full auth chain, real WebSocket RPC, creates project + thread with theclaudeAgentprovider, clean shutdown — repeated 3×readyagainst real server; SIGTERM tears down app + sidecar, no leaked processesKnown follow-ups (post-v1)
replayEvents-based reconnect gap recovery (currently timelineReset on re-subscribe)🤖 Generated with Claude Code