Previously always silently wrote to Downloads with a fixed name — no
way to pick where it goes or rename it. Added
exportConversationWithSavePanel(format:defaultFilename:), which shows
a native NSSavePanel (defaulting to Downloads + the same filename as
before) before writing. Wired into all three File menu export buttons.
The /export slash command and the conversation list's per-row Export
submenu are unchanged (still write straight to Downloads) — the slash
command already takes an explicit filename argument inline, and the
row export is meant as a quick one-click action rather than a
save-as workflow.
Dark: added a prefers-color-scheme: dark CSS block, so HTML export
adapts to the browser/OS theme it's later viewed in. PDF is baked at
export time so it can't respond live — instead the offscreen WKWebView
used for PDF rendering has its appearance explicitly set to
NSApp.effectiveAppearance, matching whatever mode the app is in right
now at export time.
Tables: the renderer had no table support at all (previously
documented as an intentional scope cut), so GFM pipe tables were
falling through to the plain-paragraph path and showing up as literal
"| --- | --- |" text. Added detection (a row line immediately followed
by a valid dashes/colons separator row) plus alignment parsing from
the separator's colons.
Top item on the roadmap ranking from 2026-07-27 — multi-modal export
alongside the existing Markdown/JSON. New ConversationExportService
consolidates the two previously-duplicated Markdown builders
(ChatViewModel and ConversationListView had separate copies of the
same **User**/**Assistant** + --- format) and adds:
- A hand-rolled Markdown->HTML renderer scoped to what actually shows
up in chat messages (headers, bold/italic, inline code, fenced code
blocks, lists, blockquotes, links, horizontal rules) rather than
full CommonMark/GFM — no existing markdown-to-HTML utility existed
in the codebase, and swift-markdown-ui is SwiftUI-view-only with no
HTML-string export API. Content is HTML-escaped before any markdown
substitution so example code containing "<div>" etc renders as
visible text, not live markup.
- PDF via an offscreen WKWebView loading that same HTML and calling
the official createPDF(configuration:) API (macOS 11+) — no new
project/framework linkage needed, WebKit is a system framework.
Wired into every place Markdown export already existed: File menu
(Export as HTML.../PDF...), /export slash command (now md|html|pdf|json),
and a new Export submenu (Markdown/HTML/PDF) on each conversation row's
context menu in the advanced conversation list, replacing the old
single-format swipe-only export. Help docs and InputBar autocomplete
updated to match.
Creating/renaming/deleting a folder in ConversationListView only ever
updated its own local @State — the sidebar has a separate folders
array that was only refreshed by onAppear or conversation-change
triggers, so a folder created in the modal didn't show up in the
sidebar until the next app launch. Sidebar now reloads when
showConversations transitions to false (the modal closing).
collapse state, make merge provider picker visibly clickable
Deleted conversations coming back: exportAllConversations() only ever
wrote files for conversations that currently exist — it never removed
the exported markdown file for a conversation that had been deleted
locally. That file just sits in the sync repo forever, so every
future pull+import (including on every app startup) silently
resurrects it, since importAllConversations() only skips an import
when a matching local ID already exists. Fixed by having export also
delete orphaned files (conversation ID no longer present locally), and
added GitSyncService.syncAfterDeletion() — a debounced export+push
triggered right after any delete/bulk-delete/merge-cleanup, so the
removal reaches the remote promptly instead of waiting on an
unrelated future auto-save. Existing duplicates need one more manual
delete to clear, but they'll stay gone after that.
Folder collapse state now persists (SettingsService.collapsedFolderIds,
JSON-encoded like favoriteModelIds) and is restored on app launch, in
both the sidebar and the advanced conversation list.
Merge model picker: the provider switcher was legitimate (it does load
each provider's own catalog independently) but looked like plain
text — no chevron, no button styling — so it wasn't obviously
clickable. Restyled to match HeaderView's provider menu affordance
(icon + label + chevron on a colored pill).
The merge model picker was reusing chatViewModel.availableModels,
which only ever holds whichever provider the main chat window
currently has active — fine for the main chat's own switcher (where
provider and model change together via the header), wrong for an
independent one-off picker like this. If your active chat was on
Anthropic, that's all you could pick from here regardless of what
other providers you have configured.
Added its own provider menu (mirroring HeaderView's) and an
independent model list fetched via ProviderRegistry for whichever
provider is selected, so OpenRouter, Anthropic, OpenAI, etc. are all
genuinely selectable regardless of what the main chat is doing.
Folders now list alphabetically (case-insensitive) everywhere they're
shown — sidebar, advanced conversation list, and the "Move to Folder"
menu — rather than creation order. listFolders() does the sort at the
DB layer; in-memory folder arrays are re-sorted after local
create/rename so newly added or renamed folders don't fall out of
order until the next reload.
Combine Conversations' AI-Assisted Merge no longer silently assumes
settings.defaultModel — added a model picker (reusing ModelSelectorView,
defaulting to the current default model/provider) so users can pick
which model performs the merge per-combine. ConversationMergeService.merge
takes optional mergeModelId/mergeProvider overrides.
Rune retested after the previous merge fix (bigger token budget,
stronger instruction) with the exact same DDNS Docker debugging
conversations and got the identical failure — same error text,
same models. Logs showed a single ~86s request with a large prompt
(31k tokens cached) and no truncation signal available to check.
The remaining suspect: the transcript was formatted as "**User:**" /
"**Assistant:**" markdown, which closely mimics a live chat turn
format. Over a long, noisy transcript that includes something reading
like a directive ("no more editing", etc), the model can lose track of
"this is data to merge" and slip into continuing/replying to it
instead — matching exactly what was observed. Replaced the transcript
markers with synthetic, non-chat-like tokens (<<<USER_TURN>>> etc) and
added an explicit "this is not a live conversation" framing both
before and after the transcript block, not just once at the top.
Also: log OpenRouter's finishReason when it's "length" (i.e. the
response was actually cut off) so a future failure like this is
distinguishable from a formatting/instruction-following miss without
guessing from the log lines Rune already has available.
Three changes, all aimed at the same failure: merges of large
conversations (e.g. long debugging sessions) were reliably failing to
parse on both Haiku 4.5 and GLM 5.2.
- maxTokens was a flat 4000 regardless of input size — a merge of two
long conversations needs a much larger completion budget than that,
so the model's JSON array output was getting cut off mid-generation.
Now scaled with transcript size (8000-16000).
- Strengthened the prompt: explicitly tell the model not to respond to
or continue anything found inside the transcripts (a real observed
failure mode was the model echoing/continuing transcript content
instead of merging it), and to emit nothing but the JSON array.
- parseTurns now falls back to scanning for a bracket-balanced JSON
array anywhere in the response (respecting quoted strings) if the
model still wraps the array in commentary despite instructions not
to, instead of failing outright on the first non-JSON response.
.draggable()'s default drag-preview snapshot wasn't inheriting the
app's dark appearance, rendering the conversation name in black on a
transparent background while dragging. Supply an explicit preview
view instead — white text on the accent color pill — so it stays
legible for the duration of the drag, independent of the row's own
(already-correct) rendering before and after the move.
Pin conversation row title text to .foregroundStyle(.primary) instead
of the implicit default — under List's sidebar/source-list style,
AppKit's row highlighting could resolve default text to black on a
just-interacted-with row (e.g. right after a drag/context-menu move),
making it unreadable. Also drop Section(isExpanded:), which was
rendering its own native disclosure chevron on the right in addition
to the custom one already in the header on the left; folder collapse
now works purely off the existing collapsedFolders state with a plain
Section, so only the intended left-side chevron remains.
SwiftUI's dropDestination(for:action:) action closure is Void-
returning, not Bool, so handleDrop's Bool return was being silently
discarded — made the discard explicit with _ =.
Folder sections now collapse via an explicit chevron/tap header
(rather than relying on platform-dependent native disclosure, which
didn't render/click reliably) — same collapsed-state binding also
backs Section(isExpanded:) so content visibility stays in sync.
Conversations are draggable onto folder/Unfiled headers to file/unfile
them. Folder names render bold. "New Folder" is now a dedicated button
next to "New Chat" in the sidebar, and next to "Select" in the
advanced conversation list, instead of a small icon buried in the
search row.
UsageStats/ModelUsageStat/ConversationUsageStat need explicit
nonisolated inits under this project's SWIFT_DEFAULT_ACTOR_ISOLATION =
MainActor setting, same as Message/Conversation. deleteFolder also
discarded FolderRecord.deleteOne's Bool result implicitly via the
write closure's return value.
Conversations can now be filed into flat (non-nested) folders, shown
as collapsible sections in both the sidebar and the advanced
conversation list. New folders migration (v9) adds a folders table
and conversations.folderId with ON DELETE SET NULL, so deleting a
folder unfiles its conversations rather than losing them. Move/rename/
delete via context menu; conversation lists with no folders fall back
to the existing flat view unchanged.
Extends the existing Stats sheet (⌘⇧S) with a Session/All-Time segmented
picker. All-Time aggregates tokens, cost, and message counts across every
saved conversation, broken down by model and by conversation, using the
modelId already stored per message — no schema change needed.
Real errors found and fixed:
- Disclaimer referenced a nonexistent Telegram feature and called the
app "oAI-Web" (leftover from a different project's README)
- Git Sync goodbye-phrase example listed "thanks" as a trigger, which
was deliberately excluded from the actual phrase list (fires on
every polite message)
- UI/UX section still described status pills (MCP/Online/Sync) as
living in the header; they moved to the footer in the v2.4 sidebar
redesign
Missing shipped features added: Apple Intelligence provider, Personal
Data Tools (Calendar/Reminders/Contacts/Location), Research Agents,
External MCP Servers, MCP PDF text extraction, and the /shortcuts,
/skills, /jarvis, // slash commands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DMGs are notarized and stapled automatically now, so the workaround
steps no longer apply. Also corrected the minimum macOS requirement
to match the actual deployment target (26.2, not 14.0).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the old 4 screenshots with 7 current ones covering chat,
General/Apple Intelligence settings, MCP + Personal Data settings,
Agent Skills (list + edit), Advanced settings, and iCloud Backup —
placed next to the sections they illustrate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Local-first policy: no telemetry/analytics/crash reporting, API keys
in Keychain, data flow documented for every opt-in feature that
touches personal data or talks to third-party AI providers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MCP's read_file previously hard-required UTF-8 text decoding, so any
PDF in an allowed folder failed with "Cannot read file as UTF-8
text" — chat attachments already supported PDFs (raw bytes to
vision-capable models), but the AI couldn't read one on its own
during agentic file-tool use. search_files' content_search had the
same gap, silently skipping PDFs.
Adds MCPService.extractPDFText(atPath:) using PDFKit (built into
macOS, no new dependency) to pull text from a PDF's text layer.
Wired into both read_file and search_files' content search. Returns
a clear error for scanned/image-only PDFs with no text layer.
Automatically covers the Research Agents sub-agent tool loop too,
since it shares the same executeTool dispatcher.
Verified live: asked the AI to read a real PDF containing "The
secret code is PINEAPPLE-42." via read_file — it extracted the text
correctly through the MCP tool-call path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Apple's Foundation Models framework is still under active development
(built against macOS 27 beta) and likely to stay rough for a while —
small 4K context window, occasional generation errors, no tool
support yet. Surface that clearly wherever it appears:
- Model name: "Apple On-Device (Beta)" (shows in header/model picker)
- Model description: notes the beta status and what to expect
- Settings -> General: "⚠️ Beta — ..." disclaimer under the Apple
Intelligence section, same style as the existing Paperless-NGX beta
note
- Credits panel: same disclaimer for the Apple Intelligence entry
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
effectiveSystemPrompt now gates tool-usage guidelines, the user's
custom system prompt, and active Agent Skills behind whether the
selected model actually supports tools (ModelInfo.capabilities.tools).
All three assume tool/file/web access and can easily blow past small
context windows.
Confirmed live against Apple Intelligence: the default system prompt
dropped from 16,377 to ~250 tokens (well under the 4K on-device
limit), fully resolving the context-exceeded error from the initial
Phase 1 rollout. Verified end-to-end with a real successful generation
in the app after the fix.
Also: .gitignore now excludes *.profraw (stray code-coverage artifact
picked up while testing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Apple Intelligence / Foundation Models is now genuinely available on
this machine (macOS 27 beta 4) — it was reverted back in June
(f63226b) when it wasn't. Ports the reverted AppleFoundationProvider
forward onto 2.4.3, adapted for everything that's changed since:
PolyForm license headers, current AIProvider protocol shape, current
Settings.Provider/ProviderRegistry/CreditsView/SettingsView structure.
Fixes a real bug found via live testing: LanguageModelSession.
GenerationError was deprecated in macOS 27.0 in favor of a new
LanguageModelError type. On a macOS 27+ runtime, generation failures
now throw LanguageModelError, not GenerationError, so the original
error-mapping catch never matched and Apple's raw error text leaked
to the user instead of oAI's friendly message. Now dispatches to
whichever type the runtime actually throws, gated with
@available(macOS 27.0, *), keeping the old GenerationError path as
a fallback for macOS 26.x (the app's actual deployment target).
Confirmed end-to-end in the live app: provider selectable, Settings
shows a live "Available" badge, chat header shows correct branding,
and — a real, expected Phase 1 limitation — oAI's default system
prompt (active Agent Skills + MCP tool guidance, ~16K tokens on this
machine) exceeds the on-device model's 4K context window on the
very first message. The friendly error message now correctly reports
this instead of Apple's raw string. Tool calling remains out of scope
until Phase 3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
17 tests against throwaway in-memory DatabaseService instances: all
v1-v8 tables/columns exist post-migration, settings CRUD, conversation
save/load round-trip, instance isolation between separate in-memory
queues, and ContextSelectionService's DB-coupled paths (starring,
excluded-range summaries, smartSelection end to end) that were
previously untestable.
DatabaseService gains a testable init(dbQueue:) plus a makeInMemory()
convenience and schema-introspection helpers (tableExists/columnNames),
so migration tests don't need the test target to import GRDB directly.
ContextSelectionService now takes an injected DatabaseService (defaults
to .shared) and its DB-coupled methods (smartSelection,
getSummariesForExcludedRange, isMessageStarred) are bumped to internal.
No logic changes to existing call sites.
The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency,
XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended
settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING,
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS,
DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never
actually staged in the very first "Add real oAITests target" commit
(8c7fb59) despite every xcodebuild test run since then depending on it
being present on disk. Every subsequent commit this session only staged
specific file paths (never oAI.xcodeproj again), so the gap went
unnoticed until a full `git status` review here.
Without this, anyone else pulling the branch (or a truly clean checkout
on this machine) would have all the .swift test files but no target to
compile them into -- xcodebuild test would fail to find oAITests at all.
Confirmed the diff is exactly the expected target-wiring content, nothing
unrelated or corrupted, before committing.
66 new tests across 8 files (93 total in the suite now):
- GitSyncServiceTests: convertToSSH/injectCredentials/sanitizeFilename/
detectSecretsInText, including a below-threshold false-positive check
on the secret regex.
- ChatViewModelPureLogicTests: detectGoodbyePhrase, inferProvider,
calculateCost -- including the cache-read (0.1x) / cache-write (1.25x)
pricing multipliers, which is real billing-affecting logic.
- EmbeddingServiceTests / ContextSelectionServiceTests: embedding
(de)serialization round-trip, importance-score weighting, and the
token-estimate fallback (content.count / 4) when no real count exists.
- OpenRouterProviderTests / OllamaProviderTests / OpenAIProviderTests /
AnthropicProviderTests: request-building (attachments, online mode,
cache_control breakpoints, o1/o3 temperature omission, tool schema
conversion) and response-parsing (text, tool_use blocks, empty-choices
fallback behavior) for all four providers, with no network involved.
Also marks calculateCost/inferProvider/detectGoodbyePhrase (ChatViewModel)
and serializeEmbedding/deserializeEmbedding (EmbeddingService) as
`nonisolated` -- discovered via the actual test failures, not
speculation: the project's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`
setting isolates the classes that carry an explicit `@MainActor`
(ChatViewModel), so calling their static members from a plain
synchronous @Test needs the pure ones marked `nonisolated`. Matches
the existing convention already used elsewhere in EmbeddingService
(cosineSimilarity was already nonisolated before this change).
Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
Every change here is either dropping `private` (still invisible
outside the module, @testable import just needs internal-or-wider)
or converting a self-independent instance method to `static func`
(ChatViewModel.inferProvider/calculateCost/detectGoodbyePhrase --
none of the three ever touched `self`, and constructing a real
ChatViewModel triggers a real network call in init, so static-ifying
them sidesteps that entirely rather than fighting it). Call sites
updated to `Self.foo(...)` where the static conversion required it.
Touches: GitSyncService's URL/secret-scanning helpers,
EmbeddingService's embedding (de)serialization, ContextSelectionService's
importance scoring, and the request-building/response-parsing helpers
on all four providers (OpenRouter, Ollama, OpenAI, Anthropic) -- the
core AI request/response layer, previously 100% untested.
Verified: full existing test suite (27 tests) still green, no
regressions. Tests for these functions land in the next commit.
Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
27 tests across 4 files, all targeting code that was already testable
the moment the target existed -- no visibility bumps, no refactors:
- GitignoreParserTests: MCPService.GitignoreParser's glob-to-regex
matching (wildcards, **, directory anchors, negation, comments).
- OpenRouterModelsTests: the string-vs-content-block-array decoders
on both the request side (APIMessage.MessageContent/ContentItem)
and response side (Choice.MessageContent, StreamChoice.Delta),
including image extraction from content blocks.
- AIProviderTests: ChatResponse/Usage decoding, with an explicit
regression guard that Usage.rawCostUSD always decodes to nil (it's
only ever set programmatically, never from API JSON).
- MessageCodableTests: confirms Message's transient fields
(isStreaming, isStarred, generatedImages, toolCalls,
thinkingContent) don't survive an encode/decode round-trip, and
documents that its custom == deliberately ignores role/timestamp/
attachments/modelId -- a real but non-obvious behavior worth
locking in with a test.
Removed the placeholder oAITests.swift example test now that there's
real coverage. Phase 1 of the test-suite rollout plan
(peaceful-baking-kurzweil).
The old Tests/oAITests/oAITests.swift + root Package.swift/Sources/
were a swift package init stub that @testable imported a fake empty
oAI module, completely disconnected from the real ~31K-line app
(built only via oAI.xcodeproj). Running `swift test` there would
have silently "passed" while testing nothing.
oAITests is a proper Unit Testing Bundle target added via Xcode's
target editor, wired into the app's scheme (TestAction references
oAITests.xctest). Verified with `xcodebuild test` before and after
removing the dead scaffold.
First phase of the test-suite rollout plan (peaceful-baking-kurzweil).
A search box above the Contents list filters TOC entries by each
linked section's full text content (not just the link title), so
e.g. searching "european" surfaces "Slash Commands" via its command
history date-format note. Pure vanilla JS/CSS, no dependencies --
the page is a static file opened directly in the default browser.
Tested interactively in Safari Technology Preview: filtering,
no-results state, reset, and click-through navigation all verified
working, in both the matched and empty-query cases.
Contacts was never actually broken by an Apple/OS bug -- it was a
wrong entitlement key in oAI.entitlements, now fixed (abf25bd). No
functional change here: the flag was already false in both places
this session, so behavior is identical; this just removes the
now-pointless beta-badge/conditional-row scaffolding built around it.
Confirmed root cause via tccd's own log output: the hardened-runtime
hardened-runtime prompting policy checks for
com.apple.security.personal-information.addressbook (the Contacts
framework's TCC service is still internally named kTCCServiceAddressBook,
a holdover from the old AddressBook framework), not
com.apple.security.personal-information.contacts as the entitlements
file had. With the correct key, tccd allows the prompt and access is
now granted correctly on both macOS 27 beta and 26.5.1 stable — this
was never an OS bug, notarization requirement, or beta-only issue.
Also keeps the fuller CNError domain/code/userInfo logging added while
diagnosing this, in case Contacts TCC issues resurface.
isContactsHiddenPendingAppleFix was flipped true on beta 2 after
CNContactStore.requestAccess returned instant "Access Denied" under
hardened runtime, while Calendar/Reminders/Location worked fine.
Rune just installed beta 4 and wants to retest.
Two machines that already had favorites before this sync feature
shipped will both have an empty favoriteModelsUpdatedAt, so the
first sync between them ties. Previously that meant the second
machine silently kept only its own set; now tied timestamps merge
via union and re-push, so no pre-existing favorites are lost.
Favorites now push/pull through a small oai_favorites.json file in the
same iCloud Drive folder used by Settings > Backup, reconciled by
last-write-wins timestamp on launch and app-become-active. Also adds
an Off/Daily/Weekly frequency picker so the full settings backup can
run itself (checked at launch and hourly) instead of requiring a
manual "Back Up Now" click every time.
Per Rune: the NSHelpManager-based Cmd+? fix from the last few commits
technically worked (registration was correct) but opened Apple's
broken generic Tips landing page instead of oAI's own content on the
macOS 27 beta this is built against — worse than the original
browser-tab behavior. Revisit at macOS 27 RC1 (see CLAUDE.md).
- openHelp() reverts to NSWorkspace.open() on index.html directly.
- Removed the "In-App Help" Ctrl+Cmd+H menu item entirely — HelpView's
panel (search already fully working) is reachable only via /help
from the input field now, by design.
- Renamed the custom CommandMenu("View") to CommandMenu("Chat") — it
was colliding with the "View" menu macOS auto-adds for
NavigationSplitView (Enter Full Screen, etc.), producing two
identically-titled top-level menus in the menu bar.
Cmd+/ was never reaching the "In-App Help" menu item — AppKit
auto-reserves Cmd+/ to open/focus the app's own Help menu (same
mechanism as Cmd+?), silently pre-empting any custom binding on that
combo, exactly like the earlier Cmd+H (Hide Application) conflict.
Verified live with computer-use before landing on Ctrl+Cmd+H:
Option+Cmd+/ has unpredictable menu-glyph rendering and didn't fire;
Option+Cmd+H is macOS's reserved "Hide Others" shortcut (visibly hid
other app windows when tested). Ctrl+Cmd+H showed no OS-level effect
and correctly opens the panel.
HelpView's search bar was already fully implemented and working —
this was purely a matter of the shortcut never reaching it.
GENERATE_INFOPLIST_FILE = YES with no INFOPLIST_FILE meant Xcode
synthesized Info.plist purely from INFOPLIST_KEY_* build settings and
silently ignored oAI/Info.plist on disk — so CFBundleHelpBookName/
CFBundleHelpBookFolder never made it into the built app. NSHelpManager
therefore couldn't resolve the book and macOS showed the generic Help
Center instead of oAI's help content (previous commit 74c8be8 fixed
openHelp() to call NSHelpManager, but that call had nothing to find).
Fix: point INFOPLIST_FILE at oAI/Info.plist so Xcode merges its extra
keys into the generated Info.plist, and add a synchronized-group
membership exception so Info.plist isn't also copied into Resources
(would otherwise produce a duplicate-file build warning).
openHelp() always found the bundled oAI.help folder, so it always took
the NSWorkspace.open(index.html) branch — handing the page to the
default web browser. The NSHelpManager branch, which launches the
native Help Viewer (with its built-in full-text search), was
unreachable dead code. Now always routes through NSHelpManager.
- ContentView now reads/writes SettingsService.sidebarVisible so the
NavigationSplitView sidebar's shown/hidden state survives relaunch,
matching the existing window size/position persistence.
- Default system prompt gains a rule: always reply in the user's
language, even for requests (e.g. translation) targeting another one.