Root-caused two real issues Rune hit with Obsidian/Homepage external MCP
servers:
1. Toggling a server's enable switch silently wiped transportKind/env/
url/bearerToken/headers back to stdio defaults (only id/name/command/
args/isEnabled/timeout/createdAt were preserved) — almost certainly
how Obsidian's config got corrupted into an empty-command stdio entry
despite never being edited directly. Fixed via
ExternalMCPServer.withEnabledToggled(), which flips only isEnabled.
2. npx (installed via Homebrew) was invisible to Confab because GUI apps
only inherit launchd's minimal PATH, not the Terminal PATH. Tried
spawning the user's login shell to ask for its real PATH — this
caused two real hangs in one session (first an -ilc pipe deadlock,
then a waitUntilExit()/CFRunLoop reentrancy issue even after fixing
that) and was abandoned entirely in favor of LoginShellEnvironment:
deterministic, subprocess-free directory probing (Homebrew, MacPorts,
Volta, nvm's alias file) that can't hang by construction.
Also added:
- Edit capability for existing External MCP servers (previously only
Add/Toggle/Delete) — the second thing Rune explicitly asked for, and
the way to fix a corrupted entry like Obsidian's without deleting it.
- MCPClientError.commandNotFound: a stdio server's command is checked
against PATH up front in StdioMCPTransport.prepare() and fails
immediately with a clear reason instead of cycling through 3 rounds of
crash/restart backoff (5s/15s/30s) for a permanently-missing binary.
- A "Get Node.js" button appears when this happens, opening a sheet with
a copyable `brew install node`, a one-click install (via
NodeInstallHelper, using the terminationHandler/readabilityHandler
pattern already proven safe elsewhere in this file — deliberately not
waitUntilExit()), or a nodejs.org link if Homebrew isn't present.
- ExternalMCPManager.retryClient(id:) to manually retry after fixing the
underlying cause.
- Help book: new "Servers That Use npx" section, updated Server Status
section, updated Settings blurb.
37 new/changed tests covering the toggle fix, PATH probing, the
commandNotFound fast-fail path, and missing-command detection — full
suite (374 tests) passes clean.
Localize mailAccessNote and AppleMailService's account-count string
(was a manual singular/plural literal, now uses inflect syntax). Also
correct the requestAccess() doc comment and the suspectedPlatformBug
fallback message, both of which still asserted "known macOS 27 beta
issue" — that diagnosis was wrong (see 6b4448d): the real cause was a
missing entitlement, already fixed. The instant-failure heuristic stays
as a defensive fallback, just no longer misattributed.
Confab.entitlements was missing com.apple.security.automation.apple-events,
so tccd's hardened-runtime policy silently refused to even prompt for
Automation consent to Mail.app — confirmed via tccd's own log, the same
failure class as the earlier Calendar/Contacts entitlement bug. Unhides
the Mail integration (MailTools.isHiddenPendingAppleFix = false).
Live-verified: Request Access now grants successfully on macOS 27 beta 7.
Rune caught this live too: content is now visible (last commit fixed
that), but the tinted glass (.regular.tint(.blue) etc.) rendered as a
solid, opaque, saturated color block instead of translucent frosted
glass — looked like a plain filled button, not Liquid Glass.
Dropped .tint() from all four Phase 1 conversions (tabButton,
mcpSidebarRow, and ModelSelectorView's filter/category/favorites/sort
chips) — the selection/active signal already comes from the icon/text
foreground color turning blue (or yellow, or the category color), which
was untouched by any of this. The glass itself is now always .regular
with no tint. tabButton's unselected state also switched from
.buttonStyle(.glass) to .buttonStyle(.plain) — restores the original
"only the selected tab shows any background" look, since with the tint
gone a plain .glass button style for every tab would visually flatten
the selected/unselected distinction back down to icon color alone.
Rune caught this live: the selected MCP tab and "External MCP" sidebar
row rendered as solid opaque blue blocks completely hiding their
icon/label. Root cause: .glassEffect() was nested inside a
.background { } closure applied to a plain Color.clear placeholder,
which isn't how the API is meant to be used — it needs to wrap the real
content directly (as the skill's own examples show), not sit behind it
as an opaque background layer.
Also discovered the skill's documented isEnabled: parameter on
glassEffect(_:in:isEnabled:) isn't available on this SDK build
("extra argument in call") — worked around by branching the view
instead of using that parameter.
Since tabButton/mcpSidebarRow are actual Buttons, switched to the
more correct approach for buttons specifically: native
.buttonStyle(.glass)/.buttonStyle(.glassProminent) for the tab bar
(matching the skill's textbook button pattern) rather than hand-applying
.glassEffect() to custom content. mcpSidebarRow needed to stay on
direct .glassEffect() application (not button styles) since its row
needs to stretch full-width via a trailing Spacer(), which glass
button styles don't support — but applied directly to the real HStack
content this time, branched via if/else, not nested in .background.
Note for the next Rune check: native glass buttons add ~13pt of their
own internal padding, so manual padding was reduced/dropped on
tabButton's label — sizing may look different than before, adjust if
too large/small.
First Liquid Glass adoption in Confab (deployment target is already
macOS 26.2, so no #available gating needed anywhere). Converts the two
confirmed-safe, non-scrolling segmented selectors:
- Settings' top-level 12-tab bar (tabButton) and yesterday's new MCP-tab
sidebar (mcpSidebarRow): selected-state Color.blue.opacity(0.1)
backgrounds become tinted .glassEffect(), wrapped in
GlassEffectContainer. Shipped without morphing (@Namespace/
glassEffectID) for now, since at most one item is visible at a time in
steady state — morphing is a stretch goal only if wanted after seeing
this.
- ModelSelectorView's filter/category/favorites/sort chip row: flat
Color.opacity() pill backgrounds become .glassEffect(), all wrapped in
one GlassEffectContainer for a real multi-element merge demo (several
chips can be active simultaneously here, unlike the single-selection
tab bars).
Deliberately NOT touched in this phase: anything inside a ScrollView/
List (formSection cards, chat bubbles, sidebar rows) per Apple's own
no-glass-in-scroll-views guidance, and the two ChatView anti-patterns
(Header/FooterView's .ultraThinMaterial) which need a bigger safeAreaBar
restructuring, planned as Phase 2.
The MCP tab had grown into one long scrolling list mixing seven unrelated
areas (File System, Bash Execution, Research Agents, External MCP
Servers, CLI Access, Personal Data, Mail), making it hard to find
anything. Split into a left sidebar (mirroring the existing top-bar
tabButton's blue-accent selected style, just as left-aligned icon+label
rows instead of icon-over-label) with each area now its own standalone
page.
The MCP tab renders outside the shared Settings ScrollView so its
content pane can scroll independently while the sidebar stays pinned —
nesting a plain ScrollView inside another one without an explicit height
just sizes to content rather than scrolling on its own. All 11 other
tabs are unaffected. Bumped the Settings window's min/ideal width
slightly to give the content pane room now that the sidebar takes some
of it on the MCP tab specifically.
Personal Data's and Mail's existing kill-switch guards
(PersonalDataTools/MailTools.isHiddenPendingAppleFix) now also hide
their sidebar rows entirely via MCPSubsection.visibleCases, not just
their content.
Root cause: on macOS, SecureField's bound value only commits when the
field loses focus (Return, click-away, tab switch) — not on every
keystroke like TextField. Every "Test Connection" button (Sync, Email,
Paperless, Anytype, Jarvis) gated its .disabled(...) on a *Configured
value fed by a SecureField-backed API key/token/password, so typing a
key straight into the field left the button looking permanently disabled
until something else forced a focus change.
Moved the "configured" check from the disabled condition into each test
function's action handler instead — by the time a click fires, the click
itself has already moved focus away and committed the field's value, so
the check now sees it correctly. Buttons stay clickable at all times
(gated only by their own isTesting spinner state) and show a clear
"Enter X first" message if config is actually incomplete.
Confirmed via live debugging this is a genuine OS bug (first-time
Automation consent grants never work on this beta), not fixable in-app.
Rather than ship a Settings section that can't currently work, added
MailTools.isHiddenPendingAppleFix (true for now) mirroring the existing
PersonalDataTools kill switch used for the same purpose during an earlier
Calendar/Contacts TCC bug — hides the Settings UI and forces mailEnabled
to false regardless of the persisted value, with no code deleted. Flip
back to false once a newer beta/RC is confirmed to fix it.
Confirmed via multiple live rounds with Rune this is a macOS 27 beta issue,
not a Confab bug — matches an already-documented pattern in this project
(Calendar/Contacts requestAccess failing identically). Every failure
returns in single-digit milliseconds, ruled out threading, wrong API,
build/signing, and notarization; even a fresh notarized build fails
identically, while Terminal->Mail (first-party) works with no prompt at
all, and the same-style bug already has a drafted Apple Feedback report
for a different permission category.
Kept the real Apple Event attempt as the primary path (the way this
should work once the OS bug is fixed), but now time it: a failure faster
than any human could plausibly answer a real dialog (default 300ms) is
classified as the platform bug rather than a genuine denial, and the UI
falls back to opening System Settings' Automation pane directly with an
explanatory note, instead of the button silently doing nothing.
Rune's log capture proved the theory wrong: AEDeterminePermissionToAutomate-
Target(askUserIfNeeded: true) returned -1743 in ~9ms — far too fast for any
real dialog to have been shown and answered — and the very next passive
status check still reported -1744 (not yet determined), meaning the OS
never actually recorded a decision despite the "denied" return. That's the
pre-flight API misbehaving, not a threading issue (both previous fixes
addressed threading and neither helped).
requestAccess() now attempts a real, harmless Apple Event (listAccounts)
via the same runHandler path mail_list_accounts/testConnection() already
use — sending an actual Apple Event is the standard, proven mechanism for
triggering macOS's first-time Automation consent prompt, and this path is
already confirmed working (it's what correctly reported "not authorized"
in the first live test).
Previous fix assumed being MainActor-isolated inside an async method was
equivalent to a classic synchronous AppKit call — it wasn't; Rune
confirmed live it still did nothing (no dialog, no state change, even
after a clean tccutil reset). AEDeterminePermissionToAutomateTarget is a
blocking, modal-dialog-presenting legacy API and needs to run from a
genuine DispatchQueue.main.async dispatch to correctly nest its own run
loop, not from inside a suspended Task continuation frame. Added extra
Log.mail checkpoints (call received / about to call AE / result) so the
next attempt has real data to diagnose from either way.
requestAccess() was dispatched onto the background queue used for
AppleScript execution, on the assumption that a blocking call needed to
be off-main. That's backwards for AEDeterminePermissionToAutomateTarget's
consent dialog — like this app's existing NSAlert.runModal() calls, it
needs to run on the main thread/run loop to actually display. Off-main,
it silently returned without ever showing a prompt or changing state.
Also added Log.mail diagnostics on both the status-only check and the
prompting call so a future report of "still doesn't work" has an actual
status code to look at instead of starting from scratch.
Settings previously only surfaced an error message after a failed Test
Connection attempt — there was no way to trigger the actual macOS
Automation permission dialog or see live grant/deny status, unlike
Calendar/Contacts/Reminders/Location.
AEDeterminePermissionToAutomateTarget (askUserIfNeeded: false/true) turns
out to provide exactly that: a non-prompting status check and an explicit
prompt-and-wait call, mirroring EKEventStore.authorizationStatus(for:)/
requestFullAccessToEvents(). Mail's Settings row now uses the same
personalDataRow component as Calendar/Contacts — live status badge plus
a real "Request Access" button — instead of a one-off Test-Connection-only
UI. Test Connection stays as a secondary functional check.
appleScriptError carried the raw NSDictionary from NSAppleScript's error
out-param, which isn't Sendable and got flagged once the type crossed an
async boundary. Extract just the two fields actually used (errorNumber,
errorMessage) into plain Int/String instead.
Lets the AI search Apple Mail, read a message, and save an attachment to
disk (e.g. to hand off to paperless_upload_document) — e.g. "find the
receipt from Elkjøp and add it to Paperless." Talks to Mail.app via
AppleScript/Apple Events rather than parsing its private on-disk store,
so no Full Disk Access or MIME parsing is needed; Mail's own attachment
save handles all decoding. Every mail_* tool call requires approval
(Deny/Allow Once/Allow for Session), mirroring the bash_execute and
Personal Data gate pattern.
Also fixes a pre-existing bug found while touching the adjacent
tool-activation condition: paperlessEnabled was missing from it, so
Paperless tools could fail to activate unless another integration was
also active.
attemptSaveCurrentConversation() (Save/Save As) and confirmDiscardIfNeeded's
no-unsaved-changes early return both left the on-disk draft_conversation.json
untouched, so a stale snapshot from before the save lingered and triggered
"Restore unsaved conversation?" on next launch even though everything had
already been saved with no changes since.
macOS's built-in window-restore (Resume) can place the window at
coordinates for a display that's no longer connected, leaving it
visible-but-invisible with no way to recover short of quitting. Now
re-centers automatically on app activate/Dock reopen, plus a manual
"Reset Window Position" menu command as a guaranteed fallback.
ExternalMCPManager.convertToolDefinition/convertInputSchema were made
nonisolated static func in an earlier session for direct unit testing,
but this project's -default-isolation=MainActor makes every type
implicitly main-actor-isolated unless marked nonisolated — so those
functions referencing Tool.Function.Parameters.Property's init and
ExternalMCPServer.slug (both plain data types with no explicit
isolation) triggered "main actor-isolated ... can not be referenced
from a nonisolated context" warnings, surfacing as Xcode's opaque
"exit code 0 but produced no further output" compile failure in a
Release build.
Marked Tool (and all its nested types) in AIProvider.swift, and
ExternalMCPServer/MCPTransportKind/MCPToolDefinition/MCPInputSchema/
MCPPropertySchema in ExternalMCPModels.swift, nonisolated at the type
level - they're plain DTOs for JSON request/response mapping with no
reason to be actor-isolated at all. Verified with a clean Release
build (matching how the warnings were originally surfaced).
It was only shown when Search Provider was set to Google, but the
same key is also needed for Google embeddings in Semantic Search
(Advanced tab) — a completely separate feature from web search. A
user who only wanted Google for embeddings had no way to find where
to enter the key. Now always visible under General → Web Search, with
a caption clarifying both uses; Search Engine ID stays conditional
since it's web-search-specific. Also pointed the Semantic Search tab's
"no providers available" hint at the same location.
Anthropic doesn't allow this kind of third-party OAuth usage, and the
underlying code (never wired into any UI) was removed on 2.5.2
(64bf704). Cherry-picked just the doc fix to main.
Anthropic doesn't allow this kind of third-party OAuth usage, so it
was never wired into any UI - no button or menu item anywhere ever
triggered it. Confirmed unreachable (only construction site for
AnthropicProvider used the apiKey initializer) before deleting:
- AnthropicOAuthService.swift (314-line PKCE flow) removed entirely
- AnthropicProvider: dropped the AuthMode enum/oauth case, now just
holds a plain apiKey string
- ProviderRegistry.hasValidAPIKey(.anthropic) no longer checks
OAuth auth state
- README: removed the "...or use OAuth" mention
Prompted by a real incident: Confab's process silently wedged, and
was indistinguishable from "just idle" after the fact — lsof showed
the CLI socket held open, every connection was refused, and nothing
had been logged for the rest of that session. No crash, no error.
- CLIServerService: NWListener's state transitions are normally
near-instant, but had no bound on that assumption. Now times out
after 8s if .ready is never reached, logging clearly and clearing
the listener slot instead of silently pretending to work forever.
- oAIApp: a 5-minute heartbeat log line, otherwise meaningless on its
own, turns "the log's gone quiet" from an ambiguous signal into a
plain read of roughly when a future freeze started.
Neither fixes a known root cause (none was found - no crash report,
no error, just silence), so this is diagnostics and a narrow
failsafe, not a claim the underlying freeze is resolved.
The multi-line backslash-continued curl command was fragile to
copy/paste: a trailing space after \ or a dropped backslash (both
common when pasting a multi-line snippet out of a chat UI or browser)
silently breaks the continuation, so each following line gets parsed
as its own bogus command instead of a curl flag - exactly what Rune
hit ("command not found: -H", "-d", "no such file or directory:
http://localhost/") after pasting the previous version. A long single
line has no continuation character left to mangle.
An unquoted prompt containing ?/*/[...] (e.g. `ai Who are you?`) was
getting glob-expanded by zsh before the ai() function ever ran,
aborting with "no matches found" and never reaching the socket server.
A plain function can't protect its own call site from this - filename
generation happens during command-line parsing, before the shell
dispatches to a function. Switched the recommended snippet to
`alias ai='noglob _ai_impl'`, which suppresses globbing for the whole
command line via noglob as a precommand modifier. Quoting remains the
fully robust habit for other shell metacharacters, but this covers the
specific mistake a user is most likely to make by accident.
External MCP Servers previously only spoke stdio (spawn a local
command + args). Adds:
- env vars for stdio servers (merged into the subprocess environment,
not embedded in the args string), with a masked key-value editor
- a native Streamable HTTP transport (URL + Bearer token + custom
headers), so HTTP-based MCP servers like Obsidian's Local REST API
plugin connect directly without needing npx/Node.js as a bridge
Introduces an MCPTransport abstraction (stdio/HTTP) so ExternalMCPClient
stays transport-agnostic — mirrors how Provider.swift already abstracts
AI backends in this codebase.
Also fixes a real crash found via live testing against Obsidian:
convertInputSchema force-unwrapped a tool parameter's `type`, which
isn't required by JSON Schema — Obsidian's plugin was the first real
server to send a parameter without one. Live-verified end to end
(vault search/read/write/edit) before this commit, per the project's
standing rule to hold external-service-dependent changes until they're
actually confirmed working, not just compiling and passing tests.
Conversation text is still only persisted when explicitly saved (⌘S),
but tokens/cost/model/provider are now logged to a new usage_events
table for every completed AI response regardless — the Analytics
view now reads from this table instead of messages, so it reflects
real usage even for conversations that were never saved. Adds a
By Provider chart mode alongside Over Time/By Model.
Also fixes the Analytics entry point: ToolbarItem(placement: .navigation)
silently doesn't render in a plain .sheet-presented NavigationStack on
macOS. Moved the button inline next to the segmented picker, matching
the codebase's existing convention (e.g. the model-favorites star filter).
New large modal off the Stats sheet with a 6-way timeframe picker
(Today/7 Days/Week/Month/Year/Total), three tappable summary tiles,
and native Swift Charts (bar chart over time, pie chart by model).
Backed by date-range-filtered DatabaseService queries plus a new
daily-bucketed query — no schema changes needed since messages
already carry timestamp/tokens/cost/role.
Image-gen models are billed per-image/per-request, not at the plain
per-token prompt/completion rates ModelInfo.Pricing captures — so
token-based cost calculation was always near-zero for them regardless
of actual spend. Now requests OpenRouter's usage.include=true, decodes
the actual billed usage.cost, and prefers it over calculated cost via
a new resolveCost() helper (covers the dedicated Images API, regular
chat completions, streaming, and the tool-calling loop alike).
sendAndClose is main-actor-isolated (implicit under -default-isolation
MainActor), but the Task in readRequest's Network.framework callback
runs nonisolated (DisableOutwardActorInference means it no longer
inherits the class's actor from lexical context). Calling sendAndClose
without hopping onto the actor is a hard error in Swift 6 mode.
ErrorDetail.code was typed String? but OpenRouter returns it as a JSON
number, so decoding the whole error body silently failed and every
non-200 response fell back to "Unknown error: HTTP <code>" instead of
showing OpenRouter's actual message (e.g. why image generation was
rejected). Dropped the unused code field, and made the streaming path
attempt to decode the error body too instead of skipping it outright.
detailRow's label and outputBlock's title were typed String instead
of LocalizedStringKey, so Text(label)/Text(title) inside them could
never localize regardless of catalog content — the exact anti-pattern
CLAUDE.md's i18n rules warn against for helper functions. Fixed the
types, then translated the 6 genuinely-new strings (Run Details, No
output for this run., Started, Duration, Trigger, Tokens) into
nb/sv/da/de/fr. A few others (Cost, Error, Output, Copied!) were
already covered from being reused elsewhere in the catalog.
Each row in the Run History list now opens a Run Details sheet showing
the full, untruncated output/error (the old inline chevron-expand
capped output at 20 lines) plus duration, trigger, tokens, and cost.
Output/error use an explicit Copy button rather than
.textSelection(.enabled), to avoid the same Escape-beeps-instead-of-
dismissing bug just fixed in ModelInfoView.
While wiring this up, found the JarvisAgentRun model didn't match the
real oAI-Web API response shape: output decoded from a nonexistent
"output" key instead of "result" (always nil, hence "No output for
this run" even on successful runs with real content), finishedAt
decoded from "finished_at" instead of "ended_at" (Duration silently
never showed), and the status icon only recognized "completed"/
"failed" instead of the API's actual "success"/"error" values (plain
gray circle instead of a green checkmark). Fixed all three, verified
against a real API response, added 4 decoding tests.
.onExitCommand alone wasn't enough: clicking into the multi-line
.textSelection(.enabled) description handed it real AppKit
first-responder status, and its own cancelOperation: handling for
Escape consumed the key event before it ever reached the modal's
exit-command handler.
Replaced text-selection on the description with an explicit Copy
button (same pattern as the chat message copy button in
MessageRow.swift) so it can no longer grab keyboard focus at all.
infoRow's single-line values keep .textSelection(.enabled) — only
the multi-line description reproduced the bug.
The description Text sits inside ModelInfoView's ScrollView, where
Text with a lineLimit doesn't reliably compute wrapping/truncation
(a documented SwiftUI/AppKit quirk). Without a fixedSize hint it
hard-clipped mid-word with no ellipsis; adding one fixed that but
silently broke the "More…" button below it in the same VStack.
Removed the whole toggle instead of chasing further edge cases — the
modal already scrolls, so a long description just means more
scrolling. Separately confirmed via OpenRouter's public API that at
least one model's description is truncated server-side with no fuller
version available through any endpoint, so this was never going to
fully solve "show the complete description" for every model regardless.
Six strings from the new CLI Access section (897bfdc) had never
been added to the localization catalog: the section title and
description, the enable toggle, and the shell-function instructions.
Verified live in a Norwegian build.
New CLIServerService listens on a Unix domain socket
(~/Library/Application Support/oAI/cli.sock) speaking a minimal
HTTP/1.1 subset, for one-shot non-streaming shell access to a single
fixed model — e.g. an `ai "prompt"` zsh function — without opening
the app window and without going through the tool-calling loop.
Configured in Settings > MCP > CLI Access (toggle, provider, model —
deliberately independent of the chat UI's active model). JSON
request/response envelope rather than raw text so new fields (model
override, streaming, tool support) can be added later without a
breaking wire-format change.
Verified live end-to-end against a real OpenRouter request, error
paths, and clean-shutdown socket cleanup. 10 new unit tests cover
the HTTP framing/parsing logic.
- ThinkingVerbs now picks from a hand-written verb list per active
display language (en/nb/sv/da/de/fr) instead of always English,
fixed to be nonisolated at the type level (this project defaults
to MainActor isolation, which was breaking the static verb arrays).
- SyncStatusIndicator.tooltipText was typed String instead of
LocalizedStringKey, silently bypassing localization for .help() —
fixed.
- Translated ~150 strings (750 individual translations) into
nb/sv/da/de/fr that had never been localized: the entire slash-
command dropdown, sync status labels, reasoning-effort
descriptions, model sort options, and settings rows added across
recent features (Git Sync conflict recovery, crash recovery,
notes, backup, external MCP servers). Most were invisible to
xcodebuild -exportLocalizations because they're LocalizedStringKey-
typed properties/helpers rather than literal Text() calls, per the
same gap documented from the original Phase 7 audit.
- AI-translated per the README's existing disclosure; verified live
in nb and fr builds, and diffed the catalog to confirm zero
pre-existing translations were altered, only additions.
Each round of a multi-tool-call chain used to append a new "Calling: X"
message, so a long tool chain stacked up a growing list of rows in the
transcript. Replaced with a transient status line under the thinking
indicator that updates in place each round; once the response
completes, the whole chain collapses into one expandable summary
message ("Used N tool calls") instead of N separate ones.
runGit() previously called Process.waitUntilExit() synchronously on
the MainActor with no timeout. If a git network operation (push/pull/
fetch) was in flight when the Mac went to sleep, the dead connection
could hang indefinitely on wake with no OS-level timeout of its own,
freezing the entire app UI with nothing logged since the command never
actually finished. Now runs on a background queue with a 30s timeout
(120s for clone), matching the pattern already used by bash_execute.
NSWorkspace.shared.open() silently drops #fragment anchors on file://
URLs, so "Fix It Myself" always landed on the Help Book index instead
of the relevant section. Replaced with GitSyncManualFixSheet, an
in-app sheet showing the real conflicting filenames and sync path.
Also indent conversation rows one level deeper than their containing
folder in the sidebar and conversation list, so nesting is visible on
the conversations themselves and not just the folder headers.
syncOnStartup() (pull+import, fired at launch) and autoSync() (export+push,
debounced off chat activity) ran as fully independent, uncoordinated Tasks
with no mutual exclusion. A user launching the app and chatting right away
could hit autoSync's export mid-pull, leaving a freshly-written untracked
file that the pull then refuses to merge over — the same failure class as
the earlier folders.json bug, now much more likely to surface widely since
folders.json/notes.json are brand new for every existing sync repo.
Adds a shared isSyncing guard across all three entry points (syncOnStartup
skips if busy, autoSync waits for a clear slot, syncNow throws
.syncInProgress) and moves Sync Now's pull/import/export/push orchestration
out of SettingsView into GitSyncService.syncNow(), where the guard can
actually protect it.