Commit Graph

319 Commits

Author SHA1 Message Date
Alan Wizemann 54a0797334 M9 #4.6 (pass-2): Dashboard Overview/Sessions split + chat project bar
Pass-2 feedback bundled into one architectural commit:

1. **Project indicator moved out of the nav-bar principal slot.** The
   iPhone nav bar's .principal area gets squeezed to icon-only when
   adjacent toolbar buttons exist — the result was a folder icon with
   no project-name text, which is worse than no indicator at all. New
   `projectContextBar` renders a full-width tinted strip BELOW the
   nav bar when a session is project-attributed: "Project chat"
   caption + folder icon + full project name. Scrolls away with the
   message list. Pattern cribbed from Slack's channel-topic header
   and Apple Mail's sender strip.

2. **Dashboard split into Overview + Sessions sub-tabs.** Segmented
   picker at the top. Overview = stats + 5 most-recent sessions for
   at-a-glance; Sessions = the deeper 25-session list with a project
   filter. `See all` button on Overview's Recent Sessions header
   switches tabs. Addresses pass-2 complaint: "The dashboard might
   need tabs to break it down better."

3. **Project filter on the Sessions sub-tab.** Menu picker (scales
   to N projects; segmented doesn't). "All projects" clears; each
   project entry filters to sessions attributed there. Uses the same
   attribution map loaded once in `IOSDashboardViewModel.load()`, so
   filtering is an O(n) in-memory pass over 25 sessions — no extra
   SFTP traffic. Addresses pass-2 complaint: "we should add a filter
   to the sessions selector in the dash to see by project."

4. **`IOSDashboardViewModel` exposes the wider surface:**
   - `allSessions` (25-session window, feeds the Sessions tab)
   - `allProjects` (project registry, drives the filter menu)
   - `sessions(filteredBy: String?)` helper — accepts a project name
     (nil = all), returns filtered subset.

Mac parity note from the earlier commit message still stands — Mac's
global Sessions list doesn't currently filter by project either.
That's a parallel post-TestFlight followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:30:11 +02:00
Alan Wizemann 9a4473333b M7 #17 (pass-2): empty-transcript UX + defensive project chip
Pass-2 observations:
1. Resumed sessions from Dashboard loaded into chat but showed no
   message history.
2. On sessions WITH a project badge, the chat nav-bar chip rendered
   the folder icon but no project name.

**Root cause for (1)** — not actually an iOS bug. ACP-native sessions
(the kind ScarfGo starts) don't persist their transcript to the
client-visible `state.db` — only CLI/terminal sessions leave
history there. Confirmed by direct SQLite inspection: the session
IDs in Dashboard's Recent Sessions show `message_count = 0`; the
sessions with lots of messages are all older CLI sessions. The Mac
has this same limitation — just less visible because Mac's Sessions
list surfaces CLI sessions preferentially.

What we fix on the UX side: a friendlier empty state when a resumed
session has no persisted transcript. Replaces the blank canvas with
an icon + "Session resumed" + explanatory caption ("Hermes has the
context for this session, but the transcript isn't cached locally.
Send a message to continue.") Nudges the user toward the right
mental model instead of leaving them wondering why their history
vanished. Gated on `sessionId != nil` so fresh-chat empty state
stays the same.

**Root cause for (2)** — `ProjectEntry.name` shouldn't be empty, but
a defensive treatment avoids ever surfacing a folder-only chip on
edge cases (registry race, partial JSON decode). startResuming now:
- Clears `currentProjectName` eagerly at the start of the resume
  flow so a lingering name from a prior session doesn't flash onto
  the new header.
- Treats empty strings as nil when the lookup returns one.
And the toolbar renderer adds a `!projectName.isEmpty` guard so an
unexpected empty string never produces an icon-only chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:52:42 +02:00
Alan Wizemann d2633fb92d M7 #16 (pass-2): don't bubble CancellationError into the chat banner
Pass-2 observed a spurious
"The operation couldn't be completed. (Swift.CancellationError error 1)"
banner appearing even after the resumed session loaded cleanly.

Root cause: when ChatController.startResuming tears down a prior live
session via `await stop()`, the in-flight event-task awaits throw
CancellationError as they unwind — that's how Swift concurrency
cooperatively cancels. That error then propagated through
recordACPFailure to the visible banner, even though nothing actually
failed.

Filter CancellationError (and the URL-loading equivalent,
NSURLErrorCancelled) out at the recordACPFailure boundary. Real
errors still flow through to the banner with hints + stderr details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:44:18 +02:00
Alan Wizemann 3b3c037fce M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows
Pass-2 UX feedback: "When selecting a per-project chat, we should
update the chat interface to show that we are 'in a project' — and
label them in the sessions list so the user can see the session
and understand what project it belongs to."

Two related changes:

**In-chat indicator** — ChatController gains `currentProjectName`,
set by `resetAndStartInProject` (direct: we have the ProjectEntry)
and by `startResuming` (resolved via SessionAttributionService +
project registry lookup). ChatView's toolbar uses a `.principal`
ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")`
subtitle underneath when attributed. Mirrors Mac's SessionInfoBar
project-chip pattern but fits the iOS nav-bar real estate instead
of eating a full-width horizontal row.

**Dashboard row labels** — `IOSDashboardViewModel.load()` now does
one additional SFTP read per refresh: pulls the session→project
sidecar + project registry, maps session id → project display name
into `sessionProjectNames`. Row renders a small tinted folder
capsule when attributed. Batched so row renders are O(1) dict
lookups — no extra SFTP traffic per cell. Silent on failure
(attribution is cosmetic).

Not in scope for this commit: Mac's global Sessions list doesn't
currently show project attribution either — that gap exists on
both platforms, but wiring Mac's ProjectsSidebar + SessionsView
for per-row labels is a bigger surgery. Scoped as a post-TestFlight
followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:38:02 +02:00
Alan Wizemann 1c2939dbbe M7 #15 (pass-2): load transcript from state.db on session resume
Pass-2 observation: "When selecting a previous session from the
dashboard, the chat opens, loads, but starts fresh — we should
load the session with previous work like we do on the mac..."

The Mac's resume path does two things: (a) call session/resume on
ACPClient to re-bind Hermes to the session id, and (b) call
`richChatViewModel.loadSessionHistory(sessionId:acpSessionId:)` to
pull the persisted transcript out of state.db and populate the
message list. ScarfGo only did (a) — the ACP channel was wired up
correctly, but there was no SQLite read, so the UI showed an empty
bubble list until the user sent their first new prompt.

Added the loadSessionHistory call right after setSessionId in
ChatController.startResuming. It internally calls `dataService.refresh()`
first so the snapshot reflects whatever Hermes wrote between the
Dashboard's last SQLite pull and the resume tap. The acpSessionId
param is nil when resume preserved the id (no origin-vs-ACP split
needed) and set to the resolved id otherwise so the CLI + ACP
message streams can be merged chronologically — same behaviour the
Mac gets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:33:18 +02:00
Alan Wizemann f3c4bc56e9 M7 #14 (pass-2): keep ACP session alive across tab switches
Pass-2 observation: "when a user switches away from chat and comes
back, there is a loading time — should we keep it open so there
isn't a reload needed?"

Removed the .onDisappear { controller.stop() } hook. TabView unmounts
tab content on switch (disappear fires), but @State keeps the
ChatController alive — so dropping the SSH exec channel + re-
opening on next appear was costing a ~1-2s reconnect every time
the user bounced Dashboard → Chat → Memory → Chat.

Cleanup still happens correctly because ChatController's lifetime
is tied to ChatView's parent (ScarfGoTabRoot). When the user
Disconnects/Forgets from the More tab, RootModel flips out of
.connected, the whole tab root unmounts, and the controller + its
ACPClient tear down via .deinit. Background termination is handled
by iOS naturally.

A comment in the file documents why we no longer tear down on
.onDisappear — easy to re-add if a future iPad / multi-window
variant wants explicit idle-pause behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:31:20 +02:00
Alan Wizemann 723ef6743d M7 #13 (pass-2): suppress empty assistant bubble during reasoning-only frames
Pass-2 turned up a ghost-message UX bug we missed in pass-1: every
"Thinking…" reasoning disclosure had an empty gray bubble next to
it. Happens because assistant messages exist momentarily in a
reasoning-only state (chunks of thinking text arrive before any
primary content), and the bubble path always rendered its padded
background regardless of content.

Gate the bubble render on non-empty content for assistant messages.
User bubbles still always render (the user explicitly submitted
content and saw it land — suppressing it on trim-empty would be
surprising). `trimmingCharacters(in: .whitespacesAndNewlines)` so
purely-whitespace assistant frames also don't render a bubble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:29:39 +02:00
Alan Wizemann 444d43dea8 M9 #4.4: APNs client skeleton (capability disabled, plumbing ready)
Ships the iOS-side scaffolding so a future Hermes push sender can
light ScarfGo up with no client-side surgery. Keeps the Push
Notifications capability in the Xcode target OFF until:

1. Apple Developer Program enrollment + APNs auth key are set up
   (out of scope until TestFlight).
2. Hermes gains a `hermes register-device` endpoint + per-event
   sender (new cron job result, new pending permission). Upstream
   work, hasn't been specced.

What's now in the tree, ready to flip on:

- `Notifications/APNSTokenStore.swift` — actor-backed singleton that
  captures the device-token hex string from a successful remote
  registration. Logs for now (no server to POST to yet); has a TODO
  marker at the spot where the real HTTPS POST will land.
- `Notifications/NotificationRouter.swift` — UNUserNotificationCenter
  delegate that handles:
  - foreground presentation (always show banner + sound);
  - default tap → route to Chat tab with resume sessionID if
    included in the payload (via the existing ScarfGoCoordinator);
  - `APPROVE_PERMISSION` / `DENY_PERMISSION` action buttons on
    notifications in the `SCARF_PENDING_PERMISSION` category, with
    Face ID / passcode required (`.authenticationRequired`). Action
    handlers log today; the real one-shot ACPClient respond-and-die
    flow is scoped out until the sender pipe exists.
  - Local-notification plumbing: `registerCategories()` +
    `setUpOnLaunch()` (requests .alert/.sound/.badge permission).
  - `registerForRemoteNotifications` deliberately commented out.
    Turning it on without the capability surfaces as runtime
    "no valid aps-environment entitlement string found" — waiting
    keeps logs clean.

Wired at ScarfIOSApp launch via a `.task` on RootView — harmless on
denial, authorization dialog only shows once. ScarfGoTabRoot sets
the router's `coordinator` weak ref on appear so notification-taps
can cross-tab route. When the capability ships, the remaining work
is one call (`UIApplication.shared.registerForRemoteNotifications()`)
inside `setUpOnLaunch`'s `granted` branch + the AppDelegate hooks for
token delivery + a sign-in style payload build in APNSTokenStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:12:56 +02:00
Alan Wizemann 9bfaaf20f0 M9 #4.3: scoped Settings editor via hermes config set
Pass-1 feedback: "Settings loads, but no fields are editable." By-
design read-only in M6, but the on-the-go story is weaker without
at least the core model / approval-mode / display toggles editable.

Not a generic YAML round-trip editor — that was ruled out in the
original iOS plan because comment/order preservation requires
Hermes-side changes or a significant YAML library. Instead:

- Curated v1 list of 7 editable keys: model.default, model.provider,
  approvals.mode, agent.max_turns, display.show_cost / show_reasoning
  / streaming. Covers ~80% of actual "I want to change this right
  now while I'm away from my Mac" scenarios.
- IOSSettingsViewModel.saveValue(key:value:) shells out to
  `hermes config set <key> <value>` over the SSH transport's
  runProcess, reusing the same PATH-prefix trick we added in pass-1
  for hermes acp so the remote shell finds hermes even in non-
  interactive mode. Hermes owns the YAML round-trip; Scarf just
  picks the value.
- SettingEditorSheet renders the right control per key: Toggle
  (booleans), segmented Picker (approval mode), Stepper (max_turns),
  TextField (model / provider / timezone). One sheet, four kinds
  of input, driven by a `SettingSpec.Kind` enum.
- SettingsView gets a "Quick edits" section at the top that lists
  the 7 keys with their current parsed values + an edit affordance.
  The existing 10+ read-only sections stay unchanged — editing stays
  scoped to the keys we curated.
- On save, the VM calls `load()` again so the parsed config (and
  therefore the Quick-edits labels + the read-only sections below)
  reflects the new value immediately.
- Errors from `hermes config set` (non-zero exit) surface inline on
  the sheet via SettingsSaveError.commandFailed.errorDescription,
  carrying stderr/stdout combined so the user sees what the remote
  complained about. Sheet stays open on error for retry.

ScarfGo builds green. Mac Settings is unaffected — this feature is
iOS-only (Mac has its own richer editors via HermesFileService).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:10:30 +02:00
Alan Wizemann 226b6e26be M9 #4.2: project-scoped chat + shared SFTP parity services
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:

1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
   spawn hermes acp with cwd = project.path, record the attribution.

Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):

- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
  ScarfCore. Public struct. Mac consumer unchanged (imports it via
  ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
  Was already transport-backed, so the port is straight lift-and-
  shift: made public, added #if canImport(os) guards around the
  Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
  VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
  marker-splice logic for the Scarf-managed region of AGENTS.md:
  - applyBlock(_:to:) — pure text splice with 3-case handling.
  - writeBlock(_:forProjectAt:context:) — transport-backed write.
  - renderMinimalBlock(projectName:projectPath:) — iOS-side block
    composer (no template-manifest or cron-attribution fields — iOS
    doesn't yet surface those concepts; markers + identity headers
    match Mac output byte-for-byte so a project scaffolded on iOS
    round-trips cleanly through the Mac).

Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.

ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
  In project…). Loads the project list over SFTP via
  ProjectDashboardService (already transport-backed, works on iOS).
  Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
  session, writes the minimal context block to <project>/AGENTS.md
  over SFTP, spawns hermes acp with cwd = project.path, records the
  attribution via SessionAttributionService. Non-fatal on block-
  write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
  projectPath + projectName, so the regular start() and the new
  project path share one ACP setup path. Attribution write happens
  after newSession returns and the sessionId is known.

Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:07:40 +02:00
Alan Wizemann ff6ea4f6dc M9 #4.1: session resume — Dashboard row tap opens Chat in resume mode
Pass-1 showed Dashboard's Recent Sessions list as a read-only
marquee — tapping a row did nothing. The natural user expectation
is "take me back to that conversation." Users were opening a new
chat every time, defeating the point of having a phone client for
an already-running agent.

Added a tiny cross-tab coordinator (ScarfGoCoordinator) modeled on
the Mac app's AppCoordinator pattern:

- `@Observable` carrier, injected via `.environment` at ScarfGoTabRoot.
- `selectedTab` drives TabView selection (bound with `.tag` on each
  tab).
- `pendingResumeSessionID` is set by Dashboard row taps; consumed
  by ChatView in `.task` / `.onChange` and cleared immediately so
  later neutral tab switches don't accidentally re-resume.

ChatController gets a new `startResuming(sessionID:)` entry point
that mirrors `start()` but calls `session/resume` (falling back to
`session/load` if the remote Hermes is < 0.9.x). The rest of the
session lifecycle is identical so the event stream + error banner +
PATH wrap all stay in force.

Dashboard Recent Sessions rows now wrap in Button with `.buttonStyle(.plain)`
and fire `coordinator?.resumeSession(session.id)` on tap.

First usable on-the-go workflow: tap app icon → pick server → tap
Dashboard → see recent sessions → tap one → land directly back in
that conversation, full transcript loaded. No new-chat ceremony.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:00:40 +02:00
Alan Wizemann 9c2e9279cc M9 #3: flush UserHomeCache on soft disconnect
Full ConnectedServerRegistry was scoped out of this phase — SwiftUI
view lifecycle already tears down transports via .onDisappear when
ScarfGoTabRoot unmounts on state transition to .serverList. Adding
a formal registry that tracks every active transport per ServerID
is complexity without proven UX payoff right now (can revisit post
pass-2 if users hit stale-connection bugs).

One real cleanup we should always do on soft disconnect: invalidate
the shared UserHomeCache entry for the server we're leaving. The
cache lives forever otherwise, and a hypothetical scenario where
the remote user's home directory changes between sessions would
surface as SFTP paths resolving to the wrong directory. Rare, but
free to fix.

`RootModel.softDisconnect()` now calls the new static
`ServerContext.invalidateCachedHome(forServerID:)` before flipping
state to `.serverList`. Static form is a convenience for callers
that have the ServerID in hand but not a full ServerContext (avoids
forcing a round-trip through config store just to rebuild the
context we're already discarding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:56:57 +02:00
Alan Wizemann bb399e6d35 M9 #2+#4: ServerListView root + ServerID-aware onboarding
ScarfGo now boots into a list of configured servers instead of the
single-server Dashboard. Each row renders nickname + user@host:port,
taps to connect, swipes to forget. A "+" toolbar button re-enters
onboarding for a new server. Fresh install → straight to onboarding.

RootModel state machine redesigned around the multi-server world:

- `.loading` → `.serverList` when listAll() returns 1+ servers.
- `.loading` → `.onboarding(forNewServer:)` on fresh install.
- `.serverList` → `.onboarding(newID)` via "+" button.
- `.serverList` → `.connected(id, config, key)` via row tap.
- `.connected(id)` → `.serverList` via soft Disconnect (keeps creds).
- `.connected(id)` → `.serverList|.onboarding` via Forget (wipes id).
- `.onboarding` → `.connected(newID, …)` on completion.

Published `servers: [ServerID: IOSServerConfig]` on the RootModel so
ServerListView renders reactively without re-querying stores on
every re-render. `refreshServers()` is the `.task` hook; `forget()`
wipes a single id + refreshes.

OnboardingViewModel gains an optional `targetServerID` so its final
save lands in `keyStore.save(_:for:)` / `configStore.save(_🆔)`
instead of the singleton shims. Nil falls back to the old singleton
path for any remaining callers (tests, previews).

OnboardingRootView accepts `targetServerID` + a new `onCancel`
closure. The toolbar now shows Cancel so users can back out without
leaving half-written credentials; Cancel hides on the final
.connected step so you can't race-cancel a just-saved server.

ScarfGoTabRoot takes the server's ServerID as the context id so the
CitadelServerTransport pool caches per-server (two active servers →
two connection holders, no SSH channel contention). Splits the v1
onDisconnect into two callbacks:
- onSoftDisconnect: close transport, return to server list, keep creds.
- onForget: wipe this server's creds + return to server list (or
  onboarding if empty).

MoreTab renders both Disconnect and Forget rows in distinct sections
with explicit footers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:55:31 +02:00
Alan Wizemann aafd9643a4 M9 #1: multi-server storage (UserDefaults + Keychain) with migration
Pass-1 revealed that iOS should hold more than one server (users
want to hop between a home server and a work server from a single
app). Storage was the first block: v1 stored exactly one config
under a fixed key and one Keychain item under account "primary".

Extend both stores with ID-keyed methods while keeping the v1
singleton API for back-compat during the transition:

- IOSServerConfigStore: add listAll, load(id:), save(_🆔),
  delete(id:). Singleton load/save/delete now operates on the
  "primary" entry (lowest UUID by string sort) — deterministic, no
  surprise mutation of other servers when a singleton caller saves.
- SSHKeyStore: same treatment. Keychain accounts for v2 entries are
  `"server-key:<UUID>"`.

Migration is one-shot and embedded in `listAll()` on both stores:

- UserDefaults: if the v1 key `com.scarf.ios.primary-server-config.v1`
  is present AND v2 key `com.scarf.ios.servers.v2` is empty, load
  the v1 config, insert under a fresh ServerID in v2, delete v1.
  Idempotent — no-op once v1 is gone.
- Keychain: if no `server-key:*` accounts exist AND the legacy
  `"primary"` account does, copy the bundle to a fresh ServerID
  slot and delete the legacy item.

Both migrations preserve the v1 single-server experience: a user
who updates the app without re-onboarding still sees exactly one
configured server on first launch of the new version, with the
same SSH key and the same host details. No data loss.

InMemory stores updated to match (dictionary-keyed internally).
Mac + iOS schemes both build clean; ScarfCore swift build green.
Callers (RootModel, OnboardingViewModel, ChatController,
ScarfIOSApp transport factory) still use the singleton API and
will migrate to ID-keyed in 3.2-3.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:49:26 +02:00
Alan Wizemann 92fba712f8 M8: custom detents for ScarfGo sheets (permission, cron editor)
.medium is neither/nor — too tall to peek, too short to commit to.
Research recommends custom detents calibrated per sheet.

- Permission sheet: `[.height(220), .large]`. 220pt shows the prompt
  + first ~3 options without forcing the user to drag; `large` is
  there for edge-case prompts with many options.
- Cron editor: `[.large]` only. Cron editing is a focused task with
  a ~6-field form; peek detent is a distraction.

`.presentationDragIndicator(.visible)` on both so users know they
can drag the sheet without having to try + fail first.

No other sheets in the app today. The Forget-server confirmation
uses confirmationDialog (system-owned — no detents needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:44:41 +02:00
Alan Wizemann 8282b1d604 M8: chat content density (code blocks, scroll anchor, context menus)
Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView
and together make the conversation readable on a phone:

2.4 — fenced code blocks (```…```) now render in a horizontally-
scrollable monospaced block inside the bubble. Collapsed to 240pt
max height with Expand/Collapse + a copy button; long shell
one-liners / JSON / stack traces stay one line each instead of
soft-wrapping into unreadable 4-line columns. New
`ChatContentFormatter.segments(for:)` splits the message body into
alternating `.text` (routed through AttributedString markdown) and
`.code` (routed to the new CodeBlockView). Deliberately simple
parser — handles the common fence shape, leaves inline backticks
to AttributedString, and falls back to plain text on unterminated
fences so nothing is ever silently swallowed.

2.5 — tool-call cards were already collapsed-by-default via a chevron
toggle. No structural change needed for M8; leaving the existing
ToolCallCard in place.

2.6 — replace the manual `onChange → proxy.scrollTo("bottom")`
pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's
`.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll-
pin fights the user's own scroll-up gesture less (the manual pattern
yanked you back to the bottom if a chunk arrived mid-read).
"New messages" pill for upward scroll-break deferred — needs a bit
of ScrollPosition state we don't plumb yet.

2.7 — `.contextMenu` on every message bubble with Copy + Share
(via ShareLink). User + assistant bubbles both. Code blocks get
their own copy button in the header. Regenerate intentionally
omitted — ACP has no native re-prompt primitive and implementing
one would be non-trivial session-state surgery.

Both schemes build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:43:20 +02:00
Alan Wizemann 5f9343be5d M8: list density tokens (scarfGoCompactListRow + scarfGoListDensity)
Apple's default List styling targets Reading/Notes-style apps:
~60pt rows, 10pt inter-row spacing, big vertical padding on
grouped cells. ScarfGo's lists (Memory, Cron, Skills, More,
Dashboard recent sessions) lean information-dense — devs want to
see 4-6 items per screen, not 2.

Two tokens in Scarf iOS/App/Theme/ListDensity.swift:

- `.scarfGoCompactListRow()` — 6pt vertical listRowInsets (down
  from default ~12pt), explicit `.frame(minHeight: 44)` to preserve
  the Apple HIG tap target, and `.contentShape(Rectangle())` so
  rows can shrink below 44pt visually while keeping the full-row
  hit area. ~48pt rows end up net, vs. ~60pt default.
- `.scarfGoListDensity()` — `.listRowSpacing(0)` kills inter-row
  gaps on the whole List, `.defaultMinListRowHeight(36)` sets the
  floor for rows that want to go smaller (e.g. `LabeledContent`).

Applied to Memory, Cron, Skills, Dashboard, MoreTab. No visual
change to Chat (it's not a List — different density patterns for
M8 items 2.4–2.7). Research-backed: Fantastical / GitHub Mobile /
Mona for Mastodon use similar spacing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:40:10 +02:00
Alan Wizemann 5cac3836cf M8: TabView root navigation (Chat / Dashboard / Memory / More)
Pass-1 loudest UX complaint — "I don't see any navigation" — was
rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/
Settings lived as a NavigationLink section halfway down a scrolling
List, below the stats + recent sessions. Users had to scroll to
find any feature. That was the right shape for a very-early MVP
but the wrong shape for a companion app whose primary tab should
be Chat.

New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root:

- **Chat** — primary tab. Tapping the app opens straight into it.
- **Dashboard** — stats + recent sessions (stripped of Surfaces /
  Connected-to / Disconnect, which now live in More).
- **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged.
- **More** — bucket for Cron / Skills / Settings plus the
  destructive Forget-this-server action. Also shows the host /
  user / port info as a read-only section.

Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree
degrades to a bottom tab bar on iPhone and renders as a native
sidebar on iPadOS / macCatalyst if we add those targets later — no
UI code change required. Matches the M8 density research's sidebar
recommendation.

Each tab owns its own NavigationStack so push navigation (Cron
editor, Memory detail, chat session list) stays scoped to that tab
and doesn't bleed across.

DashboardView is now simpler: just stats + recent sessions. The
Forget confirmation + Disconnect button moved wholesale to
MoreTab inside ScarfGoTabRoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:38:03 +02:00
Alan Wizemann cecc1060c6 M8: clamp Dynamic Type at ScarfGo scene root
ScarfGo is a developer tool that benefits from tighter defaults
than Apple's spacious baseline, but shouldn't lock out users who
need accessibility sizes. `.dynamicTypeSize(.xSmall ... .accessibility2)`
at the WindowGroup gives both: compact-first layout, still scalable
to ~XL accessibility for low-vision users.

Going past .accessibility2 collapses multi-column rows and forces
text truncation in ScarfGo's dense list layouts — not a win for
anyone. Matches Use-Your-Loaf's "Restricting Dynamic Type Sizes"
guidance from the M8 density research.

One-line change ahead of the TabView migration (2.2) so every
subsequent UX-density decision factors in the clamped range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:35:51 +02:00
Alan Wizemann e1f862e2f9 M7 #5 cross-platform: validate model ID against provider catalog
Pass-1 demonstrated the bug end-to-end: user saved provider nous
+ model claude-haiku-4-5-20251001 (an Anthropic name Nous Portal
doesn't serve). Scarf accepted the save, wrote config.yaml, and
Hermes surfaced the failure six hours later as HTTP 404. Catch at
save time.

New ModelCatalogService.validateModel(_:for:) returns one of:

- .valid — model is in the provider's catalog, or the provider is
  overlay-only (Nous Portal / OpenAI Codex / Qwen OAuth etc. — those
  don't mirror to models.dev, so any non-empty string is
  provisionally accepted; runtime errors still surface via the chat
  error banner from M7 #2).
- .unknownProvider(providerID:) — no catalog entry at all; save
  with an advisory. Usually means offline / missing local cache.
- .invalid(providerName:suggestions:) — block the save, offer up to
  5 close-by models as "did you mean…". Prefix-match on first 3
  chars; falls through to newest-5 when no prefix hits.

Mac ModelPickerSheet.submitSelection now routes through the
validator before onSelect. On .invalid it raises a .alert(item:)
with the suggestion list; user picks "Pick from catalog" (drops
out of custom mode) or "Edit" (keep the typed value to fix).

5 unit tests cover the happy path, unknown-provider branch, overlay-
only bypass, invalid-with-suggestions (using the exact pass-1 pair),
and empty input.

ScarfGo's scoped-settings editor (Phase 4.3) will reuse the same
validator when it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:34:37 +02:00
Alan Wizemann 42c0f683bd M7 #11: human-readable cron schedules across Mac + ScarfGo
Pass-1 rightly called out that rendering "0 */6 * * *" and ISO 8601
timestamps directly to users is user-hostile — cron syntax is a
devops lingua franca, not a user-facing idiom, and the iOS list
is where the problem is most visible.

New `CronScheduleFormatter` in ScarfCore pattern-matches common
cron shapes into English phrases:

- Named macros (@hourly, @daily, @weekly, @monthly, @yearly).
- Every N minutes (`*/5 * * * *` → "Every 5 minutes").
- Every hour on minute M (`30 * * * *` → "Every hour at :30").
- Every N hours at M (`0 */6 * * *` → "Every 6 hours").
- Daily at H:MM (`0 9 * * *` → "Daily at 9 AM").
- Weekdays / weekends / single-weekday at H:MM.
- Monthly on day D at H:MM.
- User-set `display` label (non-cron string) wins — preserves any
  descriptive name the user typed via `hermes cron set-display`.
- Anything unrecognised falls back to the raw expression so no
  info is ever hidden. 17-test pattern table covers every branch.

Sibling `formatNextRun(iso:)` parses Hermes's ISO-8601 `next_run_at`
field (handling both with-fractional-seconds and without) and
renders `"in 4 hours"` / `"tomorrow at 9 AM"` via Foundation's
`.relative(presentation: .numeric)`. Falls back to the raw string
if parsing fails so we never blank out useful info.

Applied to:
- ScarfGo `CronListView.CronRow` — human schedule + relative next-run.
- Mac `CronView` — row subtitle + detail-panel "Schedule" label +
  "Next run" / "Last run" Labels.

Both schemes build green. 17/17 new formatter tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:29:59 +02:00
Alan Wizemann f2f6c4e50b M7 #9+#10: Memory editor keyboard + Saved pill above keyboard
Pass-1 complaints:
- Typing near the bottom of MEMORY.md → keyboard covered the cursor,
  user lost track of where they were editing (M7 #9).
- Tapping Save → "Saved" pill was never visible because it sat at
  .bottom with a fixed 16pt padding, behind the still-raised keyboard
  (M7 #10).

Fixes:
- `.scrollDismissesKeyboard(.interactively)` on the TextEditor so
  scrolling the editor drags the keyboard down smoothly.
- Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)`
  so SwiftUI draws them above whatever is presenting the keyboard.
  The pill is now a full-width material strip (easier to hit/notice)
  instead of a floating capsule.
- Saved pill holds for 2.5s (up from 1.5s — the old timer was too
  tight to read mid-thought).
- Any in-flight hide task is cancelled when a new save lands, so
  rapid-fire saves don't produce stacked fade timers.

No Mac equivalent needed — Mac memory editor is a separate
MemoryView with different layout and a non-mobile keyboard concern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:24:23 +02:00
Alan Wizemann c802e1189f M7 #7+#8: ServerContext.readTextThrowing + Memory surfaces real errors
Pass-1 found that SFTP failures (initially the tilde-expansion bug,
but the same pattern applies to any transport error) silently
returned nil from `ServerContext.readText`, which the Memory editor
interpreted as "empty file." The user stared at a blank TextEditor
with no clue the connection had failed.

Two-part fix:

1. Add `readTextThrowing(_:)` on ServerContext that separates three
   outcomes:
   - `.some(content)` — file read succeeded.
   - `.none` — file is genuinely absent (fileExists probe returned
     false).
   - throws — transport error (SSH down, SFTP timeout, auth failure,
     non-UTF-8 data).
   The existing nil-returning `readText(_:)` stays around for callers
   that genuinely can't distinguish ("probably there, probably not")
   — now implemented as a `try?` on the throwing variant so behavior
   doesn't drift.

2. IOSMemoryViewModel.load uses the throwing variant. `.success(nil)`
   is still treated as "first-time empty" (no lastError). `.failure`
   populates `lastError` with a human message citing the underlying
   transport error's localizedDescription so the Memory editor can
   render it inline (it already had the error-banner view; just
   needed the VM to actually set the string).

Also fixes a pre-existing stale test reference in M0dViewModelsTests
(`vm.entries` → `vm.toolMessages`) — ActivityViewModel's property
name drifted during the earlier rebase; the test was left broken.
Unrelated cron-delete test failure noted for separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:23:13 +02:00
Alan Wizemann 96f60a176d M7 #2: non-retryable ACP errors surface as chat error banner
Pass-1 hit HTTP 404 from Nous Portal (misconfigured model), the
agent reported it via ACP stderr + stopReason="error", and ScarfGo
showed nothing — users saw only the perpetual working spinner. Mac
had an errorBanner for this pattern; ScarfGo didn't.

Promotes the error-banner state and helpers from Mac's ChatViewModel
(Mac target) into RichChatViewModel (ScarfCore) so both apps share:

- `acpError`, `acpErrorHint`, `acpErrorDetails` — the banner triplet.
- `clearACPErrorState()` — called on reset() and addUserMessage()
  so stale errors don't linger across prompts.
- `recordACPFailure(_:client:)` — populate triplet from a thrown
  error + stderr tail, using the existing `ACPErrorHint.classify`.
- `recordPromptStopFailure(stopReason:client:)` — populate triplet
  from a non-retryable ACP `promptComplete` stopReason. Provides a
  fallback hint per stopReason when classify doesn't match.
- `acpStderrProvider: () async -> String` — closure the controller
  sets once so `handlePromptComplete` (called from the event stream)
  can pull recent stderr without the VM holding a direct ACPClient
  reference.

Mac ChatViewModel's local triplet becomes forwarding properties to
richChatViewModel.* — call sites (~15 in ChatViewModel) stay
unchanged. `recordACPFailure` + `clearACPErrorState` become one-line
forwarders.

ScarfGo ChatView gains an `errorBanner` modeled on the Mac one:
- Orange triangle + hint + raw error
- Expand/collapse "Details" button showing stderr tail (monospaced,
  scrollable, max ~140pt tall)
- Copy-all button via `UIPasteboard.general.string` (Mac uses
  NSPasteboard; same structure otherwise)
- Rendered above the message list so it's always visible

ChatController wires `acpStderrProvider` to
`{ await client?.recentStderr ?? "" }` before the handshake and
calls `recordACPFailure` on ACP client start / newSession /
sendPrompt failure paths. `handlePromptComplete` already handles
the common provider-404 case via `recordPromptStopFailureUsingProvider`.

Both schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:17:51 +02:00
Alan Wizemann 8e14e0e776 M7 #4: split isAgentWorking into isGenerating + isPostProcessing
Pass-1 showed the "Agent is working…" spinner persisting long after
the reply had landed in the message list — Hermes delays the ACP
`promptComplete` event while it does auxiliary post-work (title
generation, usage accounting). Spinner stuck ~minute+ on a 2-second
response.

Fix without touching the ACP state machine: derive two computed
properties from existing signals in RichChatViewModel:

- `isGenerating`: agent is working AND we don't yet have a finalized
  assistant reply on the message list. Drives the prominent spinner.
- `isPostProcessing`: agent is working AND the user CAN see the
  reply. Drives a subtle "Finishing up…" pill instead of the big
  spinner. When `promptComplete` finally arrives, `isAgentWorking`
  flips false and both derived props go quiet.

`isAgentWorking` remains the canonical ACP-level flag (kept public
for any consumer that really wants the raw value), just no longer
the signal for visible "spinner now" UX.

Applied to:
- ScarfGo ChatView.swift — primary spinner + post-processing pill.
- Mac RichChatView.swift — SessionInfoBar + RichChatMessageList now
  take `isGenerating` instead of `isAgentWorking`. Same UX win for
  the macOS app (pass-1 finding was cross-platform, just surfaced
  first on iOS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:12:25 +02:00
Alan Wizemann 742605d359 M7 #3: Chat 'Connecting…' overlay during SSH exec handshake
ChatController already transitioned through a `.connecting` state
between tap-Chat and first-message-ready (ACP initialize + session/new
take ~0.5–1.5 s on a warm network), but there was no visible UI
— the screen stayed on the idle layout with a disabled composer.
Users interpreted the silence as a frozen app (pass-1 M7 #3).

Adds a `.regularMaterial` overlay with a large ProgressView +
"Connecting to <nickname>…" text, rendered whenever
`controller.state == .connecting`. Disappears automatically when
state flips to `.ready` (normal path) or `.failed` (handoff to the
existing errorOverlay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:08:35 +02:00
Alan Wizemann fee5e72d30 M7 #12: rename Disconnect -> Forget this server + confirmation
Pass-1 found that "Disconnect" was actually a factory reset —
wiping both Keychain SSH key and UserDefaults config, forcing
full re-onboarding (including re-generating a key and appending
it to authorized_keys on the remote).

Interim fix ahead of M9 multi-server work:
- Relabel button "Forget this server".
- Keep destructive role.
- Gate tap on a confirmationDialog so users see exactly what gets
  wiped and can back out.
- Add a footer explaining the authorized_keys consequence so the
  user isn't surprised by a failed reconnect later.

Behaviour is unchanged (still wipes both stores). M9 introduces
the proper split: soft Disconnect (closes live transport, keeps
credentials) vs. hard Forget (this behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:06:59 +02:00
Alan Wizemann f41ac1c84e M7: pass-1 quickfixes (PATH, SFTP tilde, SOUL.md, ScarfGo bundle id)
Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the
iOS companion against a local Hermes host. All discovered while
collaboratively driving the Simulator + tailing os.Logger.

1. ACPClient+iOS.swift — ACP exec command prepends common install
   paths to PATH. SSH RFC 4254 exec uses a non-interactive shell
   whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`);
   `.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default)
   was invisible and the agent died with "command not found: hermes".
   Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local
   probe list) inline in the exec command.

2. CitadelServerTransport.swift — SFTP tilde expansion. Every
   Memory/Cron/Skills/Settings read used paths like
   `~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal
   character, not a home-dir alias — so every read silently returned
   nil and the UIs showed "empty file" instead of the real content.
   Added a per-connection cached `resolveHome()` + a `resolveSFTPPath`
   helper applied to every SFTP entry point (readFile / writeFile /
   fileExists / stat / listDirectory / createDirectory / removeFile).
   This was the single biggest blocker on pass-1.

3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added
   as a third Memory row. SOUL.md lives in the Personalities feature
   on Mac; folding it into Memory on iOS matches the on-the-go scope
   (all agent prompt inputs in one place). Uses the existing
   `HermesPathSet.soulMD` path; no new plumbing.

4. project.pbxproj — bundle id rename for ScarfGo branding:
   - CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo"
   - PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app
   Xcode target name stays "scarf mobile" internally (rename surgery
   isn't worth the PBX churn). Home-screen label + bundle id now
   match the product name.

Both schemes build green. Phase 1 starter commit — per-item M7
fixes follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:05:29 +02:00
Alan Wizemann 19b4ba9995 Merge branch 'main' into scarf-mobile-development (v2.3.0)
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:23 +02:00
Alan Wizemann 05e2a8444a feat(credential-pools): surface OAuth expiry + Nous agent-key rotation
auth.json entries now carry expires_at_ms / expires_at and (for
Nous) agent_key_obtained_at. Decode the new fields, add an
expiryBadge helper, and render a red "expired" / orange "expires
in Nd" pill when a credential is past or within 7 days of expiring.
Nous entries also get a muted "agent key · Nh ago" line so manual
rotations are visibly confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:41:19 +02:00
Alan Wizemann fe104b83fa feat(health): surface hermes dashboard web UI in Health
Hermes v0.10.x ships a local web dashboard launchable via `hermes
dashboard` on port 9119. Scarf now detects it via a 3s
`/api/status` probe and offers Launch / Stop / Open-in-Browser
controls on the Health tab. Local contexts only — the dashboard
binds 127.0.0.1 and remote tunneling is deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:41:12 +02:00
Alan Wizemann 5498a08b11 chore: Bump version to 2.3.0 v2.3.0 2026-04-24 03:16:36 +02:00
Alan Wizemann a864c9af02 chore(l10n): Xcode auto-extracted new Tool Gateway strings 2026-04-24 03:15:06 +02:00
Alan Wizemann ec506d4652 docs(v2.3): add Tool Gateway + Nous Portal sign-in to release notes + README
v2.3 now lands two themes together: Projects Grow Up (existing) and
Hermes v0.10.0 Tool Gateway support (new, just merged on the feature
branch). The release notes and the repo README's "What's New" section
are updated to reflect both.

Release notes:

- Headline intro rewritten to frame both themes as the v2.3 story.
- New "Tool Gateway — Nous Portal support" section between "Icon
  tweak" and "Migrating from 2.2.x": picker overlay merge surfacing 6
  previously-invisible providers, in-app device-code sign-in sheet,
  per-task Nous routing in the Auxiliary tab, Health card, Credential
  Pools dead-end fix + auth-type gating, Messaging Gateway rename.
- "Under the hood" gains the Tool Gateway services paragraph
  (NousSubscriptionService, NousAuthFlow, NousSignInSheet,
  CredentialPoolsOAuthGate) + the PYTHONUNBUFFERED=1 subprocess-env
  fix note. Test count bumped from 93 → 120 (14 new tests in
  ToolGatewayTests, NousAuthFlowParserTests, CredentialPoolsGatingTests).
- "Migrating from 2.2.x" gains a Hermes version paragraph spelling
  out that v0.10.0 is required for the Tool Gateway features (rest
  of 2.3 works on earlier Hermes, just without Nous in the picker
  or subscription data in Health).
- "Documentation" section lists the new Hermes Version Compatibility
  + Core Services wiki updates that accompany this release.

README:

- v2.3 "What's New" bullet list gains a Tool Gateway bullet
  positioned between the chat-indicator bullet and the window-layout
  bullet.
- Trailing "See the full release notes" line expanded to reference
  the Hermes Version Compatibility wiki page so users on Hermes v0.9
  know why they don't see Nous in their picker.

Companion wiki update already pushed in 741b253 on the wiki repo
(Hermes-Version-Compatibility, Core-Services, Home).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:08:57 +02:00
Alan Wizemann 38226fea2c fix(nous): force PYTHONUNBUFFERED=1 so device-code output surfaces
The sign-in sheet was stuck on the "Contacting Nous Portal…" spinner
even though hermes was running correctly. Root cause: Python
block-buffers stdout when it's a pipe instead of a TTY, and
`hermes auth add nous` enters a 15-minute polling loop after printing
the device-code block without ever calling `input()` — so nothing
flushes the buffer. Our readability handler never receives the URL +
user_code lines.

PKCE doesn't hit this because hermes calls `input("Authorization
code: ")`, which flushes stdout before blocking. Device-code has no
equivalent trigger.

Setting PYTHONUNBUFFERED=1 in the subprocess environment forces
line-buffered stdout for the duration of the flow — the device-code
block surfaces immediately, our regex extracts the URL and code, and
the sheet transitions into the waitingForApproval state as intended.

Local-only fix; remote SSH contexts get the remote's login env
untouched (the user's remote shell config owns buffering behavior
there).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:54:22 +02:00
Alan Wizemann 257772e2d1 feat(nous): in-app sign-in + credential pools auth-type gating
The Tool Gateway feature shipped the Nous Portal provider in Scarf's
picker, a subscription-state detector, and a per-task aux toggle — but
there was no way to actually sign in. `hermes auth` in a terminal took
six steps, and Credential Pools' "Start OAuth" button silently stalled
for `nous` because it tried to run the PKCE flow against a device-code
provider.

Changes:

- NousAuthFlow: new @Observable MainActor service that spawns
  `hermes auth add nous --no-browser`, parses the device-code block
  (verification_uri_complete + user_code) with two line-anchored
  regexes, opens the verification URL via NSWorkspace.shared.open,
  and confirms success by re-reading auth.json via
  NousSubscriptionService. Detects the `subscription_required`
  failure and extracts the billing URL so the UI can offer a
  Subscribe link.
- NousSignInSheet: four-state sheet (starting / waitingForApproval /
  success / failure). Shows the user code in a large monospaced
  badge with Copy + re-open-browser affordances, auto-dismisses
  1.2s after success, Subscribe + Try again + Copy error buttons
  on failure.
- Wired three entry points (per user-approved plan):
    1. ModelPickerSheet's Nous Portal subscription summary — replaces
       the stale "Run hermes auth" caption with a primary
       "Sign in to Nous Portal" button.
    2. AuxiliaryTab's per-task Nous toggle — inline "Sign in first"
       button when not subscribed, instead of a dead-end caption.
    3. Credential Pools "Add Credential" sheet — when provider is
       `nous`, replaces the broken Start OAuth button with
       "Sign in to Nous Portal".
- CredentialPoolsOAuthGate: testable helper that routes provider IDs
  to the right OAuth flow based on the overlay table. Closes the
  silent-fail dead-end for openai-codex, qwen-oauth,
  google-gemini-cli, and copilot-acp too — disables the generic
  button with an inline "run hermes auth add <provider> in a
  terminal" hint. PKCE providers (anthropic, etc.) and unknown
  providers still pass through as `.ok` — this gate is strictly
  additive.

Tests: 14 new tests across two suites (NousAuthFlowParserTests,
CredentialPoolsGatingTests). Full suite 120/120 green on top of
v2.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 02:49:08 +02:00
Alan Wizemann 115bc16b14 feat: Nous Portal + Tool Gateway support for Hermes v0.10.0
Hermes v0.10.0 (v2026.4.16) introduces the Tool Gateway — paid Nous
Portal subscribers route web search, image generation, TTS, and browser
automation through their subscription without separate API keys.

- ModelCatalogService merges HERMES_OVERLAYS on top of the models.dev
  cache, surfacing 6 overlay-only providers (Nous Portal, OpenAI Codex,
  Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) that were
  previously invisible in Scarf's picker. Subscription-gated providers
  sort first.
- NousSubscriptionService reads ~/.hermes/auth.json -> providers.nous
  to detect subscription state. Read-only; Hermes owns the write path.
- ModelPickerSheet renders a "Subscription" pill, auth-type-aware
  instructions, and free-form model-ID entry for overlay providers
  (no models.dev catalog for them).
- AuxiliaryTab gains a per-task "Nous Portal" toggle that flips
  auxiliary.<task>.provider between "nous" and "auto". Hermes derives
  gateway routing from provider selection; there's no separate
  use_gateway key in the source.
- HermesConfig + HermesFileService parse platform_toolsets.
- HealthViewModel adds a synthetic "Tool Gateway" section showing
  subscription state, platform_toolsets, and which aux tasks are
  routed through Nous.
- Gateway -> Messaging Gateway rename (sidebar, dashboard card, menu
  bar, log-source filter, Settings/Agent/Gateway section header) to
  disambiguate from the new Tool Gateway.
- CLAUDE.md bumped to Hermes v0.10.0 (v2026.4.16) with a
  keep-overlayOnlyProviders-in-sync reminder.
- 13 new tests covering overlay merge, subscription detection, and
  platform_toolsets parsing; full suite (106 tests, 19 suites) green
  on top of v2.3 projects branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:59:21 +02:00
Alan Wizemann eda5e467f9 Merge branch 'v2.3-projects': v2.3 — Projects Grow Up
Brings in 17 commits delivering the full v2.3 scope:

- Projects sidebar hierarchy: folders, rename, archive/unarchive,
  fuzzy search (⌘F), ⌘1–⌘9 keyboard jumps. Registry schema v2
  (optional folder + archived fields); backward-compatible with
  v2.2.1 readers.
- Per-project Sessions tab alongside Dashboard / Site. "New Chat"
  spawns hermes acp with the project's directory as cwd and
  attributes the resulting session via a Scarf-owned sidecar at
  ~/.hermes/scarf/session_project_map.json (Hermes's state.db has
  no cwd column, so Scarf owns the mapping).
- Agent context injection: ProjectAgentContextService writes a
  Scarf-managed block into <project>/AGENTS.md between
  <!-- scarf-project:begin/end --> markers. Hermes auto-reads
  AGENTS.md at session boot, so the agent now actually knows the
  project name, dashboard path, template id, configuration field
  NAMES (secret-safe — never values), registered cron jobs, and
  uninstall-manifest presence. Template-author content outside
  the markers is preserved byte-identical across refreshes.
- Chat indicator: folder chip in SessionInfoBar + "Chat ·
  <ProjectName>" nav title when scoped. Resumed project-
  attributed sessions automatically re-surface the indicator via
  the attribution lookup at resume time.
- Window-layout cleanup: .windowResizability(.contentMinSize) +
  idealHeight caps on Chat/Sessions subtrees so the window stops
  growing past the screen when switching to content-heavy
  sections. Pre-existing issue surfaced by the new per-project
  surfaces.

22 new Swift tests across ProjectRegistryMigrationTests (7),
ProjectsViewModelTests (7), SessionAttributionServiceTests (7),
and ProjectAgentContextServiceTests (13) — total suite size is
now 93/93.

Release notes at releases/v2.3.0/RELEASE_NOTES.md (9.4 KB). README
"What's New in 2.3" block prepended; prior v2.2 block demoted to
"Previously, in 2.2."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:49:56 +02:00
Alan Wizemann 9127aef682 docs: v2.3.0 release notes + README What's New
Prep commit for the v2.3 release. Covers the 16 feature + fix
commits landed on the v2.3-projects branch:

- releases/v2.3.0/RELEASE_NOTES.md — new file. release.sh picks
  this up automatically as the GitHub release body at tag time.
  Sections: sidebar grows up (folders/rename/archive/search/
  keyboard jumps), per-project Sessions tab + sidecar, the
  AGENTS.md marker-block injection (with the invariants —
  secret-safe, idempotent, bounded, non-fatal, bare-project
  friendly — called out explicitly), chat-UI project awareness
  (folder chip + nav title), window-layout cleanup, under-the-
  hood (new services, 22 new tests), migration, thanks.
- README.md — "What's New in 2.3" block at the top; demotes
  the prior 2.2 block to "Previously, in 2.2" (condensed to the
  four most user-facing points since the full 2.2 notes live at
  the release link).
- Localizable.xcstrings — Xcode auto-regen from the new string
  literals introduced across the v2.3 feature commits (folder
  chip tooltip, Sessions tab header, etc.). Riding along.

93/93 Swift tests still pass. No code change here — pure docs.
Wiki Home + Release-Notes-Index updates land as a separate
wiki commit after the release is cut (standard post-release
chore per CLAUDE.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:45:06 +02:00
Alan Wizemann 5ae8db25c3 fix(chat): resume session on coordinator.selectedSessionId, not just pendingProjectChat
Clicking a session in the Projects Sessions tab routed to the
Chat section (correct — we want interactive resume, not the
read-only Sessions browser), but the session didn't actually
load and the project chip didn't appear. Root cause: ChatView
only observed `coordinator.pendingProjectChat` (for new chats),
not `selectedSessionId` (for resumes). Setting the id had no
effect because no consumer existed on the Chat side.

Every other session-click site in Scarf routes to `.sessions`,
and SessionsView consumes selectedSessionId at its `.task` +
clears it. Projects is the exception — the whole point of the
per-project Sessions tab is to resume chats interactively rather
than browse them, so we route to `.chat`. That routing was right;
the Chat side just needed to grow the symmetrical consumer.

This commit adds two handoff paths in ChatView (mirrors the
existing `pendingProjectChat` pattern):

- `.task` picks up a selectedSessionId that was set before
  ChatView mounted (cold-launch handoff from Projects).
- `.onChange(of: coord.selectedSessionId)` picks up mid-session
  navigation (user clicks a session while already in Chat).

Both call `viewModel.resumeSession(id)` then clear the coordinator
field. The project chip rendering + navTitle update then happen
automatically inside ChatViewModel.resumeSession ->
startACPSession, which already looks up attribution via
SessionAttributionService.projectPath(for: resolvedSessionId) —
that plumbing was in from Part B. The bug was entirely in the
trigger, not the side-effect.

`else if` between pendingProjectChat and selectedSessionId makes
precedence explicit — new-chat wins over resume if both are
somehow set. In practice only one is ever populated per
navigation, but the explicit ordering avoids surprise.

No race with SessionsView's own consumer: `coordinator.selectedSection`
ensures only one view is rendering at a time, and both consumers
clear the field on consume.

93/93 Swift tests still pass. No test change — this is a view-
wiring integration fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:34:19 +02:00
Alan Wizemann fb833d4a0a fix(projects): open HermesDataService before filtering sessions
Sessions tab was showing "This project has N attributed sessions,
but none are in the recent history. They may have been deleted
from Hermes." on projects with valid sidecar entries and actual
sessions present in state.db. Root cause: the VM never opened
the DB handle.

`HermesDataService` is an actor with a lazily-initialised SQLite
pointer. Every query method short-circuits to `[]` when
`db == nil`. Callers have to open/refresh the handle explicitly
— InsightsViewModel does it (line 106), ActivityViewModel does
it (line 60). ProjectSessionsViewModel was constructed fresh
per project, never inherited a shared service, and never called
refresh() itself, so fetchSessions returned empty on every load
and the filter against the (correctly-populated) sidecar map
produced zero matches. The empty-state message ("may have been
deleted") fired on that false-negative.

The data was fine all along: sqlite3 ~/.hermes/state.db confirmed
both attributed sessions with source='acp', parent_session_id
IS NULL — they pass fetchSessions's WHERE clause cleanly. The
sidecar mappings were correct. The file watcher was firing. The
only missing piece was the DB-open precondition.

Fix: `_ = await dataService.refresh()` before fetchSessions,
mirroring the pattern used by every other feature VM that
consumes HermesDataService. Also adds a `close()` on the VM + an
onDisappear handler on the view, so the handle doesn't dangle
once the tab isn't visible — same cleanup ActivityView has.

This is NOT forward-only. Existing sidecar entries that
currently show the misleading empty-state will surface
correctly as soon as users rebuild — no data migration, no
re-create-the-chat, no backfill. The bug was "couldn't read what
was already there," not "lost old data."

93/93 Swift tests still pass. No test change — the fix is an
integration-level call-ordering detail that isn't meaningfully
testable without mocking HermesDataService (overkill for a
two-line fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:27:06 +02:00
Alan Wizemann 7656ad8052 docs(v2.3): document how agents see Scarf projects
Three doc updates covering the AGENTS.md context-injection
pattern introduced in the previous commit.

CLAUDE.md — new "Project-scoped chat + Scarf-managed AGENTS.md
context (v2.3)" subsection under Project Templates. Covers:

- The session-project sidecar at ~/.hermes/scarf/session_project_map.json
  (why it exists, what manages it)
- How Hermes picks up project context: cwd-based auto-load of the
  first matching context file (priority order, 20KB cap)
- Exact marker format and block shape
- Invariants that future edits must preserve: secret-safe,
  idempotent, bounded-region, non-fatal, refresh-before-session-start
  ordering
- Template-author contract: leave the region alone, put
  instructions below
- Known caveat: parent-directory `.hermes.md` shadowing (deferred
  to v2.4)

scarf-template-author SKILL.md — new pitfall bullet in the
"Common pitfalls" checklist telling scaffolding agents to
preserve the `<!-- scarf-project -->` region and put template-
specific instructions below it. Rebuilt the bundle so installs
from the catalog pick up the guidance; regenerated catalog.json.

Wiki update (Project-Templates page) lands next via scripts/wiki.sh.

93/93 Swift + 24/24 Python tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:09:49 +02:00
Alan Wizemann 5b1481f33f feat(projects): Scarf-managed project-context block in AGENTS.md
Hermes has no native "project" concept and the ACP wire protocol
drops extra params at `session/new`. But Hermes DOES auto-read
AGENTS.md from the session's cwd at startup (research confirmed:
priority order `.hermes.md` → `HERMES.md` → AGENTS.md → CLAUDE.md
→ .cursorrules; 20KB cap; first match wins). So the agent-
awareness path is file-based, not protocol-based.

This commit adds `ProjectAgentContextService` — a one-job service
that writes a Scarf-managed block into `<project>/AGENTS.md`
between `<!-- scarf-project:begin -->` and `<!-- scarf-project:end -->`
markers. Same pattern as the v2.2 memory-block appendix: bounded,
self-declaring, re-generable, safe on hand-authored content
outside the markers.

## Block contents

- Project name (from registry)
- Project directory path
- Dashboard.json path
- Template id + version (when template-installed)
- Configuration field NAMES with type hints — never VALUES.
  Secrets always render as `field_key (secret — name only, value
  stored in Keychain)`. Config.json values never appear in the
  block, so the injected context is safe to drop into any agent
  regardless of what's in Keychain.
- Registered cron jobs attributed to this project (matched via
  the `[tmpl:<id>] …` prefix convention)
- Uninstall manifest reference (when `.scarf/template.lock.json`
  exists)
- A note to the agent: cwd is the project dir, respect template
  content below the block.

## Integration point

`ChatViewModel.startACPSession(resume:projectPath:)` refreshes
the block BEFORE `client.start()` — Hermes reads AGENTS.md
during session boot, so it has to land on disk first. `try?`
with a warning log: a failed refresh doesn't block the chat,
the session just starts without the extra context.

## Idempotency + safety

- Two consecutive refreshes produce byte-identical output
- Hand-edits outside the markers survive every refresh
- Empty project dir → AGENTS.md created with just the block
- Existing AGENTS.md without markers → block prepended; rest
  preserved below
- Orphaned begin-marker (no end) → treated as "no block
  present," new block prepended, orphan left in place (likely
  hand-typed, not a Scarf corruption)

## Tests

13 new tests in ProjectAgentContextServiceTests:
- applyBlock pure-text transform: prepend / replace / idempotency
  / empty input / orphaned-marker fallback
- renderBlock content: identity fields, template presence, config
  field names (and CRITICALLY: no values leak for secret fields)
- refresh end-to-end on isolated temp dirs: file creation, user
  content preservation, idempotency across runs, stale-block
  rewrite

93/93 Swift tests pass (was 80; +13 new).

## Deferred

TERMINAL_CWD env-var plumbing in ACPClient was scoped in the plan
but skipped — ACPClient.start() doesn't know the cwd at launch
(it's per-session), and plumbing it would restructure the actor's
lifecycle. Hermes already receives the cwd via ACP's `session/new`
params and uses it for context-file discovery there, so
TERMINAL_CWD is belt-and-suspenders we can add later without
breaking anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:05:15 +02:00
Alan Wizemann e4920538d2 feat(chat): show active-project indicator in SessionInfoBar + nav title
Adds a visible cue telling the user when their chat is scoped to
a Scarf project. Two surfaces:

- **SessionInfoBar** gets a folder-fill icon + project name chip at
  the start of the bar (before the working dot + title). Rendered
  with `.tint` foregroundStyle so it's visually anchored as the
  first piece of context. Hidden for non-project chats — the bar
  looks identical to v2.2.1 when projectName is nil.

- **Navigation title** becomes `Chat · <ProjectName>` when scoped,
  stays as plain `Chat` otherwise. Matches macOS conventions for
  "subject — detail" titles.

ChatViewModel gains two `@Observable` properties:

- `currentProjectPath: String?` — absolute path, source of truth
  for attribution lookups
- `currentProjectName: String?` — resolved via the projects
  registry at session-start; stored to avoid disk reads on every
  render. Falls back to the raw path (rather than nil) when a
  session's attribution points at a project no longer in the
  registry — the user still sees *something* rather than silently
  losing the indicator.

Both are populated in `startACPSession(resume:projectPath:)` from
two sources:

1. If the caller passed `projectPath` — fresh project-chat case
2. Otherwise, SessionAttributionService.projectPath(for:
   resolvedSessionId) — resumed-session case. Means clicking an
   old project-attributed session from ANY surface (the project's
   Sessions tab, the global Resume menu) re-surfaces the
   indicator.

When the user starts a non-project session, both fields reset to
nil explicitly so the indicator doesn't leak between chats.

Files:
- ChatViewModel.swift — new properties + resolve logic
- SessionInfoBar.swift — new `projectName: String?` parameter +
  chip rendering
- RichChatView.swift — passes chatViewModel.currentProjectName
  through to SessionInfoBar
- ChatView.swift — navTitle reflects the active project

80/80 Swift tests still pass. Visual change only; no test change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:00:07 +02:00
Alan Wizemann 5340e70dd3 fix(projects): watch session-project-map so Sessions tab refreshes
ProjectSessionsView's `.onChange(of: fileWatcher.lastChangeDate)`
was silently never firing when a new chat attributed a session to
a project — the sidecar was written correctly, the session was in
state.db correctly, attribution IDs matched exactly, but the per-
project Sessions list didn't auto-refresh.

Root cause: HermesFileWatcher.watchedCorePaths was missing
`paths.sessionProjectMap` (`~/.hermes/scarf/session_project_map.json`,
introduced in the v2.3 feature commit). Since the watcher didn't
observe that file, writes from SessionAttributionService.persist
produced no `lastChangeDate` change, the VM's onChange never ran,
and the Sessions tab stayed empty until the user navigated away
and back (triggering .task(id: project.id) to re-fire).

One-line fix: add the sidecar to the watched-paths array.

Now the flow works end-to-end:
1. User clicks "New Chat" on a project
2. ChatViewModel starts ACP session with cwd=project.path
3. SessionAttributionService.attribute writes the sidecar
4. HermesFileWatcher detects the change, bumps lastChangeDate
5. ProjectSessionsView's onChange fires, VM reloads, new session
   appears in the list immediately

80/80 tests still pass. No test change needed — the sidecar's
direct tests are in SessionAttributionServiceTests; this is a
file-watching integration fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:58:07 +02:00
Alan Wizemann 7ad78a5492 fix(layout): cap RichChatView/ProjectSessions idealHeight; revert broken detail wrap
Prior commits tried to solve the "window grows whenever Chat or
Sessions is selected" bug by wrapping NavigationSplitView's detail
slot with an explicit frame (`205bb2c`). That broke the HSplitView
layout in Projects — the project list column, dashboard header,
tab bar, and Sessions-tab header all vanished. Scarf's convention
(PlatformsView.swift:12 calls it out explicitly) is to apply
size constraints on individual HSplitView columns, never on an
outer wrapper.

This commit:

- Reverts the broken ContentView.swift outer frame from `205bb2c`.
  NavigationSplitView.detail goes back to its v2.2.1 shape.

- Caps the subtrees whose natural ideal heights are what was
  actually pushing the window past the screen:
  - RichChatView: `.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)`
    on the outer VStack. The message list uses a plain VStack
    (deliberately, to dodge the LazyVStack whitespace bug — see
    RichChatMessageList.swift:13-24), so its natural ideal grows
    with every message. Capping idealHeight at 500 gives the
    window a screen-safe starting size without limiting how tall
    the view can flex when the user drags the window bigger.
  - ProjectSessionsView: same treatment with `idealHeight: 400`.
    Replaces the earlier `.frame(maxWidth: .infinity, maxHeight:
    .infinity)` which set MAX but didn't influence what got
    reported upward as ideal.

- Xcode regenerated Localizable.xcstrings during builds; riding
  along.

`.frame(idealHeight:)` is the specific SwiftUI knob that overrides
a child's reported ideal on the way up — `maxHeight: .infinity`
alone doesn't. With `.windowResizability(.contentMinSize)` (still
in scarfApp, left alone), the window sizes itself to the reported
ideal on open and respects user drags above the content min. With
a screen-safe ideal, the window opens at a usable size and never
pushes past the desktop.

User-verified: window behaves correctly across section switches,
resize persists, chat input bar always visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:57:15 +02:00
Alan Wizemann 205bb2c56e fix(window): pin detail column's reported frame so Chat/Sessions stop resizing window
Prior fixes (4baa3d4, 9aad905, d968878) narrowed the root cause
but didn't fully close the loop. Both the Chat section and the
v2.3 per-project Sessions tab were still growing the window past
the screen — the chat input bar ended up below the visible
desktop edge, unreachable.

Why the previous fixes weren't enough:
- Adding `.frame(maxHeight: .infinity)` on ChatView /
  ProjectSessionsView / dashboardArea told each view to FILL the
  space they were offered, but didn't cap what they reported UP
  the tree as their intrinsic ideal.
- `.windowResizability(.contentMinSize)` at the WindowGroup
  level used the content's minimum size as the window's min
  floor — and with VStack-based layouts (RichChatMessageList
  materialises every message in a plain VStack to avoid
  LazyVStack's whitespace bug), the minimum bubbles up as
  ~messages-total-height, which exceeds the screen on long
  sessions.

This commit pins the NavigationSplitView.detail slot's reported
frame explicitly. The detail column now reports:
- minWidth/minHeight: 500×300 — big enough for toolbars + chat
  input to always fit, small enough to work on any Mac screen
- idealWidth/idealHeight: 900×600 — reasonable first-launch size
  that fits under `.contentMinSize`'s floor without pushing past
  the screen
- maxWidth/maxHeight: infinity — user-resizable, no ceiling

With this bound intercepting the size-reporting chain,
NavigationSplitView's ideal becomes 500×300 ± idealWidth/Height
regardless of what ChatView or ProjectSessionsView's children
want internally. The window's content-derived minimum stays
bounded to a sensible value. Views still fill the offered space
because their `.frame(maxHeight: .infinity)` modifiers continue
to claim whatever the detail column hands them.

This is a window-layout-level fix that sits above the per-view
clamps in earlier commits — those stay in as defensive intra-
view layout, and the new frame here handles the outer coupling
to the window.

80/80 Swift tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:17:08 +02:00
Alan Wizemann d9688781ee fix(app): windowResizability(.contentMinSize) so window stops auto-resizing
Root cause of the "window grows whenever I switch to Chat / the
v2.3 Sessions tab" bug. Prior commits (4baa3d4 sessions-tab
clamp, 9aad905 chat+projects detail-area clamp) were defensive
but not sufficient — with the actual window policy treating
content's ideal height as a BINDING (not a minimum), those
clamps only kept things inside the view, not inside the window.

scarfApp's WindowGroup had .defaultSize(width: 1100, height: 700)
but no explicit .windowResizability(...) modifier. On macOS, a
non-Settings WindowGroup defaults to .automatic, which evaluates
to .contentSize — meaning every layout pass rebinds the window to
the currently-displayed detail view's ideal height. Explains
every symptom:

- Switching to Chat / Sessions grows the window to content size
- User drag-to-resize snaps back on next layout
- Sections with ScrollView-bounded content (Dashboard, Insights)
  "work" because their ideal height is their visible slot
- Resize while in a bounded section looks sticky because the
  rebind target doesn't push back
- Coming back to Chat reasserts the bind and the window grows
  again — sometimes past the screen

Switched to .windowResizability(.contentMinSize). Content's ideal
height is now a minimum FLOOR — user resize works freely, the
window persists across section switches, and it still can't
shrink below a section's minimum render (so tool bars, input
fields, etc. stay visible).

Pre-existing pre-v2.3 bug; v2.3's new content-heavy surfaces
(per-project Sessions list) just made it much more obvious. The
earlier clamp commits stay in — they're still correct for
intra-view layout, just not the window-level fix.

80/80 Swift tests still pass. No test change; behavior is
platform-layout-policy level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:06:57 +02:00
Alan Wizemann 9aad9051c4 fix(chat,projects): clamp detail-column views so they don't grow the window
Two sibling fixes to the one landed in 4baa3d4 (Sessions tab
height clamp). User reported that both the Chat section and the
per-project Sessions tab expanded the window height past the
screen once their content grew intrinsically.

Root cause is the same for both: the outer VStack at the top of
each view had no `.frame(maxHeight: .infinity)`. When
NavigationSplitView's detail slot renders one of these, SwiftUI
asks the child for its ideal height. Without a clamp, a tall
enough child (RichChatView's message list; a long attributed-
sessions list; a dashboard with a text widget containing a long
README block) bubbles its intrinsic size all the way up and
macOS grows the window to fit.

ChatView: add `.frame(maxWidth: .infinity, maxHeight: .infinity)`
to the outer VStack in `body`. Pre-existing issue that predated
v2.3 — it just happened to be masked by the chat area having
enough give until now. Surfaced as the user exercised the
section more during v2.3 testing.

ProjectsView: add the same modifier to the "dashboard is loaded"
VStack branch in `dashboardArea`. The ContentUnavailableView
branches (no dashboard / no projects / no selection) don't need
it — ContentUnavailableView self-clamps.

Both the widgetsTab (ScrollView) and the siteTab (explicit
maxHeight) were already fine. The sessions tab picked up its
fix in 4baa3d4. These two commits together cover every surface
that lives in the detail column.

80/80 Swift tests still pass. Visual-only fix; no test change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:00:19 +02:00
Alan Wizemann 4baa3d4d28 fix(projects): clamp Sessions tab height so it doesn't push the window
The new Sessions tab's outer VStack had no maxHeight constraint.
Its inner `List(sessions) { … }` uses intrinsic content size — which
grows with the row count — and with enough sessions the enclosing
VStack would push the project window past the bottom of the screen.

Fixed by adding `.frame(maxWidth: .infinity, maxHeight: .infinity)`
to the outer VStack in `ProjectSessionsView.body`, matching the
pattern `siteTab` uses for its webview. Now the List fills the
available tab area and scrolls internally as expected.

Other v2.3 tabs already self-constrain (`widgetsTab` via ScrollView,
`siteTab` via explicit maxHeight). This brings Sessions in line.

80/80 Swift tests still pass. Visual-only fix; no test change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:55:10 +02:00