Commit Graph
84 Commits
Author SHA1 Message Date
rune ff26a57d26 Liquid Glass Phase 1: segmented-selection tab bars and filter chips
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.
2026-08-21 14:38:25 +02:00
rune b8e1986cdb Split the MCP Settings tab into a sidebar-navigated set of sub-pages
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.
2026-08-20 08:24:47 +02:00
rune 5787dee018 Fix Test Connection buttons needing a tab switch to activate
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.
2026-08-20 08:00:03 +02:00
rune e105aa7378 Hide Mail integration behind a kill switch pending the macOS 27 beta fix
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.
2026-08-19 15:17:40 +02:00
rune 1dc485badb Fall back to System Settings when the Mail consent prompt is the known beta bug
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.
2026-08-19 14:55:24 +02:00
rune 21d598d88a Add a real permission prompt/status for Mail access, matching Personal Data
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.
2026-08-19 14:01:29 +02:00
rune 4064851a3d Add Mail integration: search, read, and save attachments via AppleScript
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.
2026-08-19 13:47:38 +02:00
rune 4d65ab7703 Surface the Google API key field regardless of search provider
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.
2026-08-16 15:15:49 +02:00
rune 7527cc4091 Collapse the CLI Access shell snippet's curl call onto one line
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.
2026-08-14 11:06:05 +02:00
rune 35b5b09c6d Harden the CLI Access shell snippet against unquoted glob characters
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.
2026-08-14 10:54:54 +02:00
rune 25028e3405 Add env-var support and native HTTP transport for External MCP Servers
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.
2026-08-13 16:05:42 +02:00
rune 30e18f92a5 Track usage independent of conversation save state; fix Analytics toolbar button
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).
2026-08-12 13:49:12 +02:00
rune d460230158 Add Usage Analytics view: tokens/questions/cost over time and by model
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.
2026-08-12 13:05:41 +02:00
rune 30efc58d16 Translate Jarvis Run Details; fix String vs LocalizedStringKey bug
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.
2026-08-07 13:44:04 +02:00
rune 1925c9c657 Add clickable run detail view to Jarvis run history; fix field mismatches
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.
2026-08-07 13:31:39 +02:00
rune 91f67f891b Fix Escape beeping instead of dismissing Model Info after clicking description
.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.
2026-08-07 13:15:32 +02:00
rune e3d0658a09 Always show full model description instead of a fragile truncate/expand toggle
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.
2026-08-07 09:13:00 +02:00
rune 897bfdce10 Add local CLI access via Unix-socket server
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.
2026-08-05 13:33:02 +02:00
rune c21fe49f4f Add per-language thinking verbs; close major i18n translation gap
- 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.
2026-08-05 12:57:49 +02:00
rune 8875ae9aa9 Collapse tool-call chat rows into a single live status line
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.
2026-08-05 11:33:10 +02:00
rune e2284aba2b Show manual sync-conflict fix instructions in-app instead of deep-linking to Help
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.
2026-08-04 12:21:15 +02:00
rune 125e1698f7 Fix Git Sync race between startup pull and auto-sync export
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.
2026-08-04 08:52:48 +02:00
rune 3414e37e24 Add per-conversation notes.md
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.
2026-08-04 07:58:47 +02:00
rune 32e6ce3c37 Show release notes in-app instead of opening the web releases page
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".
2026-08-03 13:13:36 +02:00
rune 6480a50eee Sync folder structure via Git Sync (folders.json), plus bugs found testing it
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.
2026-08-03 11:48:23 +02:00
rune c3abc5a748 Update remaining oai.pm references to confab.no
Covers the per-file license-header comment (~80 Swift files) plus
the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
2026-08-03 08:44:35 +02:00
rune 2668555b98 Add nested (hierarchical) conversation folders
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.
2026-08-02 17:52:10 +02:00
rune 7f5d858b2a Add multi-select + bulk move-to-folder to both conversation lists
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.
2026-08-02 16:54:05 +02:00
rune 0e4389d272 Add live code formatting in the chat input
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.
2026-08-02 15:22:21 +02:00
rune 76b58e6fdc Rename app from oAI to Confab
"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.
2026-08-02 14:58:14 +02:00
rune e3557a87df Add unsaved-changes save prompt and crash-recovery draft
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.
2026-07-31 08:11:12 +02:00
rune 8db7820de2 Replace Jarvis API key hint with an oAI-Web blurb and Gitea link 2026-07-30 09:43:57 +02:00
rune 634b83f284 Add HTML and PDF conversation export
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.
2026-07-29 07:47:57 +02:00
rune 727fc7d6af Refresh sidebar folders when the advanced conversation list closes
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).
2026-07-29 07:19:27 +02:00
rune 72540305ad Fix deletions not sticking (git sync resurrection), persist folder
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).
2026-07-28 15:21:11 +02:00
rune 37734232f5 Decouple merge model picker from the main chat's active provider
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.
2026-07-28 14:57:19 +02:00
rune e898d4b6db Sort folders alphabetically; let users pick the merge model directly
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.
2026-07-28 14:39:18 +02:00
rune 03f84ebe01 Fix black text in drag preview when moving a chat to a folder
.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.
2026-07-28 13:30:53 +02:00
rune 7fbac5e809 Fix black row text after moving to a folder, remove duplicate chevron
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.
2026-07-28 13:25:40 +02:00
rune 8c0cfb87f5 Fix unused-result warning on dropDestination handlers
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 _ =.
2026-07-28 13:15:11 +02:00
rune 24847762c4 Make folders collapsible, draggable, bold, and add New Folder button
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.
2026-07-28 13:11:02 +02:00
rune 376fff4939 Add folders for organizing saved conversations
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.
2026-07-28 12:43:05 +02:00
rune 0642f3746f Add all-time usage statistics tab
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.
2026-07-28 12:35:45 +02:00
runeandClaude Sonnet 5 377e783a17 Mark Apple Intelligence provider as Beta throughout the UI
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>
2026-07-24 10:21:41 +02:00
runeandClaude Sonnet 5 30fbb4162e Revive Apple Intelligence provider (Phase 1 — on-device)
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>
2026-07-24 09:46:17 +02:00
rune e9aceca4e7 Remove now-dead isContactsHiddenPendingAppleFix kill switch
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.
2026-07-21 15:01:19 +02:00
rune 77cc646ee0 Re-enable Contacts row to retest on macOS 27 beta 4
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.
2026-07-21 14:16:03 +02:00
rune fc786d48f8 Sync starred models across machines; add automatic backup scheduling
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.
2026-07-21 13:19:50 +02:00
rune 86027001c7 Roll back native Help Viewer integration; fix duplicate View menu
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.
2026-07-20 08:17:33 +02:00
rune 98979584fb Fix dead Cmd+/ shortcut for In-App Help; rebind to Ctrl+Cmd+H
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.
2026-07-20 08:00:25 +02:00