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, 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.
Notes files now export to notes/ + notes.json alongside conversations.json,
matching folders.json's manifest pattern: matched by conversation ID, never
overwrites notes a machine already has locally, same empty-state orphan-
cleanup safety guard as the existing conversation/folder sync code. Also
adds Cmd+D to the "Discard" button on the crash-recovery restore prompt.
Fixes two Swift 6 actor-isolation build warnings surfaced along the way:
ConversationNotesService and the String filename-sanitizing extension are
pure, state-free helpers called from nonisolated contexts (DatabaseService,
GitSyncService) but defaulted to @MainActor — marked nonisolated.
Gives each conversation an opt-in, persistent memory file the model reads
automatically every turn and writes to on its own initiative via a fenced
```update-notes``` block in its reply — no per-write approval, matching the
Confab-as-CLAUDE.md-for-itself concept Rune wanted. /notes on|off|show,
files live in ~/Library/Application Support/oAI/notes/, embedded ID header
for future Git Sync compatibility. Adds DB migration v12.
New "Read Release Notes" entries in the Help menu (current installed
version) and the "Check for Updates" alert (the new, not-yet-installed
version) render a release's markdown notes in a Confab modal.
- UpdateCheckService.fetchReleaseNotes(forTag:) fetches a release's
title + body from Gitea's public releases-by-tag API, caching the
result by version tag in the settings table (a published release's
notes don't change, so no need to refetch on every view).
- ReleaseNotesView reuses the existing MarkdownContentView renderer;
shows a friendly "not available yet" state for versions with no
published Gitea release (e.g. a dev build ahead of the last release).
- ReleaseNotesRequest carries which version to show atomically via
.sheet(item:), per this project's established sheet-timing pattern.
- Removed the redundant "Release Page" button from the update alert
now that notes show in-app; added an explicit .keyboardShortcut
(.cancelAction) to its cancel button so Escape actually closes it —
role: .cancel alone didn't do it, since NSAlert only auto-binds
Escape to a button literally titled "Cancel".
Folders and conversation→folder assignments now sync across machines:
- Folder gains updatedAt (v11 migration) to resolve renames/reparents
last-write-wins across machines.
- New folders.json manifest at the sync repo root: folder tree +
conversationId→folderId assignments, imported before conversation
files so new conversations land in the right folder immediately.
- Local folders missing from the manifest are pruned (reparent-safe),
guarded the same way conversation-orphan cleanup already is against
an empty/stale manifest wiping everything.
Three real bugs found and fixed during live multi-machine testing:
- Sidebar never refreshed after Git Sync imported conversations/folders
directly into the database — only reloaded on launch or when the
advanced conversation list closed, with no equivalent hook for the
Settings sheet.
- "Sync Now" exported before pulling, so it could write folders.json
as an untracked file that then collided with the remote's tracked
copy on the next pull ("untracked working tree files would be
overwritten by merge"). Reordered to pull → import → export → push.
- Folder assignment only applied to brand-new conversations during
import, so any conversation already synced to a machine before this
feature existed never got filed — which in practice is every
conversation on a second machine, not an edge case. Now backfills
a folder assignment for existing conversations that aren't filed
anywhere locally yet, without clobbering an already-set folderId.
Also renamed the "Initialize Repository" button to "Clone Repository"
(it's always been a git clone, not new-repo creation) across the UI,
localization catalog, and Help Book.
Both files diverged from a shared base: main got doc content fixes
without the oAI→Confab rename, while 2.5.0 got the same fixes plus the
rename plus new feature docs (crash recovery, unsaved-changes prompt).
2.5.0's version is a strict superset, so conflicts resolved in its favor.
Folders can now contain other folders, arbitrarily deep — e.g. "Work"
containing "Project A"/"Project B". v10 migration adds a
self-referencing parentId column; tree ordering, depth, and cycle
detection are pure Swift (Folder.orderedTree/isDescendant/
visibleFolderIds), not SQL, so listFolders() stays a simple flat
query.
- Create nested folders via "New Subfolder…" (context menu, both
list views) or by dragging a folder onto another to reparent it.
Dragging onto an existing descendant is rejected (cycle guard).
- Deleting a folder reparents its children and any conversations
filed directly in it up one level to the deleted folder's own
parent — conversations are never deleted. This also fixes a real
bug: the previous deleteFolder never persisted unfiling to the
database, only patched in-memory state, so a conversation whose
folder was deleted kept a dangling folderId and silently vanished
from view after the next relaunch.
- All "Move to Folder" pickers (sidebar, advanced list, per-row
context menus, the Save dialog's folder popup) show an indented
flat list reflecting the tree.
- New DraggedItem enum disambiguates a dragged folder from dragged
conversation(s) in the shared string-based drag payload, and
unifies both list views on the same bundled-multi-selection format
— closes a gap where dragging a multi-selection in the advanced
list (⌘L) only moved the one row grabbed, unlike the sidebar.
Confirmed working live, including relaunch-survival of the
delete/reparent fix.
ConversationListView (advanced list, ⌘L): ⌘-click toggles a row,
Shift-click selects a contiguous range, and a "Move to Folder"
toolbar button/context-menu entry moves every selected conversation
at once. Confirmed working live.
SidebarView: same capability, adapted to the sidebar's own click
model since opening a chat there previously required only a single
click. Single-click now selects only (replacing the prior selection),
⌘/Shift-click work the same as the advanced list, and double-click
opens a chat (clearing the selection). Selected rows get a distinct
neutral tint from the existing accent highlight used for the
currently-open conversation. Dragging a row that's part of a
multi-selection now bundles every selected conversation's ID into the
drag payload, so dropping on a folder moves the whole selection
instead of just the dragged row.
Range-selection math (idsInRange) is defined once on
ConversationListView and reused directly by SidebarView rather than
duplicated — it's `internal`, not `private`, specifically so both
views can share it.
Inline single-backtick spans and multi-line fenced ```blocks``` now
render with monospace styling as you type, plus real per-language
syntax highlighting for fenced blocks (reusing the existing
SyntaxHighlighter utility). Only complete, closed spans/fences light
up — an unterminated backtick or fence is left as plain text until
closed.
Pure regex/range logic extracted into testable static functions
(inlineCodeRanges, fencedCodeBlocks, fencedCodeBlockRanges) rather
than living inline in the NSTextView coordinator.
A full rebrand (oAI -> Confab) warrants a minor version bump rather
than a patch release. Branch renamed from 2.4.4 to 2.5.0 to match the
project's per-version branch convention (old branch deleted from the
remote after the new one was pushed and tracked).
Untrack ConversationListViewPureLogicTests.swift and
NativeTextEditorPureLogicTests.swift — these belong to two other
unrelated, unfinished features (conversation multi-select and input
code formatting) and got swept in by an overly broad glob in the
previous commit. Files remain on disk, just untracked again.
Also stage the deletion of the old oAI.entitlements path — the
earlier git mv to Confab.entitlements had its staged rename undone by
an intermediate git reset, so the previous commit added the new file
without ever removing the old one from tracking.
"oAI" reads as easily confused with OpenAI, both visually and in
casual conversation. Renamed to "Confab" throughout: Xcode
target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book
identity, all user-facing UI text, internal Log subsystem and color
identifiers, localization catalogs (6 languages, including a proper
reworded/retranslated Intel-deprecation notice), Help Book HTML
content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY).
Deliberately cosmetic-only: the on-disk data folder
(~/Library/Application Support/oAI/), database/backup filenames,
Keychain service identifiers, and EncryptionService's key-derivation
inputs are all left untouched so existing conversations, settings,
and stored API keys survive the update with zero migration and no
re-entering credentials. Verified live: a real signed build
successfully decrypted a stored API key and loaded an existing
conversation database after the bundle ID change.
Also includes a small already-completed, previously uncommitted
model-release-date feature (ModelInfo/OpenRouterModels/
OpenRouterProvider/ModelInfoView) that happened to share several
files with this rename.
Gitignored on this branch and updated on disk but not part of this
commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
exportAllConversations()'s orphan-cleanup treated "zero local
conversations" as "every synced conversation was deleted," so a
just-cloned repo on a new machine could get emptied and pushed before
the post-clone import ever ran. orphanedExportFilenames() now returns
no orphans when the local ID set is empty, and cloneRepository()
imports immediately after cloning to close the window entirely.
Replaces heuristic auto-save (goodbye-phrase detection, idle timeout,
min-message count, on-model-switch) with a standard macOS unsaved-changes
gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation,
and Quit. The Save dialog gained a folder picker with inline "New Folder…"
creation.
Separately, the in-progress conversation is periodically mirrored to disk
(DraftRecoveryService, configurable interval in Settings, default 10s) and
offered back on next launch if oAI crashes or is force-quit, including the
model that was selected.
Two real bugs found via ObjectIdentifier/log-based diagnosis before this
worked correctly:
- oAIApp.init() wired AppDelegate.chatViewModel from its own @State read,
which returned a throwaway ChatViewModel instance distinct from the one
ContentView actually renders. Wiring moved to ContentView.onAppear.
- NSApplication.shared.delegate as? AppDelegate always failed silently:
@NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate
wrapper as the real NSApp.delegate (same name, different type in a
different module), which forwards protocol methods but isn't castable
to our type. AppDelegate now tracks itself via a static `shared`.
Also guards checkForCrashRecoveryDraft() against running under
XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test
launches this same app, and a leftover draft file on disk would otherwise
hang the entire test run on a blocking NSAlert with no one to click it.
Two things were fighting the previous dark-PDF attempt, both
confirmed by direct experimentation:
- prefers-color-scheme is ignored entirely by the print pipeline —
identical CSS printed light even with the webview's appearance
forced to dark. Screen dark-mode media queries just don't apply to
NSPrintOperation rendering.
- Even with dark colors set unconditionally (no media query), the
print pipeline still dropped every background color and printed
white — browsers/WebKit strip background-color/background-image by
default when printing, to save ink, unless told otherwise via
print-color-adjust: exact.
PDF now has its own always-dark stylesheet (pdfCss) instead of the
prefers-color-scheme-driven one HTML export still uses, plus
`* { print-color-adjust: exact }` so the dark backgrounds actually
survive the print pass. html(name:messages:) takes a new
forceDarkCSS parameter (default false, unchanged for HTML/other
callers); pdfData passes true. Dropped the now-pointless
webView.appearance forcing, since dark is unconditional now.
Verified with the same standalone repro-script + Read-the-PDF method:
dark page background, colored message boxes, dark code block, all
correctly surviving the real A4 print pipeline.
Rune compared our PDF against a normal reference PDF and the
difference was structural, not just a font-size tweak: the reference
was 6 standard A4 pages (595x842pt); ours was ONE continuous page
850x6886pt — nearly 8 feet tall. That's what createPDF() actually
does when content overflows its frame (confirmed in the prior commit)
— it auto-grows to fit everything as a single non-standard-sized
page, never paginating. There was no page of a familiar size to judge
the "normal" font size against, which is what actually read as "huge"
even after the sizing fixes.
Replaced with WKWebView's real print pipeline: build an NSPrintInfo
for US Letter (612x792pt) with normal margins, get a print operation
via webView.printOperation(with:), and run it silently (no panel) to
a temp file, dispatched off the main actor since NSPrintOperation.run()
blocks synchronously. This is genuine multi-page pagination — same
mechanism any app's real print-to-PDF uses.
Also added break-inside/page-break-inside: avoid on .message so a
single message doesn't get split awkwardly across a page boundary.
Verified with a standalone reproduction script (same method as the
prior fix) using the actual exported CSS and realistic multi-message
content: clean 2-page US Letter output, message boundaries respected
across the page break, normal-looking document proportions.
The remaining "text still looks large" complaint after the page-
geometry fix was real: .message h1-h6 only ever set margin, never
font-size, so any markdown header inside an AI response (very common
in formatted answers — "## Reality Check on Capacity" etc) fell back
to the browser's default heading sizes (h2 ≈ 22px+ against a 15px
body). Gave headers an explicit, compact scale (19/17/15.5/14px) and
trimmed the base body size from 15px to 14px and the outer title from
24px to 22px for a tighter, more document-like feel overall.
Verified by reproducing the exact content from the reported screenshot
(the M1 AI Setup conversation with its "Reality Check on Capacity"
h2 and bolded list items) through the same standalone WKWebView/
createPDF harness used for the previous fix, and visually confirming
the heading now renders proportionately instead of oversized.
Empirically reproduced this outside the app (standalone WKWebView +
createPDF script, inspecting real PDF page bounds via PDFKit) instead
of guessing again. Findings:
- WKPDFConfiguration.rect left at default captures the webview's
frame size verbatim when content fits within it, and auto-grows to
a single tall page matching full scrollable content when it
overflows. It does not paginate, and setting rect explicitly just
clips to that one rect instead.
- The scrollHeight-based frame resize added last round (to "fix" this
same bug) was itself the problem: resizing the frame before calling
createPDF produced a page that didn't match the resized frame at
all (e.g. resized to 816x295, captured page came out 799x375) — a
small, badly-proportioned page that made ordinarily-sized text look
enormous relative to it. Removed entirely.
- A visible scrollbar during capture shaves ~17pt off the captured
page width. Added ::-webkit-scrollbar { display: none } to prevent
that.
Verified against a 12-message realistic conversation: clean single
page, correct proportions, readable text, matches the intended CSS
layout. The text-size-adjust/viewport-meta fix from last round turned
out to be unnecessary (computed font-size was correct all along) but
is harmless, so left in place.