Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
139 lines
6.6 KiB
Swift
139 lines
6.6 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
// MARK: - Shared model + App Group reader
|
|
|
|
/// One row of the session overview the app writes to the shared App Group.
|
|
/// Mirrors MobileWidgetSession in packages/ui/src/apps/mobileWidgetSnapshot.ts.
|
|
struct WidgetSession: Codable, Identifiable, Hashable {
|
|
let id: String
|
|
let title: String
|
|
let unread: Bool
|
|
/// Project label for the session's directory. Optional so snapshots written before this
|
|
/// field existed still decode.
|
|
var project: String?
|
|
}
|
|
|
|
/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the
|
|
/// JSON the app stores decodes directly.
|
|
struct WidgetSnapshot: Codable {
|
|
var runtimeKey: String?
|
|
let attentionCount: Int
|
|
let recentSessions: [WidgetSession]
|
|
|
|
static let empty = WidgetSnapshot(runtimeKey: nil, attentionCount: 0, recentSessions: [])
|
|
}
|
|
|
|
enum WidgetStore {
|
|
static let appGroup = "group.com.openchamber.app"
|
|
static let snapshotKey = "widgetSnapshot"
|
|
|
|
/// Reads the latest snapshot the app persisted. Returns `.empty` when nothing has been
|
|
/// written yet (fresh install / app never foregrounded) so widgets render a clean state.
|
|
static func load() -> WidgetSnapshot {
|
|
guard let defaults = UserDefaults(suiteName: appGroup),
|
|
let json = defaults.string(forKey: snapshotKey),
|
|
let data = json.data(using: .utf8),
|
|
let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else {
|
|
return .empty
|
|
}
|
|
return snapshot
|
|
}
|
|
}
|
|
|
|
// MARK: - Deep links (mirror packages/ui/src/apps/deepLinks.ts)
|
|
|
|
enum WidgetDeepLink {
|
|
static func newSession() -> URL { URL(string: "openchamber://new")! }
|
|
static func attention() -> URL { URL(string: "openchamber://sessions?filter=attention")! }
|
|
static func status() -> URL { URL(string: "openchamber://status")! }
|
|
static func settings() -> URL { URL(string: "openchamber://settings")! }
|
|
static func changes() -> URL { URL(string: "openchamber://changes")! }
|
|
static func files() -> URL { URL(string: "openchamber://view/files")! }
|
|
static func instances() -> URL { URL(string: "openchamber://view/instances")! }
|
|
static func session(_ id: String) -> URL {
|
|
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
|
return URL(string: "openchamber://session/\(encoded)") ?? newSession()
|
|
}
|
|
}
|
|
|
|
// MARK: - Timeline provider
|
|
|
|
struct OverviewEntry: TimelineEntry {
|
|
let date: Date
|
|
let snapshot: WidgetSnapshot
|
|
}
|
|
|
|
struct OverviewProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> OverviewEntry {
|
|
OverviewEntry(date: Date(), snapshot: .empty)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (OverviewEntry) -> Void) {
|
|
completion(OverviewEntry(date: Date(), snapshot: WidgetStore.load()))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<OverviewEntry>) -> Void) {
|
|
// The app/NSE reload timelines (WidgetCenter) when the snapshot changes, but with several
|
|
// widgets sharing the app's WidgetKit reload budget iOS can refresh them unevenly and
|
|
// leave one stale. Ask for a periodic refresh too so every widget independently re-reads
|
|
// the shared snapshot and converges to the latest state (budget permitting).
|
|
let entry = OverviewEntry(date: Date(), snapshot: WidgetStore.load())
|
|
let nextRefresh = Date().addingTimeInterval(10 * 60)
|
|
completion(Timeline(entries: [entry], policy: .after(nextRefresh)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Logo (full OpenChamber mark drawn from the SVG)
|
|
|
|
/// The OpenChamber logo, drawn to match packages/web/public/logo-dark-512x512.svg: an
|
|
/// isometric cube with translucent face fills, stroked edges, and the OpenCode mark on the
|
|
/// top face. Faces use low-opacity `.primary` so the system tint on the Lock Screen / Control
|
|
/// Center reads as a translucent fill (no colour) rather than a flat wireframe. Coordinates are
|
|
/// the SVG inner group (range x:-41.568…41.568, y:-48…48).
|
|
struct CubeLogoView: View {
|
|
var body: some View {
|
|
Canvas { context, size in
|
|
let halfW: CGFloat = 41.568
|
|
let halfH: CGFloat = 48
|
|
let scale = min(size.width / (halfW * 2), size.height / (halfH * 2))
|
|
let cx = size.width / 2
|
|
let cy = size.height / 2
|
|
let lineWidth = max(1.5, 3 * scale)
|
|
|
|
// Cube coordinate → canvas point.
|
|
func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: cx + x * scale, y: cy + y * scale) }
|
|
// OpenCode-mark local coordinate → canvas point (SVG: matrix(0.866,0.5,-0.866,0.5,0,-24) · scale(0.75)).
|
|
func m(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
|
|
let s: CGFloat = 0.75
|
|
let mx = 0.866 * s * x - 0.866 * s * y
|
|
let my = 0.5 * s * x + 0.5 * s * y - 24
|
|
return p(mx, my)
|
|
}
|
|
|
|
var left = Path()
|
|
left.move(to: p(0, 0)); left.addLine(to: p(-halfW, -24)); left.addLine(to: p(-halfW, 24)); left.addLine(to: p(0, 48)); left.closeSubpath()
|
|
var right = Path()
|
|
right.move(to: p(0, 0)); right.addLine(to: p(halfW, -24)); right.addLine(to: p(halfW, 24)); right.addLine(to: p(0, 48)); right.closeSubpath()
|
|
var top = Path()
|
|
top.move(to: p(0, -48)); top.addLine(to: p(-halfW, -24)); top.addLine(to: p(0, 0)); top.addLine(to: p(halfW, -24)); top.closeSubpath()
|
|
|
|
context.fill(left, with: .color(.primary.opacity(0.2)))
|
|
context.fill(right, with: .color(.primary.opacity(0.35)))
|
|
context.stroke(left, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
|
context.stroke(right, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
|
context.stroke(top, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
|
|
|
// OpenCode mark: square ring (even-odd) + a partial inner fill.
|
|
var ring = Path()
|
|
ring.move(to: m(-16, -20)); ring.addLine(to: m(16, -20)); ring.addLine(to: m(16, 20)); ring.addLine(to: m(-16, 20)); ring.closeSubpath()
|
|
ring.move(to: m(-8, -12)); ring.addLine(to: m(-8, 12)); ring.addLine(to: m(8, 12)); ring.addLine(to: m(8, -12)); ring.closeSubpath()
|
|
context.fill(ring, with: .color(.primary), style: FillStyle(eoFill: true))
|
|
|
|
var inner = Path()
|
|
inner.move(to: m(-8, -4)); inner.addLine(to: m(8, -4)); inner.addLine(to: m(8, 12)); inner.addLine(to: m(-8, 12)); inner.closeSubpath()
|
|
context.fill(inner, with: .color(.primary.opacity(0.4)))
|
|
}
|
|
}
|
|
}
|