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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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 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.
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).
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).