Skip to content

feat(mac): native macOS app scaffold — SwiftUI + Liquid Glass, t3 server sidecar architecture#4

Open
SergeSerb2 wants to merge 7 commits into
mainfrom
feat/native-mac-app
Open

feat(mac): native macOS app scaffold — SwiftUI + Liquid Glass, t3 server sidecar architecture#4
SergeSerb2 wants to merge 7 commits into
mainfrom
feat/native-mac-app

Conversation

@SergeSerb2

@SergeSerb2 SergeSerb2 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

What

Native macOS app for SergeCode (apps/mac): SwiftUI with Liquid Glass on macOS 26+, no Electron, no web view. The Node t3 server runs unchanged as a supervised sidecar. Electron app remains for Windows/Linux.

Commits (reviewable per-component)

  1. Scaffold — SwiftPM app buildable without Xcode (CLT only); UIState shim for the macOS 27 beta SDK's Xcode-only @State/@Entry/@Animatable macros
  2. Docs + model seam — ARCHITECTURE.md; BackendService protocol decoupling UI from transport
  3. Wire protocol spec — 991-line Effect-RPC wire spec generated from contracts + vendored effect source, every claim cited
  4. Liquid Glass UI — shell, chat timeline with streaming markdown, approvals, diff viewer, checkpoints, settings (works standalone via --mock)
  5. SidecarKit — spawn/supervise node bin.mjs: stdin bootstrap handoff, readiness poll, backoff restart, SIGTERM+grace shutdown
  6. T3Kit — Swift Effect-RPC client: envelope codec, Ack backpressure, heartbeat, auth chain (bootstrap token → access token → wsTicket)
  7. Live wiringLiveBackend + quit-path teardown (SIGTERM routing; .terminateLater is unusable — AppKit's reply-wait starves the main queue, documented in AppDelegate)

Verification

  • 60 unit tests green (47 T3KitTests + 13 SidecarKitTests)
  • Live E2E (SERGECODE_LIVE_E2E=1): spawns real server, full auth chain, real WebSocket RPC, creates project + thread with the claudeAgent provider, clean shutdown — repeated 3×
  • App launch: reaches ready against real server; SIGTERM tears down app + sidecar, no leaked processes
  • Every component adversarially reviewed by a separate agent before merge; all confirmed findings fixed (incl. a CRITICAL auth-encoding bug and an orphaned-sidecar leak)

Known follow-ups (post-v1)

  • Bundle a Node runtime into the .app (currently resolves system node, engines-gated)
  • Terminal, browser preview, PR review dialogs, Clerk/cloud auth, SSH/tailscale remotes
  • Sparkle auto-updates; Developer ID signing/notarization
  • replayEvents-based reconnect gap recovery (currently timelineReset on re-subscribe)

🤖 Generated with Claude Code

SergeSerb2 and others added 2 commits July 4, 2026 00:28
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>
@github-actions github-actions Bot added size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Jul 4, 2026
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>
@github-actions github-actions Bot added size:XXL and removed size:XL labels Jul 4, 2026
SergeSerb2 and others added 4 commits July 4, 2026 00:56
…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>
@SergeSerb2 SergeSerb2 marked this pull request as ready for review July 4, 2026 17:22

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +360 to +363
case .snapshot(let snapshot):
for shell in snapshot.projects {
projectsByID[shell.id] = mapProject(shell)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +364 to +367
for shell in snapshot.threads {
let thread = mapThread(shell)
threadsByID[thread.id] = thread
emit(.threadUpserted(thread))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +551 to +552
case .threadReverted(let payload):
emit(.diffInvalidated(threadID: threadID))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +400 to +401
for provider in providers {
providersByInstanceId[provider.instanceId] = provider

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +87 to +88
case .diffInvalidated(let threadID):
Task { await refreshDiff(threadID: threadID) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +290 to +292
} catch {
currentClient = nil
currentConnection = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +697 to +698
threadsByID[threadID] = thread
return thread

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +941 to +945
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant