2.5.4 #17

Merged
rune merged 45 commits from 2.5.4 into main 2026-09-26 11:38:42 +02:00
Owner

Quite a few changes...

Quite a few changes...
rune added 45 commits 2026-09-26 11:38:31 +02:00
HeaderView and FooterView both hand-rolled the same anti-pattern —
.background(.ultraThinMaterial) plus a manual hairline-border overlay
to fake a system chrome bar. Replaced with .safeAreaBar(edge: .top)/
.safeAreaBar(edge: .bottom) on ChatView's main content, which gives a
real system blur treatment and correctly reserves safe-area space for
the scrollable message list (the previous VStack-stacking approach
just visually sat the bars in the flow without adjusting scroll
insets). InputBar stays in the regular VStack flow, not pulled into
either safeAreaBar — it's a solid-surface text input area, not glass
chrome, matching the existing plan's scope (Phase 2 was HeaderView/
FooterView/ChatView only).

Verified .safeAreaBar's real signature against the SDK directly before
using it: it's safeAreaBar(edge:alignment:spacing:content:), a
required `edge:` label — the installed skill's own example code shows
it called positionally (`.safeAreaBar(.bottom) { ... }`), which
wouldn't actually compile. Confirmed both .top and .bottom (VerticalEdge
cases) are real and available at the deployment target.

Build, Release build (no warnings), and full test suite all clean.
- Apple Intelligence: macOS 26.x was failing on every single
  interaction with "context limit exceeded" — the 8,192-token budget
  was calibrated from a macOS 27 beta error and applied unconditionally.
  Now OS-version-gated (4,096 on 26.x, 8,192 on 27+).
- MCP "off" toggle wasn't actually a kill switch for file access:
  read_file/list_directory/search_files (and their write/delete/
  create/move variants) were registered and enforced purely off
  allowedFolders regardless of settings.mcpEnabled, so any other
  active integration (Mail, Paperless, bash, etc.) alone was enough
  to keep file access working with MCP toggled off. Now gated the
  same way every other integration already is.
- On-device tool selection: a "what's my newest email" question
  called paperless_search instead of any mail_search tool (and only
  "worked" because a copy of that email happened to be archived in
  Paperless). Tightened both tools' descriptions to disambiguate
  live Mail vs. the Paperless document archive.

Also fixed a pre-existing test (AppleDynamicToolTests) that depended
on MCPService.shared's ambient mcpEnabled state instead of building
its own fixture — broken by the registration-gating fix above.
switchToConversationModel's failure paths only ever surfaced as an
in-chat system message, never to the log file — so a silent skip (no
primaryModel on the loaded conversation) was indistinguishable in the
logs from a silent failure inside the function itself. Rune hit this
exact ambiguity live: switching from an Apple Intelligence chat to a
saved conversation that should have restored a different model
produced zero relevant log output. Logs both branches of the
performLoadConversation guard plus every early-return inside
switchToConversationModel, so the next repro pins down the actual
failure point instead of requiring another guess.
visible alert for failed model restore, stronger Paperless/Mail guard

- Apple On-Device was still hitting "context limit exceeded" on macOS
  26 on the very first message, even after the 4,096-token OS-gated
  budget — meaning the earlier fix was correcting the wrong variable.
  Found the real leak: effectiveSystemPrompt's excludeVerboseCustomization
  gate (added specifically to protect Apple's tiny context window) was
  already skipping the custom system prompt and Agent Skills for
  tool-capable Apple, but NOT toolUsageGuidelines — a ~1,500-char block
  written for the shared manual chatWithToolMessages() loop every other
  provider uses, that Apple's native session-based tool calling
  (respondWithTools) doesn't even go through. Gated it the same way.
  Also added a diagnostic log (tool count/names + instructions char
  count) right before session construction, since macOS 26.x's
  exceededContextWindowSize error carries no token/limit detail at all
  — if this doesn't fully resolve it, the next repro gives real numbers
  instead of another guess.
- switchToConversationModel's failure paths only ever showed an in-chat
  system message, which Rune reported as a "silent error" he didn't
  notice (only found the real cause — no Anthropic API key — via the
  log file). Added modelRestoreWarning on ChatViewModel, driving a real
  .alert() in ContentView, matching the existing Software Update /
  Intel Mac alert pattern, for every failure branch.
- paperless_search still got called for a live-email question on Apple
  On-Device even after the mail/paperless disambiguation fix, while the
  identical test correctly declined on a bigger provider — a small-model
  instruction-following gap, not a logic bug. Restructured the
  description to front-load the "not your inbox" prohibition (small
  models weight early tokens more) and made it not depend on mail_search
  actually being in the tool list, since the failing case had Mail
  disabled entirely.

Still open: the MCP-toggle-off test showed file content readable again
in one case — needs Rune to confirm whether Bash Execution was enabled
during that test before concluding it's a gap in the MCP gating fix
itself (bash_execute has its own independent toggle and would fully
explain it).
Root-caused the "MCP off, file still readable" report from live testing
2026-09-01 via Confab.log: it wasn't MCP at all — bash_execute ran
`cat /Users/rune/Downloads/test.txt` directly, outside the configured
`~/Code/` working directory. The MCP-gating fix from earlier today is
correct; this is a separate, real gap — bashWorkingDirectory only ever
set the shell's starting cwd (Process.currentDirectoryURL), it was never
enforced as an actual boundary, so any absolute path reaches anywhere
the user's own account can, "Require Approval" or not (once approved
once, it's approved to run — the directory was never in question).

Added a second, independent trust-boundary check ahead of the existing
approval flow: pathsOutsideWorkingDirectory(in:workingDirectory:) is a
best-effort heuristic (documented as such — shell variable expansion,
command substitution, and flag-embedded paths can all evade a purely
textual scan) that flags absolute/tilde/parent-traversal paths outside
the working directory. When it finds one, BashApprovalSheet shows a
distinct red warning naming the exact path(s), with its own Once/Allow-
for-Session choice (bashOutsideWorkdirSessionApproved) separate from
the general bashSessionApproved — approving one doesn't imply the
other. This check runs unconditionally, even when "Require Approval"
is off, since it's gating a different question (does this leave the
sandboxed area) than the general toggle (should every command be
reviewed).

11 new tests for the path-detection heuristic (absolute/relative/
tilde/parent-traversal paths, flags, URLs, quoted paths with spaces,
multiple escapes in one command).
FooterView's Text elements had no lineLimit, so narrowing the content
area (e.g. opening the sidebar) let them wrap onto 2-3 lines instead
of truncating, growing the safeAreaBar's height. Add .lineLimit(1)
throughout so they truncate with an ellipsis instead.
looksLikePathToken/resolvePathToken are pure string functions called
from the already-nonisolated pathsOutsideWorkingDirectory, but weren't
themselves marked nonisolated, triggering an actor-isolation warning
under the class's default @MainActor isolation.
Same conversion as Phase 2's HeaderView/FooterView: the 5 remaining
sheet footers (ConversationListView, ModelInfoView,
CombineConversationsSheet, AgentSkillEditorSheet, SkillEditorSheet)
dropped their manual Divider()-then-HStack bottom bar in favor of
.safeAreaBar(edge: .bottom), which gives real glass chrome instead of
a flat divider line. SettingsView's addExternalMCPServerSheet footer
is intentionally excluded — it isn't scroll-anchored the same way.
"navigate/open" hint used .tertiary, too low-contrast against the new
safeAreaBar glass background. Bumped to .secondary.
BashApprovalSheet, PersonalDataApprovalSheet, MailApprovalSheet,
GitSyncConflictSheet, and GitSyncManualFixSheet each hand-rolled the
same icon+title+subtitle header and outer padding/frame. New
ApprovalSheetChrome (Views/Components/) consolidates that, plus a
shared ApprovalInfoBox for the neutral "info display" boxes (ACTION,
AFFECTED FILES, sync path, etc) with a glass background.

BashApprovalSheet's COMMAND box wraps a ScrollView and deliberately
keeps the old flat Color.secondary.opacity(0.08) style instead of
ApprovalInfoBox — glass sitting behind a scrolling container causes
shimmer/refraction artifacts, a lesson already learned in Phase 1.
Colored warning/error banners (red/orange, semantic) are left flat
for the same reason tinted glass looked wrong in Phase 1: it renders
as an opaque solid block, not translucent glass.

Untinted buttons (Fix It Myself/For Me, Done) switched to native
.buttonStyle(.glass)/.glassProminent. Buttons that carry semantic
tint (Deny=red, Allow=orange) keep .bordered/.borderedProminent with
.tint(), since tinted glass has the same rendering bug as tinted
banners.

Debug build, Release build, and full test suite all clean.
Converted the flat-opacity chips that survive individual verification
against the "never glass inside a scrolling container, never tint
glass" rules established in Phases 1-4:
- EmailLogView's search bar background -> glassEffect
- JarvisView's queueStatusBadge (Paused/Running/Idle pill) -> glassEffect
- HelpView's search bar background -> glassEffect

Everything else surveyed in these 3 files stays flat and was left
alone, each for a documented reason: EmailLogRow's background and
JarvisView's prompt-preview/totals-row/output-block backgrounds all
sit inside a ScrollView; the "Disabled" badge and Jarvis credits card
carry a semantic tint (orange/blue); HelpView's keyboard-shortcut
badges, category containers, and example snippets are all nested
inside its main ScrollView.

Also converted 2 footers that match the exact Divider()+HStack
pattern fixed across 5 other sheets in Phase 3, but were missed from
that survey: EmailLogView's "N entries / Clear All" bar and HelpView's
"Done" bar both now use .safeAreaBar(edge: .bottom).

Debug build, Release build, and full test suite all clean.
The "Jarvis is oAI-Web..." blurb and Gitea link were nested inside
the enabled-only if-block, so a user who hadn't turned Jarvis on yet
had no way to learn what it is from Settings. Moved it out to always
render, matching how the other integration tabs describe themselves
regardless of toggle state.
Swept every file under oAI/Views/ for Button/Menu controls whose
visible label is only an SF Symbol icon (no accompanying text), and
added .help(...) to the ones still missing it — 13 in total:
copy-code/message/description/output buttons, delete-agent,
select/deselect-conversation, remove-folder, delete-server,
add/remove key-value pair, and the sync/email password/token
visibility toggles.

Left alone deliberately: any control that already shows visible
text (its purpose is already obvious), and the app-wide
close-sheet (xmark.circle.fill) and clear-search-field patterns,
which appear consistently untooltipped across ~15+ existing sheets
already — treating that as an established, deliberate convention
rather than a gap to fix piecemeal.

Translated all 13 new tooltip strings into nb/sv/da/de/fr, added
directly to Localizable.xcstrings (xcodebuild -exportLocalizations
doesn't extract .help(...) literals, only Text() calls, so this
needed a manual pass rather than the export tool). Inserted via
surgical text insertion rather than a full JSON round-trip, since a
full json.load/dump round-trip reorders unrelated existing entries'
language keys and produces a 1000+ line spurious diff.

Debug build, Release build, and full test suite all clean.
The Help Book and the in-app Help panel both displayed shortcuts that
didn't match what oAIApp.swift actually binds:
- Help Book showed ⌘? for opening Help; the real binding requires
  Shift too (physically Cmd+Shift+/, which macOS renders as ⌘? without
  an explicit shift glyph since "?" already implies it — but reads as
  ambiguous on its own, so spell it out as ⇧⌘?).
- HelpView's own in-app shortcuts list showed ⌘H for Command History,
  contradicting both the real ⇧⌘H binding and CLAUDE.md's own
  documented note that bare ⌘H is reserved by macOS for Hide
  Application.
New "Show Sidebar"/"Hide Sidebar" item in the Chat menu, bound to
⌃⌘S — the standard macOS "Toggle Sidebar" combo, unclaimed elsewhere
in this app. ContentView's NavigationSplitView columnVisibility is
private @State with no direct reach from oAIApp.swift's Commands
closure, so ChatViewModel.sidebarVisible mirrors it: ContentView syncs
both directions via onChange, SettingsService.sidebarVisible stays the
persisted source of truth.
Every sensitive field in Settings had its own reveal affordance —
some had none at all (an always-hidden SecureField: the 5 general API
keys, MCP bearer token, env/header pair values), some a "Show"/"Hide"
text button (Paperless token, Anytype key, Jarvis key), and 3 already
had the eye icon (Git Sync password/token, Email password) but each
duplicated the same if/else TextField-vs-SecureField block inline.

New shared RevealableSecureField (Views/Components/) replaces all 13
call sites with one consistent look: hidden by default, an eye button
toggles a plain TextField in place of the SecureField. Callers keep
their own .onSubmit/.onChange persistence logic attached externally —
.textFieldStyle/.font/.onSubmit propagate through via SwiftUI's
environment same as before, and .onChange just watches the external
binding regardless of where it's attached, so nothing needed to move
inside the component.

Removed the 6 now-dead showXxx @State toggles this replaces.

New "Show key"/"Hide key"/"Show value"/"Hide value" tooltip strings
(plus "Show Sidebar"/"Hide Sidebar" from the previous commit) added to
Localizable.xcstrings for nb/sv/da/de/fr via surgical text insertion,
not a full JSON round-trip (see feature_tooltips_pass precedent for
why: a round-trip reorders unrelated entries and produces a spurious
diff). "Show password"/"Hide password"/"Show token"/"Hide token"
already existed in the catalog and were reused as-is.
Rune corrected the previous fix: oAIApp.swift's .keyboardShortcut("?",
modifiers: .command) binding was never broken — AppKit resolves both
the required physical keys and the rendered glyph dynamically based on
the Mac's active keyboard layout, so the same code genuinely plays out
as ⌘? on an English layout and ⇧⌘? on Norwegian/German/Danish layouts.
Hardcoding either spelling in the (English-only, non-localized) Help
Book and HelpView's shortcuts list was only ever correct for some
readers.

Both docs now note the layout dependency instead of picking one
spelling. Also added an entry for this shortcut to HelpView's in-app
list, which didn't list it at all before.
Confirmed via a read-only CGWindowListCopyWindowInfo query against the
live process (no window in the on-screen list at all, not an off-screen
one) that the 2026-08-18 self-heal fix only covers repositioning an
existing-but-off-screen window — it does nothing when the app has zero
windows left, which Dock-click/Cmd-Tab reopen then also failed to
recreate. Likely cause: implementing applicationShouldHandleReopen
ourselves appears to suppress SwiftUI's own built-in "recreate a
WindowGroup window" behavior instead of layering on top of it, and
since New Window was intentionally replaced with New Chat, there was no
other way back short of quitting and relaunching.

repositionOffscreenWindows now reports whether it found any eligible
window; when it finds none, both applicationDidBecomeActive and
applicationShouldHandleReopen fall back to opening a fresh one via
SwiftUI's openWindow action, wired from ContentView.onAppear the same
way chatViewModel already is. Also deminiaturizes a minimized window
before centering it, another gap the old geometry-only check missed.
FileLogger filters by minimumLevel, and the heartbeat was logged at
.info — so it showed up at both the Info and Debug log-level settings,
not just Debug as intended for a purely diagnostic line.
Links inherited the .primary foreground color set globally on the
Markdown view, making them visually identical to surrounding text even
though they were already tappable. Adds a targeted style override for
the link role using the existing blue accent color.
Filters ExternalMCPManager.cachedToolSchemas by the server's slug
prefix (already computed there, no new state needed) and shows a
"N tools" label plus a scrollable ~3-line list of names below the
server's command line. Long lists scroll rather than truncate — tool
names are never elided.
New "Export as JSON…" menu item alongside the existing Markdown/HTML/
PDF ones, wired through exportConversationWithSavePanel(format: "json",
...). Replaces the old ad-hoc JSONSerialization dict in both that path
and /export json with a real Encodable shape that also carries
timestamp, tokens, cost, and modelId per message.

When exporting to JSON and the current session still has in-memory
tool-call details (Message.toolCalls — never persisted to the
database, by design), an NSSavePanel accessory checkbox lets the user
include them; the /export json slash command always includes them
when present since it has no UI surface for a toggle. Older, reloaded
conversations simply have nothing to include, since that data doesn't
survive past the session it was generated in.
New Chat ▸ Generate Image… opens a sheet that adapts to the selected
model: a resolution and aspect ratio picker appear only when the
model's /images/models metadata actually declares options for them
(hidden otherwise), plus an always-available reference image picker
(up to 14 images, image-to-image editing) for models that support it —
unsupported models just surface OpenRouter's error in the resulting
chat message, same as any other generation failure.

OpenRouterProvider.generateImage gains optional resolution/aspectRatio/
referenceImages parameters, encoded into the existing /images request
body (resolution, aspect_ratio, base64 input_references). Request-body
construction is pulled into a pure imageRequestBody(...) function for
testability, and ImageModelData's supported_parameters decoding is
deliberately lenient: the exact schema wasn't verified against a live
response (OpenRouter's reference docs 404'd during research, only a
blog summary was available), so an unexpected shape there degrades to
"no resolution/aspect-ratio options for this model" instead of losing
the model — or the whole /images/models response, since the top-level
decode is wrapped in try? — over one malformed field.

Generating from the dialog switches to the chosen model via the
standard selectModel() path if it differs from what's active, then
inserts a normal .user message (prompt + reference images as regular
FileAttachments) before the assistant's image response, so the result
reads as an ordinary conversation turn — exportable, star-able, no
special-cased message type.
NSSavePanel gives a bare NSButton assigned directly as accessoryView
its standard-control chrome, including divider rules above and below.
Wrapping the checkbox in a plain container view instead avoids that —
same control, no surrounding lines.
A new button appears above the paperclip in the input bar whenever the
selected model supports image generation, opening the same Generate
Image… dialog as the Chat menu item.

The dialog also gains an optional "Prompt Builder" section (Subject,
Setting, Style, Lighting, Composition — the standard image-prompt
structure) with a Compose Prompt button that joins the filled-in
fields into the Prompt field below. These aren't API parameters (only
resolution/aspect_ratio/input_references are), just a writing aid —
the composed text stays freely editable afterward.
640x820 minimum (was 480x480) with wider/taller ideal sizes — Model,
Prompt Builder, Prompt, Options, and Reference Images all fit without
feeling cramped or needing as much scrolling.
The single-column Form with this many sections ran taller than a 13"
laptop's usable screen (behind the menu bar, above the Dock) even
after raising minHeight once already. Splits into two side-by-side
Forms — Model + Prompt Builder on the left, Prompt + Options +
Reference Images on the right — cutting the required height
significantly while giving the Prompt text box more room instead of
less. New size: 860x520 minimum (was 640x820).

Note for Rune: a sheet's position (anchored to the parent window,
undraggable) is standard macOS sheet behavior, not something this
dialog controls — the actual fix here is not needing that much height
in the first place.
The session's new UI text never went through the i18n pass — 27
strings across ImageGeneratorView, the external MCP tool count, and
the Export as JSON… menu item, translated into nb/sv/da/de/fr and
added to Localizable.xcstrings.

Also fixes two real localization bugs introduced along the way:
Text(pickerError) rendered a plain String variable, which silently
skips localization (Text(stringVariable) never routes through
LocalizedStringKey) — replaced with a Bool flag and a literal Text()
instead. And the MCP tool count used a manual singular/plural ternary
instead of the project's established "^[\(count) tool](inflect: true)"
syntax, which turned out to already exist as a translated key from
prior work — reused it rather than adding a duplicate.
Collapse state was applied unconditionally to rendering, including
while actively searching. A match inside a collapsed folder (or
nested inside a collapsed ancestor) computed correctly in
filteredConversations but never rendered a row — and since that list
wasn't actually empty, the "No Matches" empty state didn't show
either, so it silently looked like search found nothing.

Extracted the collapse-during-search rule into a testable static
func (effectiveCollapsedFolders) and applied it everywhere collapse
state affects rendering: folder header visibility, row visibility,
the chevron, and shift-click range selection order.
SidebarView has its own independent copy of ConversationListView's
folder-tree rendering (a separate view, not a shared component), so
962b9eb's fix only covered the 'Open Chat…' modal — the main sidebar
search still had the exact same bug: collapse state applied
unconditionally to rendering regardless of active search.

Reuses ConversationListView.effectiveCollapsedFolders(_:searchText:)
as the shared source of truth instead of duplicating the logic.
The single biggest confirmed i18n gap (see project_roadmap_priorities
memory): showSystemMessage(_ text: String.LocalizationValue) is
technically localizable at the type level, but xcodebuild
-exportLocalizations only regex-extracts literal Text(...) calls, so
none of its ~90 call sites in ChatViewModel.swift were ever caught by
any past localization pass — they always rendered in English
regardless of app language.

Wrote a paren-depth-aware Swift literal scanner to extract every
showSystemMessage(...) call site (handling string interpolation,
ternaries of two literals, and inflect syntax), diffed against the
existing catalog, and translated the 87 genuinely missing keys into
nb/sv/da/de/fr. 5 multi-placeholder keys use explicit %1$@/%2$@
positional indices (plus an explicit 'en' entry), matching this
catalog's existing convention for multi-arg strings. Slash-command
tokens (/mcp, /save, etc.) kept literal per CLAUDE.md's i18n rules;
surrounding prose translated normally.

Inserted purely additively (new blocks appended after the "strings"
anchor) per the technique in the localization memory notes — verified
zero deletions in the diff.
Second confirmed structural gap from project_roadmap_priorities: this
func returned hardcoded English literals interpolated straight into
showSystemMessage(...), never routed through localization at all —
unlike showSystemMessage's own gap (fixed in 4925a9e), this one wasn't
even typed to opt into it.

Wrapped each literal return in String(localized:) so it now resolves
against the catalog like everywhere else; added the 9 distinct
messages (one, the timeout message, is returned from two branches) in
nb/sv/da/de/fr. Left the raw passthrough fallback (return desc) and
the Hugging Face branch's dynamic 'base' prefix alone — both are raw
upstream API/error text, not app copy.

Did NOT change Settings.Provider.displayName's type (the other 'small'
item on the same roadmap line) after checking it: every value it
returns is a brand name (OpenRouter, Hugging Face, Apple Intelligence,
...) which this project's own conventions already exclude from
translation, so String and LocalizedStringKey render it identically
today. Retyping it would touch many call sites for zero visible
change, with real risk of breaking string-interpolation call sites
(LocalizedStringKey doesn't interpolate like String) — not worth that
blast radius for a cosmetic type mismatch with no behavioral bug
behind it.
Completes the structural gap from project_roadmap_priorities: wrote a
scanner covering Button(/.help(/Label(/Toggle(/Link(/SecureField(/
TextField(/CommandMenu(/Picker(/Menu(/NavigationLink(/Section(/.alert(/
.navigationTitle(/.confirmationDialog( literal call sites across all
108 Swift files, diffed against the catalog. Found 64 candidates;
manually verified each against its actual source context (not just
the extracted string) before classifying — 41 were genuine UI copy
never localized (folder context menus, Apple Intelligence status
badges, export menu, GitSync conflict/manual-fix sheet buttons, Node
install sheet, stats section headers, etc.), 23 were correctly
excluded per CLAUDE.md (TextField placeholders showing example URLs/
ports/paths, the 'safaridriver'/'Safari' external-MCP preset names,
model-ID examples), and 1 ('Increment') was a scanner false positive
— a Button(...) call inside MockData.swift's mock chat *content*
string, not real UI.

Also fixed a real manual-plural bug found along the way in
CombineConversationsSheet.swift: "(\(count) messages)" hardcoded
the English plural instead of using the project's inflect syntax —
now ^[\(count) message](inflect: true), matching CLAUDE.md's stated
plural convention.
Rune caught these by actually using the nb build — a category my
earlier sweep today (c525496) missed entirely: literal strings inside
custom LocalizedStringKey-typed computed properties/functions
(MCPSubsection.label, tabTitle(), mcpStatusLabel(), syncText, etc.)
and literal arguments passed to custom helper functions like
tabButton(label:). xcodebuild -exportLocalizations only regex-extracts
direct Text("literal") calls — a  inside a
switch inside a var/func typed -> LocalizedStringKey is invisible to
it even though the type is perfectly correct, same root cause as the
historical FooterView 'Last sync' gotcha in the localization memory
notes, just never swept for this file before.

Wrote two more targeted scanners (computed-property bodies typed
LocalizedStringKey; known custom helper call sites: row/sectionHeader/
tabButton/detailRow/outputBlock/editorField/infoRow/costExample/
capabilityBadge/step/show) and manually verified every hit against
its real source context before translating. 32 genuine strings found
and translated into nb/sv/da/de/fr: all 9 Settings tab names, the MCP
sidebar's File System/External MCP/Research Agents labels, the
Research Agents section's body+cost-warning text (the actual bug
Rune's screenshots showed — completely untranslated, not just this
file's tab labels), MCP connection status badges, git sync status
text, and 4 GitSyncManualFixSheet instructions.

Also fixed 2 self-inflicted duplicate-key bugs found while verifying:
3 of the 32 keys (Research Agents + its two body strings) already
existed as pre-existing EMPTY stub entries elsewhere in the file —
inserting a new block at the top created a duplicate key, and since
JSON has no duplicate-key protection, the empty stub (appearing later
in the file) silently won on parse, discarding my translations. A 4th
duplicate (unrelated to this batch) was found the same way: 'Click to
re-save "%@"' already existed with real prior translations job
elsewhere in the file; removed my redundant copy, kept the original.
Both classes of duplicate verified now-absent via a full top-level-key
uniqueness scan, and a full-catalog scan confirms zero keys are left
untranslated in all 5 languages.
Third distinct blind spot found this session, after computed
properties (87616ad) and known custom functions (c525496): a literal
string passed as a LABELED ARGUMENT TO A CUSTOM VIEW STRUCT'S INIT
(FooterItem(label:), StatusPill(label:), StatRow(label:),
EmailStatItem(title:), CreditRow(label:), CommandCategory(name:)) is
invisible to xcodebuild -exportLocalizations for the same reason as
the others — it only regex-extracts direct Text("literal") calls.
This is what Rune's footer screenshot showed: 'Messages:'/'Tokens:'/
'Cost:' rendering in English inside an otherwise-Norwegian footer.

Found by grepping every `let/var x: LocalizedStringKey` stored
property declaration (not just computed ones this time), resolving
each to its containing struct name, then scanning the whole codebase
for that struct's init call sites with a literal in the matching
labeled position. 25 genuine strings across FooterView/StatsView/
EmailLogView/CreditsView/HelpView, translated into nb/sv/da/de/fr.

Also widened the MCP tab's sidebar from a fixed 180pt to 220pt.
Root cause of the "Forskningsagenter" wrap Rune's screenshot showed
wasn't translation-specific — the *English* "Research Agents" wrapped
mid-word too, in his very first screenshot this session. 180pt was
already too tight for an 18-character unbroken compound word
("Forskningsagenter"/"Forschungsagenten"); 220pt gives real headroom
for every current label in every shipped language. The 900-1000pt
Settings window has ample room to absorb the extra 40pt.

Added a duplicate-key guard to this batch's insertion script (refuses
to run if any new key already exists anywhere in the file) after
finding and fixing that exact bug in the previous commit — verified
zero duplicate top-level keys and zero keys missing all 5 languages
across the whole 915-key catalog after this insert.
Users can report bugs, request features, or ask questions from a new
/feedback sheet. Submissions POST to confab-feedback-relay (a separate
self-hosted service that holds the real Forgejo token) instead of
shipping any API key inside the app — no secret is embedded client-side.

- FeedbackService: posts to the relay, validates input, maps HTTP errors
- FeedbackView: category/title/description form, success/error states
- Wired into /feedback command, InputBar dropdown, and HelpView
- Localized into nb/sv/da/de/fr
- FeedbackServiceTests covers validation and error-mapping logic
Rune's reverse proxy for the relay lives on a separate machine and will
front it as its own subdomain, not a path under gitlab.pm.
No keyboard shortcut, per Rune's request. Localized into nb/sv/da/de/fr.
macOS already provides "Send Confab Feedback to Apple" in some contexts;
renamed ours to "Send Confab Feedback to Developer…" so it's clear at a
glance where each one actually goes.
Under -default-isolation=MainActor, FeedbackResponse's synthesized
Decodable conformance was main-actor-isolated, which mapHTTPError (a
nonisolated static func) can't use — a warning today, a hard error
under Swift 6 language mode.
rune merged commit 877706dede into main 2026-09-26 11:38:42 +02:00
Sign in to join this conversation.
No reviewers
No labels
user-feedback
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
rune/oai-swift!17
No description provided.