Repository navigation
cmux NEXT sidebar: chat history (Earlier) and pinning under the session tree #22323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
teamleaderleo
merged 50 commits into
feat-cmux-next
from
feat/next-sidebar-chat-history
Oct 21, 2026
Merged
Changes from 41 commits
Commits
Show all changes
50 commits
Select commit
Hold shift + click to select a range
078fdc7
cmux NEXT sidebar: session tree with pull request badges and keycaps …
teamleaderleo 961a30e
cmux NEXT media: the core tour turns on the session tree last
teamleaderleo 9bee46b
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo caa48f0
chore(next): PR autofix at 9bee46b945a1
a343254
cmux NEXT sidebar: the session tree feed lives in CmuxNextAgentLanes
teamleaderleo 9bc7d07
cmux NEXT: CI target graph for CmuxNextAgentLanes' new dependencies
teamleaderleo 09fb35e
Merge remote-tracking branch 'origin/feat/next-sidebar-session-nestin…
teamleaderleo 7ad7c4e
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 752dc87
cmux NEXT sidebar: review fixes for the session tree
teamleaderleo 3031e0c
chore(next): settings exports for sidebar.sessionTree
teamleaderleo 215bf61
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo f1f1ce3
cmux NEXT: CI target graph keeps CmuxNextAgentLanes' dependencies aft…
teamleaderleo 05f6f09
chore(next): settings exports for sidebar.sessionTree after the base …
teamleaderleo 08374a3
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 596f604
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo a705802
chore(next): lower the type-scale baseline for BrowserPopupPanel (bas…
teamleaderleo d191e27
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo ef8b005
cmux-next tour: turn sidebar.sessionTree back off after its step
teamleaderleo d986f8e
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 9657fe1
test(next): close the #require calls in the session tree lane test
teamleaderleo 2090229
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo c485e2c
Merge remote-tracking branch 'origin/feat/next-sidebar-session-nestin…
teamleaderleo f2d34a4
test(next): the Markdown viewer test host implements FilePageHosting.…
teamleaderleo 21e8645
chore(next): PR autofix at f2d34a4a49c6
ec46e0d
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo 280f61f
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo bc24c7c
acpmux: record the PRs each session opens; the sidebar session tree c…
teamleaderleo 98385c0
fix(next): take the base's generated files and Markdown test host aft…
teamleaderleo 42d1b27
generated: restore base's shortcuts export (a stale autofix dropped p…
teamleaderleo c8e1121
Merge remote-tracking branch 'origin/feat/next-sidebar-session-nestin…
teamleaderleo 4e00ea2
fix(next): keep the base's hook status conflicts (#21813) after the s…
teamleaderleo 96d5993
chore(next): PR autofix at 4e00ea23a3e1
cda7f70
chore(next): rerun the PR autofix so it regenerates the settings exports
teamleaderleo 014d9cc
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 0a26f98
chore(next): PR autofix at 014d9cccf691
84c7e74
test(next): the sidebar's PR recheck reads only open pull requests
teamleaderleo a6e584a
fix(next): the sidebar's PR recheck reads only open pull requests
teamleaderleo 3e5680b
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo f25802a
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 4dd489e
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/next-s…
teamleaderleo 72a582c
feat(next): sidebar chat history and pinning under the session tree
teamleaderleo 883eff5
Merge feat-cmux-next into feat/next-sidebar-chat-history
teamleaderleo 8cf0b7a
Merge feat-cmux-next into the PR branch (string catalogs merged key-w…
teamleaderleo b16e062
cmux NEXT sidebar chat history: Pin/Unpin runs a registered action; E…
teamleaderleo 084555e
SidebarChatHistoryFeed: import Observation publicly
teamleaderleo 288cc2e
Merge feat-cmux-next into the PR branch (picks up base's workflow gua…
teamleaderleo 983c18a
Keep the sidebar string catalog in base's key order (content unchanged)
teamleaderleo 9f75c7f
Merge feat-cmux-next into the PR branch
teamleaderleo 1ca6506
Sidebar history: the row menu hook lives on SidebarChatHistory, not a…
teamleaderleo 6a733e1
Sidebar history: a group fold lays the list out again while the histo…
teamleaderleo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
66 changes: 66 additions & 0 deletions
66
Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/SidebarChatHistoryFeed.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| public import CmuxNextAgentActivity | ||
| public import CmuxNextAgentPane | ||
| import CmuxNextCompat | ||
| public import CmuxNextDaemon | ||
| import CmuxNextDesign | ||
| public import CmuxNextSidebar | ||
| import Foundation | ||
| import Observation | ||
|
|
||
| /// Feeds the session tree's chat history (`sidebar.sessionTree`, hq#2569 #6) from acpmux's chat | ||
| /// index (the app's Chats feed mirror, metadata only): the pinned chats, and Earlier, the newest | ||
| /// chats that no open workspace of this Mac shows, one page at a time. A workspace shows a chat | ||
| /// when one of its agent tabs runs an acpmux session that continues it (the lane's `chat`). | ||
| /// Off, it leaves `SidebarChatHistory.state` nil. | ||
| @MainActor @Observable | ||
| public final class SidebarChatHistoryFeed { | ||
| public static let shared = SidebarChatHistoryFeed() | ||
|
|
||
| /// The chat index mirror. The app sets it on each index change (a copy-on-write value). | ||
| public var chats = AcpmuxChatsStore() | ||
| @ObservationIgnored private var task: Task<Void, Never>? | ||
|
|
||
| /// Follows the setting, the index, the lanes, every machine's `workspaces` and the user's pins | ||
| /// from now on; a row opens its chat with `open` (the app's Open Chat path). | ||
| public func start(workspaces: @escaping @MainActor @Sendable () -> [WorkspaceModel], localHost: String?, | ||
| open: @escaping @MainActor (String) -> Void, lanes: AgentLaneStore = .shared, | ||
| history: SidebarChatHistory = .shared) { | ||
| guard task == nil else { return } | ||
| history.onOpen = open | ||
| // task-owner: the app (one feed for the app's life); event-driven (Observation) | ||
| task = Task { [self] in | ||
| for await state in ObservationStream({ [self] () -> SidebarChatHistoryState? in | ||
| guard DesignSettings.shared.sidebarSections.sessionTree else { return nil } | ||
| let live = Self.liveChats(workspaces(), lanes: lanes.lanes, localHost: localHost) | ||
| return Self.state(chats, live: live, pinned: history.pinned, limit: history.limit, folded: history.earlierFolded) | ||
| }) where history.state != state { | ||
| history.state = state | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// The chats an open workspace shows: those its agent tabs' acpmux sessions on this Mac continue. | ||
| static func liveChats(_ workspaces: [WorkspaceModel], lanes: [String: AgentActivityLane], localHost: String?) -> Set<String> { | ||
| var live: Set<String> = [] | ||
| for workspace in workspaces { | ||
| for tab in workspace.screens.flatMap(\.panes).flatMap(\.tabs) { | ||
| guard let ref = tab.agentSession, ref.host == localHost, let session = ref.session, | ||
| let chat = lanes[session]?.chat else { continue } | ||
| live.insert(chat) | ||
| } | ||
| } | ||
| return live | ||
| } | ||
|
|
||
| /// The history of `index`: reads the index lazily, newest first, only as far as the page needs. | ||
| static func state(_ index: AcpmuxChatsStore, live: Set<String>, pinned: [String], limit: Int, folded: Bool) -> SidebarChatHistoryState { | ||
| SidebarChatHistoryState.make(newest: index.newestFirst.lazy.map(historyChat), chat: { index.chat(id: $0).map(historyChat) }, | ||
| live: live, pinned: pinned, limit: limit, folded: folded) | ||
| } | ||
|
|
||
| /// A chat as the history lists it: its title, else its folder's name, else its harness. | ||
| static func historyChat(_ chat: AcpmuxChat) -> SidebarHistoryChat { | ||
| let folder = chat.cwd.map { ($0 as NSString).lastPathComponent }.flatMap { $0.isEmpty ? nil : $0 } | ||
| return SidebarHistoryChat(id: chat.id, title: chat.title ?? folder ?? chat.harness, updatedAt: chat.updatedAt) | ||
| } | ||
| } | ||
239 changes: 239 additions & 0 deletions
239
Packages/macOS/CmuxNext/Sources/CmuxNextAgentLanes/SidebarSessionTreeFeed.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| public import CmuxNextAgentActivity | ||
| import CmuxNextAgentPane | ||
| import CmuxNextBridge | ||
| import CmuxNextCompat | ||
| public import CmuxNextDaemon | ||
| import CmuxNextDesign | ||
| public import CmuxNextSidebar | ||
| import CmuxNextWakeups | ||
| import Foundation | ||
| import Observation | ||
|
|
||
| /// Feeds the sidebar's session tree (`sidebar.sessionTree`, Claude desktop's Code tab) from data | ||
| /// the app already has: each workspace's agent chats (their acpmux sessions), acpmux's `spawnedBy` | ||
| /// on those sessions' lanes, the pull request of each session folder's branch (`gh pr view`, as the | ||
| /// chat header's chip reads it) and each pull request a session opened (acpmux `pullRequests`, | ||
| /// `gh pr view <url>`). Off, it leaves `SidebarSessionTree.state` nil. | ||
| @MainActor | ||
| public final class SidebarSessionTreeFeed { | ||
| public static let shared = SidebarSessionTreeFeed() | ||
|
|
||
| private var task: Task<Void, Never>? | ||
| private let pullRequests = SidebarPullRequestStore() | ||
|
|
||
| /// What one pass reads: the tree, and the folders and pull request URLs it shows. | ||
| private nonisolated struct Pass: Equatable, Sendable { | ||
| var state: SidebarSessionTreeState | ||
| var asks: Set<String> | ||
| } | ||
|
|
||
| /// Follows the setting, the lanes and every machine's `workspaces` from now on. `localHost` is | ||
| /// this Mac's `install:<id>`: the lanes are this Mac's acpmux sessions. | ||
| public func start(workspaces: @escaping @MainActor @Sendable () -> [WorkspaceModel], localHost: String?, | ||
| lanes: AgentLaneStore = .shared, tree: SidebarSessionTree = .shared) { | ||
| guard task == nil else { return } | ||
| let pullRequests = pullRequests | ||
| // task-owner: the app (one feed for the app's life); event-driven (Observation) | ||
| task = Task { | ||
| for await pass in ObservationStream({ () -> Pass? in | ||
| guard DesignSettings.shared.sidebarSections.sessionTree else { return nil } | ||
| return Self.pass(workspaces(), lanes: lanes.lanes, localHost: localHost, | ||
| pullRequests: pullRequests.answers) | ||
| }) { | ||
| if tree.state != pass?.state { tree.state = pass?.state } | ||
| if let pass { pullRequests.refresh(pass.asks) } else { pullRequests.stop() } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static func pass(_ workspaces: [WorkspaceModel], lanes: [String: AgentActivityLane], localHost: String?, | ||
| pullRequests: [String: AgentPanePullRequest]) -> Pass { | ||
| // Each workspace's acpmux sessions on this Mac (the lanes cover this Mac's acpmux only). | ||
| var sessions: [SidebarWorkspaceID: [String]] = [:] | ||
| var owner: [String: SidebarWorkspaceID] = [:] | ||
| for workspace in workspaces { | ||
| let id = SidebarWorkspaceID(workspace.id) | ||
| for tab in workspace.screens.flatMap(\.panes).flatMap(\.tabs) { | ||
| guard let ref = tab.agentSession, ref.host == localHost, let session = ref.session else { continue } | ||
| sessions[id, default: []].append(session) | ||
| owner[session] = owner[session] ?? id | ||
| } | ||
| } | ||
| var state = SidebarSessionTreeState() | ||
| var asks: Set<String> = [] | ||
| for (id, ids) in sessions { | ||
| let shown = ids.compactMap { lanes[$0] } | ||
| if let parent = shown.lazy.compactMap({ $0.spawnedBy.flatMap { owner[$0] } }).first(where: { $0 != id }) { | ||
| state.parents[id] = parent | ||
| } | ||
| let wanted = Self.asks(shown) | ||
| asks.formUnion(wanted) | ||
| state.pullRequests[id] = Self.badge(wanted, answers: pullRequests) | ||
| } | ||
| return Pass(state: state, asks: asks) | ||
| } | ||
|
|
||
| /// What a workspace's sessions show: their folders (each one's branch's pull request) and the | ||
| /// pull requests they opened (their URLs), as ``SidebarPullRequestStore`` keys them. | ||
| static func asks(_ lanes: [AgentActivityLane]) -> [String] { | ||
| let folders = Set(lanes.compactMap(\.cwd)).sorted() | ||
| var opened: [String] = [] | ||
| for url in lanes.flatMap(\.pullRequests) where !opened.contains(url) { opened.append(url) } | ||
| return folders + opened | ||
| } | ||
|
|
||
| /// The badge of `asks`' answers, with each one's line for the hover card: each pull request | ||
| /// once, though a folder's branch's and an opened one's URL, or two sessions in one folder, | ||
| /// name the same. | ||
| static func badge(_ asks: [String], answers: [String: AgentPanePullRequest]) -> SidebarPullRequestBadge? { | ||
| var seen: Set<String> = [] | ||
| let items = asks.compactMap { answers[$0] }.filter { seen.insert($0.url).inserted }.map(Self.item) | ||
| return SidebarPullRequestBadge.combining(items: items) | ||
| } | ||
|
|
||
| /// The badge color of one pull request; nil for a closed one, which the badge leaves out. | ||
| static func status(_ pullRequest: AgentPanePullRequest) -> SidebarPullRequestBadge.Status? { | ||
| item(pullRequest).state.badgeStatus | ||
| } | ||
|
|
||
| /// One pull request's line in the hover card. Failed checks say so before a conflict (red wins | ||
| /// over orange); a pending check reads as passing until it fails. | ||
| static func item(_ pullRequest: AgentPanePullRequest) -> SidebarPullRequestItem { | ||
| let state: SidebarPullRequestItem.State = switch pullRequest.state { | ||
| case "merged": .merged | ||
| case "closed": .closed | ||
| case "draft": .draft | ||
| default: pullRequest.checks.state == "failing" ? .failing : pullRequest.mergeable == "conflicting" ? .conflict : .passing | ||
| } | ||
| return SidebarPullRequestItem(url: pullRequest.url, repository: SidebarPullRequestItem.repository(of: pullRequest.url), | ||
| number: pullRequest.number, title: pullRequest.title, state: state, | ||
| additions: pullRequest.additions, deletions: pullRequest.deletions, | ||
| createdAt: pullRequest.createdAt) | ||
| } | ||
| } | ||
|
|
||
| /// The pull request of each session folder's branch and of each pull request URL a session opened, | ||
| /// for the sidebar's session tree: read with the chat header's reader (two gh calls at a time, never | ||
| /// prompting), at most once every five minutes per folder or URL: when the tree passes again (a | ||
| /// lane or a workspace changed), and once every five minutes on its own while the tree shows | ||
| /// open pull requests (an idle session's CI can still fail). A folder is one `gh pr view` there; every stale github.com URL | ||
| /// goes in one batched GraphQL read (`read(urls:)`), never one call per pull request; a failed read | ||
| /// keeps the last answers. The | ||
| /// chat header's chip shares those two calls, so the sidebar asks seldom and never stalls it. | ||
| @MainActor @Observable | ||
| final class SidebarPullRequestStore { | ||
| /// By folder or URL; a folder whose branch has no pull request has none. | ||
| private(set) var answers: [String: AgentPanePullRequest] = [:] | ||
| @ObservationIgnored private var asked: [String: ContinuousClock.Instant] = [:] | ||
| @ObservationIgnored private var asking: Set<String> = [] | ||
| /// Failed reads in a row, by URL: each doubles its wait, up to ``maximumBackoff``. | ||
| @ObservationIgnored private var failures: [String: Int] = [:] | ||
| /// The most doublings of `freshness` a failing URL waits (5 minutes become 80). | ||
| static let maximumBackoff = 4 | ||
| @ObservationIgnored private let reader: AgentChatPullRequestReader | ||
| /// What the tree showed last, read again by ``recheck``. | ||
| @ObservationIgnored private var shown: Set<String> = [] | ||
| /// Reads `shown` again once `freshness` passes, though nothing in the tree changed. | ||
| @ObservationIgnored private let recheck = DemandTimer(owner: "sidebar.pullRequests.recheck") | ||
| @ObservationIgnored private let freshness: Duration | ||
|
|
||
| init(reader: AgentChatPullRequestReader = AgentChatPullRequestReader(), freshness: Duration = .seconds(300)) { | ||
| self.reader = reader | ||
| self.freshness = freshness | ||
| } | ||
|
|
||
| /// Asks again for each of `asks` (a folder, or a pull request's `https://` URL) whose answer is | ||
| /// missing or older than `freshness`. | ||
| func refresh(_ asks: Set<String>) { | ||
| refresh(asks, onlyInFlight: false) | ||
| } | ||
|
|
||
| /// `onlyInFlight` is the recheck: it reads again only the open or draft pull requests, since a | ||
| /// folder without one or a merged or closed one changes only with a lane or a workspace. | ||
| private func refresh(_ asks: Set<String>, onlyInFlight: Bool) { | ||
| shown = asks | ||
| // Drop what the tree no longer shows (closed workspaces, ended sessions). | ||
| let kept = asks.union(asking) | ||
| let pruned = answers.filter { kept.contains($0.key) } | ||
| if pruned.count != answers.count { answers = pruned } // a set always notifies the tree's observation | ||
| asked = asked.filter { kept.contains($0.key) } | ||
| failures = failures.filter { kept.contains($0.key) } | ||
| if freshness > .zero, asks.contains(where: inFlight) { | ||
| recheck.scheduleIfIdle(after: freshness) { @MainActor [weak self] in | ||
| guard let self else { return } | ||
| refresh(shown, onlyInFlight: true) | ||
| } | ||
| } else { | ||
| recheck.cancel() | ||
| } | ||
| let stale = asks.filter { key in | ||
| !asking.contains(key) && !isSettled(key) && (!onlyInFlight || inFlight(key)) && asked[key].map { ContinuousClock.now - $0 >= wait(key) } ?? true | ||
| } | ||
| let urls = stale.filter { $0.hasPrefix("https://") }.sorted() | ||
| for folder in stale.subtracting(urls).sorted() { ask(folder) } | ||
| if !urls.isEmpty { ask(urls: urls) } | ||
| } | ||
|
|
||
| private func ask(_ folder: String) { | ||
| asking.insert(folder) | ||
| asked[folder] = .now | ||
| let reader = reader | ||
| // task-owner: one gh read per stale folder; its answer lands in `answers`. | ||
| Task { [weak self] in | ||
| let answer = await reader.read(in: folder) | ||
| guard let self else { return } | ||
| asking.remove(folder) | ||
| if answers[folder] != answer { answers[folder] = answer } | ||
| } | ||
| } | ||
|
|
||
| private func ask(urls: [String]) { | ||
| asking.formUnion(urls) | ||
| for url in urls { asked[url] = .now } | ||
| let reader = reader | ||
| // task-owner: one batched gh read for every stale URL; the answers land in `answers`. | ||
| Task { [weak self] in | ||
| let read = await reader.read(urls: urls) | ||
| guard let self else { return } | ||
| asking.subtract(urls) | ||
| // A URL whose read failed keeps its last answer and waits longer; one GitHub has no pull | ||
| // request for clears. | ||
| for url in urls { | ||
| guard let answer = read[url] else { | ||
| failures[url, default: 0] += 1 | ||
| continue | ||
| } | ||
| failures[url] = nil | ||
| if answers[url] != answer { answers[url] = answer } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Whether the five-minute recheck is pending. | ||
| var rechecks: Bool { recheck.isScheduled } | ||
|
|
||
| /// Stops the recheck: the tree is off. | ||
| func stop() { | ||
| shown = [] | ||
| recheck.cancel() | ||
| } | ||
|
|
||
| /// A merged pull request's URL: it never changes again, so it is not asked again. (A closed one | ||
| /// can be reopened; a folder's branch can get a new pull request after one merges, so both are.) | ||
| func isSettled(_ key: String) -> Bool { | ||
| key.hasPrefix("https://") && answers[key]?.state == "merged" | ||
| } | ||
|
|
||
| /// A shown pull request whose CI or review can still change on its own (open or draft): what | ||
| /// keeps the five-minute recheck. A folder without one, or a merged or closed one, changes only | ||
| /// with a lane or a workspace, which passes the tree again anyway. | ||
| func inFlight(_ key: String) -> Bool { | ||
| guard let state = answers[key]?.state else { return false } | ||
| return state != "merged" && state != "closed" | ||
| } | ||
|
|
||
| /// How long `key`'s answer stays fresh: `freshness`, doubled for each failed read in a row. | ||
| func wait(_ key: String) -> Duration { | ||
| freshness * (1 << min(failures[key] ?? 0, Self.maximumBackoff)) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SidebarChatHistoryFeed.sharedglobally owns the chat mirror and its long-lived task. The repository's no-ambient-global-state rule requires this runtime state to be owned and injected at the app boundary. Construct and retain the feed inAppServicesinstead. UnlikeSidebarChatHistory, this feed does not fall under the exception for stores that wrapUserDefaults.Rule Used: Flag new ambient global state in production Swift: a top-level (file-scope) func used as API, a top-level mutable var or a stub class/struct holding a global flag/once-token, a caseless enum/empty struct used purely as a static func/static let namesp... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!