Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c994e95
feat(mac): scaffold native macOS SwiftUI app with Liquid Glass
SergeSerb2 Jul 4, 2026
03a1c92
feat(mac): architecture docs + UI/backend model seam
SergeSerb2 Jul 4, 2026
cb1b772
docs(mac): wire-level Effect-RPC protocol spec for Swift client
SergeSerb2 Jul 4, 2026
6af83cb
feat(mac): Liquid Glass UI — shell, chat, approvals, diffs, checkpoin…
SergeSerb2 Jul 4, 2026
af563be
feat(mac): SidecarKit — spawn and supervise the t3 Node server
SergeSerb2 Jul 4, 2026
49cb0d2
feat(mac): T3Kit — Swift Effect-RPC WebSocket client
SergeSerb2 Jul 4, 2026
dbed2f8
feat(mac): LiveBackend wiring + lifecycle teardown + live E2E test
SergeSerb2 Jul 4, 2026
dd138fa
feat(mac): core chat parity — user-input prompts, plan/runtime modes,…
SergeSerb2 Jul 4, 2026
85eea92
feat(mac): composer power — @-file-mentions, image attachments, slash…
SergeSerb2 Jul 4, 2026
9a6d67b
feat(mac): settings CRUD, provider refresh/update, archived-thread ma…
SergeSerb2 Jul 4, 2026
a8a8ca8
feat(mac): git/VCS integration — live status, branch ops, stacked git…
SergeSerb2 Jul 4, 2026
ab02e57
feat(mac): workspace file browser, file preview, open-in-editor
SergeSerb2 Jul 4, 2026
dcc25f7
docs(mac): record v2 feature-parity scope in ARCHITECTURE.md
SergeSerb2 Jul 4, 2026
01641a0
fix(mac): make Finder/open launches actually connect
SergeSerb2 Jul 4, 2026
6d47892
feat(mac): native folder picker for new projects; stable signing iden…
SergeSerb2 Jul 4, 2026
6537fc0
fix(mac): address review findings — snapshot reconciliation, approval…
SergeSerb2 Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mac/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build/
dist/
130 changes: 130 additions & 0 deletions apps/mac/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# SergeCode for macOS — Architecture

Native macOS client for the t3 server. SwiftUI with Liquid Glass (macOS 26+),
no Electron, no web view. The existing Node server (`apps/server`, npm package
`t3`) is kept unchanged and runs as a supervised child process.

```
┌───────────────────────────────────────────────┐
│ SergeCode.app (SwiftUI, macOS 26+) │
│ │
│ SergeCodeMac (app target) │
│ • Liquid Glass UI: sidebar, chat, diffs │
│ • AppModel: observable session state │
│ T3Kit (library) │
│ • Effect-RPC-over-WebSocket client │
│ • Codable models mirroring @t3tools/contracts│
│ • Reconnect supervisor (backoff, resubscribe)│
│ SidecarKit (library) │
│ • Spawns `node dist/bin.mjs --mode desktop` │
│ • Bootstrap JSON over stdin (--bootstrap-fd 0)│
│ • Readiness poll, crash restart, shutdown │
└──────────────┬────────────────────────────────┘
│ ws://127.0.0.1:<port>/ws + local HTTP
┌──────────────▼────────────────────────────────┐
│ t3 server (Node, unchanged) │
│ providers: codex / claude / cursor / opencode │
└───────────────────────────────────────────────┘
```

## Why this shape

- The server is the product's brain (orchestration engine, provider drivers,
SQLite persistence). The Electron app already runs it as a supervised child
(`apps/desktop/src/backend/DesktopBackendManager.ts`); we reuse that exact
contract from Swift instead of porting Effect-TS to Swift.
- `apps/mobile` proves a non-browser client works: it speaks the same
`WS_METHODS` effect-rpc contract via `packages/client-runtime`. T3Kit is the
Swift equivalent of `client-runtime`'s Primary connection path.

## Sidecar contract (from apps/desktop + apps/server)

- Spawn: `node <server>/dist/bin.mjs --mode desktop --bootstrap-fd 0`,
loopback host. Write ONE line of JSON (schema
`packages/contracts/src/desktopBootstrap.ts` `DesktopBackendBootstrap`,
includes a random `desktopBootstrapToken`) to stdin before anything else.
- Readiness: poll `GET /.well-known/t3/environment` (100ms interval, 60s cap).
- Auth: with mode=desktop + loopback bind, server policy is
`desktop-managed-local` — exchange the bootstrap token for a session/bearer
token via the local HTTP auth API. No Clerk, no pairing, no DPoP in v1.
- Shutdown: SIGTERM, 2s grace, then SIGKILL. Restart on crash with
exponential backoff (500ms → 10s).
- Data dir: pass explicit `--base-dir` under
`~/Library/Application Support/SergeCode/` so we never collide with an
Electron install's state.
- PATH repair is the server's job (`os-jank.ts fixPath()`); the app just
spawns node. Dev builds locate node via `/usr/bin/env node` (engines:
^22.16 || ^23.11 || >=24.10); bundling a Node runtime into the .app is a
post-v1 packaging task.

## Wire protocol

See `docs/wire-protocol.md` (generated from packages/contracts +
packages/client-runtime). Effect-RPC over a WebSocket at `/ws`: JSON request
envelopes with tag/id/payload, streamed responses for subscriptions.
T3Kit hand-ports the message shapes as Codable structs; contract drift is
caught by `Tests/T3KitTests` fixtures copied from the TS side.

## Scope

v1 (core parity): project/thread sidebar, chat timeline (turns, streaming
tokens, tool events), composer, approvals, diff panel, checkpoints,
provider status, light/dark.

v2 (feature parity — landed): user-input prompt cards, plan mode +
proposed-plan cards with implement action, runtime-mode picker, model
picker, context-window meter, live plan-progress (todo) inspector tab,
composer @-file-mentions (projects.searchEntries), image attachments
(inline base64 dataUrl), slash-command menu, editable settings
(server.getSettings/updateSettings), provider refresh + CLI update,
archived-thread management, git/VCS (subscribeVcsStatus, branch
switch/create, pull, stacked commit/push/PR actions with outcome banner,
PR links), workspace file browser + preview + open-in-external-editor.

Out (excluded or later): embedded terminal (needs a SwiftTerm dependency —
Package.swift change to coordinate), browser preview/automation, PR review
dialogs, Clerk/T3 Connect cloud auth + relay + pairing (t3-service
exclusive, permanently out for the fork), SSH/tailscale remotes,
keybindings editor, auto-update (Sparkle later), Windows/Linux (Electron
app remains for those).

## Liquid Glass usage

macOS 26+ only, no fallbacks: `glassEffect(_:in:)` on floating surfaces
(composer, approval sheets, toolbars), `GlassEffectContainer` for morphing
groups, `.buttonStyle(.glass)`, scroll-edge effects on sidebar/timeline.
Content layers (chat text, diffs) stay opaque for readability; glass is for
chrome, never for long-form reading surfaces.

## Build without Xcode

Only Command Line Tools are required. `scripts/make-app.sh` runs
`swift build` and assembles `dist/SergeCode.app` (hand-written Info.plist,
ad-hoc codesign). CRITICAL: the macOS 27 beta SDK defines SwiftUI `@State`,
`@Entry`, `@Animatable` as compiler macros in a `SwiftUIMacros` plugin that
ships only with Xcode. Rules for all Swift code in this package:

- Use `@UIState` (Sources/SergeCodeMac/Support/StateShim.swift) — never `@State`.
- Custom environment values: manual `EnvironmentKey` conformance — never `@Entry`.
- Manual `Animatable` conformance — never the `@Animatable` macro.
- `@Observable` (ObservationMacros) works and is the preferred store pattern.

### Code signing + TCC (why Finder launches need a stable identity)

The node sidecar (and the repo checkout it loads from) usually lives under
`~/Documents`, which is TCC-protected. macOS keys the Documents-folder grant
to the app's code identity; an ad-hoc signature produces a new identity on
every rebuild, so the grant never sticks — and while consent is unresolved
the sidecar's very first `open()` of `bin.mjs` blocks in the kernel, which
presents as "app launches but stays on Launching Server… forever" (terminal
launches are unaffected because they inherit the terminal app's grant).

One-time setup for a stable local identity (`make-app.sh` picks it up
automatically and falls back to ad-hoc with a warning):

1. Generate + import a self-signed codesigning cert named
`SergeCode Dev Signing` (openssl req/pkcs12 + `security import`).
2. Trust it for code signing:
`security add-trusted-cert -p codeSign <cert.pem>` (approve the prompt).
3. Rebuild with `scripts/make-app.sh`; on first Finder launch approve the
"SergeCode would like to access files in your Documents folder" dialog.
23 changes: 23 additions & 0 deletions apps/mac/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# apps/mac — agent rules

Native SwiftUI macOS app (macOS 26+ Liquid Glass). Read ARCHITECTURE.md first.

Hard rules (build breaks otherwise — no Xcode on this machine, CLT only):
- NEVER use `@State`, `@Entry`, or `@Animatable` — the macOS 27 beta SDK makes
them Xcode-only compiler macros. Use `@UIState`
(Sources/SergeCodeMac/Support/StateShim.swift), manual `EnvironmentKey`
conformances, and manual `Animatable` conformance instead.
- `@Observable`, `@Binding`, `@Environment`, `@Bindable`, `@StateObject` are fine.
- Build/test: `swift build --package-path apps/mac`. Tests need the swift-testing
macro plugin passed explicitly (CLT keeps it in a subdir the compiler doesn't
search): `swift test --package-path apps/mac -Xswiftc -plugin-path -Xswiftc
/Library/Developer/CommandLineTools/usr/lib/swift/host/plugins/testing`.
App bundle: `apps/mac/scripts/make-app.sh`.
- Live E2E (spawns real server): prefix with `SERGECODE_LIVE_E2E=1`, filter
`LiveIntegrationTests`.
- Concurrency: Swift 6 strict mode. UI types `@MainActor`; T3Kit/SidecarKit
internals actor-isolated.
- Do not edit Package.swift without coordinating — target layout is fixed
(T3Kit, SidecarKit, SergeCodeMac + test targets).
- Liquid Glass: glass for chrome (toolbars, composer, sheets); never behind
long-form text (chat bodies, diffs).
34 changes: 34 additions & 0 deletions apps/mac/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// swift-tools-version: 6.1
import PackageDescription

let package = Package(
name: "SergeCodeMac",
platforms: [
.macOS("26.0")
],
targets: [
.target(
name: "T3Kit",
path: "Sources/T3Kit"
),
.target(
name: "SidecarKit",
path: "Sources/SidecarKit"
),
.executableTarget(
name: "SergeCodeMac",
dependencies: ["T3Kit", "SidecarKit"],
path: "Sources/SergeCodeMac"
),
.testTarget(
name: "T3KitTests",
dependencies: ["T3Kit"],
path: "Tests/T3KitTests"
),
.testTarget(
name: "SidecarKitTests",
dependencies: ["SidecarKit"],
path: "Tests/SidecarKitTests"
),
]
)
90 changes: 90 additions & 0 deletions apps/mac/Sources/SergeCodeMac/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import SwiftUI

// App entry point. AppModel is a reference type (@Observable class), so a
// plain `let` on the App struct is sufficient — no @State needed (and @State
// is unusable here anyway, see Support/StateShim.swift). The model is passed
// explicitly down the view tree rather than injected via .environment, to
// match the explicit `model:` init parameter used by every screen-level view.
@main
struct SergeCodeApp: App {
private static let backend: any BackendService = SergeCodeApp.makeBackend()
private let model = AppModel(backend: SergeCodeApp.backend)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

// MockBackend when launched with `--mock` or `SERGECODE_MOCK=1`; otherwise
// the real sidecar-backed LiveBackend.
private static func makeBackend() -> any BackendService {
if CommandLine.arguments.contains("--mock")
|| ProcessInfo.processInfo.environment["SERGECODE_MOCK"] == "1"
{
return MockBackend()
}
return LiveBackend()
}

var body: some Scene {
WindowGroup {
RootView(model: model)
.containerBackground(.thinMaterial, for: .window)
.onAppear {
model.start()
appDelegate.backend = SergeCodeApp.backend
}
}
.defaultSize(width: 1100, height: 720)

Settings {
SettingsScene(model: model)
}
}
}

/// Ensures `backend.stop()` (SIGTERM -> 2s grace -> SIGKILL of the node
/// sidecar, plus socket/subscription teardown) actually runs before the app
/// quits. Without this, quitting orphans the sidecar child process —
/// `Process` does not kill children when its owning process exits.
///
/// Two deliberate choices here, both learned the hard way on this SDK:
/// - Raw SIGTERM/SIGINT (kill, launchd, logout) bypass
/// `applicationShouldTerminate` entirely, so DispatchSourceSignal routes
/// them into `NSApp.terminate`.
/// - `.terminateLater` + `reply(toApplicationShouldTerminate:)` is unusable
/// for async cleanup: while AppKit waits for the reply it spins a modal
/// run loop that services neither the main dispatch queue nor MainActor
/// tasks (verified empirically — GCD asyncAfter and MainActor Tasks both
/// starve), so the reply can never be sent from async work. Instead the
/// delegate blocks the main thread on a semaphore, bounded at 6s, while
/// `backend.stop()` runs on a detached task. That is safe precisely
/// because `BackendService.stop()` is actor- (not MainActor-) isolated
/// and never hops to the main actor; the UI is about to die anyway.
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
var backend: (any BackendService)?
private var signalSources: [DispatchSourceSignal] = []
private var didCleanup = false

func applicationDidFinishLaunching(_ notification: Notification) {
for sig in [SIGTERM, SIGINT] {
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { NSApp.terminate(nil) }
source.resume()
signalSources.append(source)
}
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let backend, !didCleanup else { return .terminateNow }
didCleanup = true
let done = DispatchSemaphore(value: 0)
Task.detached {
await backend.stop()
done.signal()
}
// ServerProcess.stop() SIGKILLs the child after a 2s grace, so 6s
// covers the worst legitimate case; a hung teardown must never
// wedge quit.
_ = done.wait(timeout: .now() + 6)
return .terminateNow
}
}
75 changes: 75 additions & 0 deletions apps/mac/Sources/SergeCodeMac/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import SwiftUI

// App shell. File kept as ContentView.swift for historical reasons but now
// hosts RootView, the top-level NavigationSplitView wired to AppModel.
struct RootView: View {
let model: AppModel

@UIState private var showInspector = true
@UIState private var showNewSessionSheet = false

var body: some View {
NavigationSplitView {
SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 220, ideal: 280)
} detail: {
if let thread = model.selectedThread {
ThreadDetailView(model: model, thread: thread, showInspector: $showInspector)
} else {
EmptyStateView {
showNewSessionSheet = true
}
}
}
.toolbar {
ToolbarItem(placement: .navigation) {
ConnectionStatusPill(phase: model.connection)
}
ToolbarItem(placement: .primaryAction) {
newSessionMenu
}
ToolbarItem(placement: .primaryAction) {
Button {
showInspector.toggle()
} label: {
Label("Inspector", systemImage: "sidebar.right")
}
.disabled(model.selectedThread == nil)
}
}
.sheet(isPresented: $showNewSessionSheet) {
NewSessionSheet(model: model, isPresented: $showNewSessionSheet)
}
}

/// Toolbar "New Session" menu: pick an existing project + provider
/// directly (calls model.createThread immediately), or fall through to
/// the glass sheet to add a new project first.
@ViewBuilder
private var newSessionMenu: some View {
Menu {
if model.projects.isEmpty {
Text("No projects yet")
}
ForEach(model.projects) { project in
Menu(project.name) {
ForEach(ProviderKind.allCases) { provider in
Button {
Task { await model.createThread(projectID: project.id, provider: provider) }
} label: {
Label(provider.displayName, systemImage: "bolt")
}
}
}
}
Divider()
Button {
showNewSessionSheet = true
} label: {
Label("Add Project…", systemImage: "folder.badge.plus")
}
} label: {
Label("New Session", systemImage: "plus")
}
}
}
Loading
Loading