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>
@@ -142,6 +142,42 @@ Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swif
|
||||
|
||||
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
|
||||
|
||||
### Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)
|
||||
|
||||
v2.3 adds a per-project Sessions tab and a "New Chat" button that spawns `hermes acp` with `cwd = project.path`. Session-to-project attribution is persisted in a Scarf-owned sidecar at `~/.hermes/scarf/session_project_map.json` — the ACP wire protocol has no project-metadata hook (extra params are silently dropped), and `state.db` has no cwd column, so the sidecar is Scarf's source of truth for "which project does this session belong to?" Managed by [SessionAttributionService.swift](scarf/scarf/Core/Services/SessionAttributionService.swift); read by the per-project [ProjectSessionsView.swift](scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift).
|
||||
|
||||
**Giving the agent project awareness.** Hermes auto-reads a context file from the session's cwd at startup — priority order `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap. We lean on that by writing a Scarf-managed block into `<project>/AGENTS.md` before opening the session. Service: [ProjectAgentContextService.swift](scarf/scarf/Core/Services/ProjectAgentContextService.swift). Block shape:
|
||||
|
||||
```
|
||||
<!-- scarf-project:begin -->
|
||||
## Scarf project context
|
||||
_Auto-generated by Scarf — do not edit between the begin/end markers._
|
||||
|
||||
You are operating inside a Scarf project named **"<Project Name>"**. …
|
||||
|
||||
- **Project directory:** `<absolute path>`
|
||||
- **Dashboard:** `<path>/.scarf/dashboard.json`
|
||||
- **Template:** `<author/id>` v<version> <!-- template-installed only -->
|
||||
- **Configuration fields:** `field_a`, `field_b (secret — name only, value stored in Keychain)`
|
||||
- **Registered cron jobs:** `[tmpl:<id>] <name>` — schedule …, currently paused|enabled
|
||||
- **Uninstall manifest:** `<path>/.scarf/template.lock.json` <!-- when present -->
|
||||
|
||||
Any content below this block is template- or user-authored; preserve and defer to it.
|
||||
<!-- scarf-project:end -->
|
||||
```
|
||||
|
||||
**Invariants.**
|
||||
|
||||
- **Secret-safe.** Block surfaces field NAMES, never VALUES. A project with a Keychain-stored secret shows `api_token (secret — name only, …)`; the Keychain ref URI and any plaintext value never appear. Auditable by `refreshListsFieldNamesNotValues` in `ProjectAgentContextServiceTests`.
|
||||
- **Idempotent.** Two refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta, avoiding file-watcher churn.
|
||||
- **Bounded.** Everything outside the markers is preserved on every refresh. Template-author AGENTS.md content lives safely below the block.
|
||||
- **Non-fatal.** `ChatViewModel.startACPSession` calls refresh with `try?` + log — a failed write doesn't block the chat from starting; worst case is the session loses project awareness.
|
||||
- **Refresh timing.** Called BEFORE `client.start()` so the block lands before Hermes's session-boot context scan. Skipping this ordering = the agent sees stale context from the previous refresh (or nothing, on fresh projects).
|
||||
|
||||
**Template-author contract.** A template shipped via the catalog should include an `AGENTS.md` with the template's operational instructions. Authors leave the `<!-- scarf-project -->` region alone — Scarf populates it at chat-start time. Everything below is template-owned and preserved.
|
||||
|
||||
**Known caveat.** If any parent directory of the project contains `.hermes.md` or `HERMES.md`, those shadow the project's `AGENTS.md` (higher in Hermes's priority order). No fix in v2.3 — deferred to v2.4 pending user input on how to handle authored `.hermes.md` files.
|
||||
|
||||
## Template Catalog
|
||||
|
||||
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
|
||||
|
||||
@@ -19,16 +19,24 @@
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||
</p>
|
||||
|
||||
## What's New in 2.2
|
||||
## What's New in 2.3
|
||||
|
||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar.
|
||||
- **Typed configuration with Keychain-backed secrets** — Templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`. A slider icon in the dashboard header opens the same form post-install for edits — rotate a token, change a site, toggle a feature, and the next cron run picks it up.
|
||||
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) is a static catalog site generated from `templates/<author>/<name>/` in this repo. Each template has a detail page with a live dashboard preview, the schema rendered with constraint summaries, and a one-click install button. Community submissions go through a CI-enforced Python validator that mirrors the Swift-side invariants.
|
||||
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), every Keychain secret that will be written, and a live diff of any memory appendix. Markdown fields render inline. Nothing writes until you click Install.
|
||||
- **Site tab** — A dashboard with at least one `webview` widget gets a second tab next to Dashboard. The example `awizemann/site-status-checker` template uses this to render whatever URL you configured as your first watched site, updating on every cron run.
|
||||
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records every file, cron job, Keychain ref, and memory block for one-click uninstall. Exports carry the configuration schema but never the user's values — safe on projects with live config. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
||||
- **Projects sidebar grows up** — group projects into folders, rename / archive / unarchive in place, filter the list with ⌘F, jump to the first nine with ⌘1–⌘9. Archived projects hide by default; a toggle in the bottom bar surfaces them. Non-destructive on the v2.2 registry file — downgrade stays clean.
|
||||
- **Per-project Sessions tab** — alongside Dashboard and Site. Shows chats attributed to the project, with a **New Chat** button that spawns `hermes acp` with the project's directory as the session cwd and attributes the result via a Scarf-owned sidecar (`~/.hermes/scarf/session_project_map.json`). Click any listed session to resume it with project context automatically restored.
|
||||
- **Agent actually knows what project it's in** — the architectural headline. Every project-scoped chat gets a Scarf-managed block auto-injected into the project's `AGENTS.md` before the session starts. Hermes reads AGENTS.md from the session's cwd at startup and picks up the block as part of its system prompt. Ask the agent *"what project am I in?"* and it answers with the project name, directory, template id + version, configuration field names, and registered cron jobs — pulled from the injected block. Secret-safe (field names only, never values), idempotent, bounded to `<!-- scarf-project:begin/end -->` markers so template-author content outside the block is preserved across refreshes.
|
||||
- **Project indicator in Chat** — folder chip in `SessionInfoBar` and `Chat · <ProjectName>` in the nav title when you're in a project-scoped chat. Resumed sessions keep the indicator by looking up the attribution sidecar at resume time.
|
||||
- **Window-layout cleanup** — switching to Chat or a Sessions tab no longer grows the window past the screen. `.windowResizability(.contentMinSize)` + targeted `idealHeight` caps keep the window's floor at a sensible content minimum while letting users freely drag larger or smaller.
|
||||
|
||||
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||
See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||
|
||||
### Previously, in 2.2
|
||||
|
||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the **Templates** menu in the Projects toolbar.
|
||||
- **Typed configuration with Keychain-backed secrets** — templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`.
|
||||
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) with live dashboard previews + schema rendering. CI-enforced Python validator mirrors the Swift-side invariants on every PR.
|
||||
- **Safe-by-design** — skills namespaced, cron jobs tagged and paused-on-install, lock-file-driven uninstall, exports carry schema but never values.
|
||||
|
||||
See the [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) for the full 2.2 series.
|
||||
|
||||
### Previously, in 2.1
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
## What's New in 2.3.0
|
||||
|
||||
The projects sidebar stops being a flat list and becomes a workspace. Folders, rename + archive + search + keyboard jumps, a per-project Sessions tab with a one-click New Chat button, and — the big architectural piece — every project-scoped chat now automatically carries Scarf-managed context into the agent itself, so the agent knows what project it's operating in without any user prompting.
|
||||
|
||||
### Projects sidebar grows up
|
||||
|
||||
- **Folders.** Group related projects with folders. Right-click any project → *Move to Folder…* — pick an existing folder or create a new one on the fly. Folders are soft: any folder name that isn't referenced by at least one project just disappears, so there's no "empty folder" state to clean up.
|
||||
- **Rename** a project from the context menu. Preserves everything else — the path, folder assignment, archive flag, and any running cron attribution stay intact. Rejects duplicate names + empty input with an inline warning.
|
||||
- **Archive / Unarchive.** Hide projects you don't actively use without deleting anything. The sidebar's bottom bar gains a Show Archived toggle so they're one click away when you need them.
|
||||
- **Search.** ⌘F focuses a filter field at the top of the sidebar. Fuzzy-matches on name, path, and folder label, live as you type.
|
||||
- **Keyboard jumps.** ⌘1 through ⌘9 jump to the first nine top-level projects. Pairs cleanly with Scarf's existing window-level shortcuts.
|
||||
|
||||
Registry migration is non-destructive — `~/.hermes/scarf/projects.json` gains two optional fields (`folder`, `archived`), and a file written by v2.3 is still parseable by v2.2.1 (unknown-keys are ignored), so downgrade works if you ever need it.
|
||||
|
||||
### Per-project Sessions tab
|
||||
|
||||
Every project now has a **Sessions** tab alongside Dashboard and Site. It shows chats attributed to this specific project — the sidecar at `~/.hermes/scarf/session_project_map.json` maintains the session-to-project mapping (Hermes's `state.db` has no column for this, so Scarf owns the record).
|
||||
|
||||
- **New Chat** — spawns `hermes acp` with the project's directory as the session's working directory, attributes the resulting session to the project, and takes you straight into the chat view.
|
||||
- **Click any listed session to resume it** in the Chat tab; the project indicator comes along automatically.
|
||||
- Forward-only attribution: sessions you've already started via the CLI or via the global Chat sidebar section continue to live in the global Sessions view unchanged; they simply aren't attributed to any project.
|
||||
|
||||
File descriptors are released cleanly on tab-disappear, matching Scarf's other Hermes-DB-reading VMs.
|
||||
|
||||
### Agent context injection via AGENTS.md
|
||||
|
||||
The architectural headline of this release. Hermes has no native "project" concept and ACP's wire protocol drops extra session params. But Hermes DOES auto-read `AGENTS.md` from the session's cwd at startup (priority: `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap). So Scarf leans on that.
|
||||
|
||||
Every time you start a project-scoped chat, Scarf writes a managed block into `<project>/AGENTS.md`:
|
||||
|
||||
```
|
||||
<!-- scarf-project:begin -->
|
||||
## Scarf project context
|
||||
|
||||
You are operating inside a Scarf project named "<Project Name>". …
|
||||
|
||||
- Project directory: …
|
||||
- Dashboard: …
|
||||
- Template: <id> v<version>
|
||||
- Configuration fields: field_a, api_token (secret — name only, value stored in Keychain)
|
||||
- Registered cron jobs: [tmpl:<id>] <name> — schedule …
|
||||
…
|
||||
<!-- scarf-project:end -->
|
||||
```
|
||||
|
||||
Ask a fresh chat *"what project am I in?"* and the agent answers with the project name, dashboard path, template id, and current cron schedule — pulled from the block Hermes injected into its system prompt automatically.
|
||||
|
||||
**Invariants the block guarantees:**
|
||||
|
||||
- **Secret-safe.** Surfaces config field *names* with type hints; never values. A project whose config.json has Keychain-ref URIs renders the fields as `api_token (secret — name only, value stored in Keychain)`. Keychain URIs and plaintext values never appear in the block. Locked in by an explicit test (`refreshListsFieldNamesNotValues`).
|
||||
- **Idempotent.** Two consecutive refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta — no unnecessary file-watcher churn.
|
||||
- **Bounded.** Everything outside the `<!-- scarf-project -->` markers is preserved across every refresh. Template-author AGENTS.md content lives safely below the block; hand-edits are never clobbered.
|
||||
- **Non-fatal.** A failed block refresh doesn't block the chat from starting — logged + the session proceeds without the extra context.
|
||||
- **Bare-project friendly.** Projects without an AGENTS.md (plain directories added via the + button) get one created with just the block. Agent awareness works even without template scaffolding.
|
||||
|
||||
**Template-author contract:** leave the `<!-- scarf-project -->` region alone in your bundled `AGENTS.md`. Put template-specific instructions below it so they're preserved across refreshes. The `scarf-template-author` scaffolding skill already teaches this pattern to future agents doing project scaffolding.
|
||||
|
||||
**Known caveat:** if any parent directory of your project contains a `.hermes.md` or `HERMES.md`, that file takes priority over the project's AGENTS.md in Hermes's discovery order — the Scarf block gets shadowed. No fix in 2.3 — planned for 2.4 pending design input on handling authored `.hermes.md` files.
|
||||
|
||||
### Chat UI — project awareness everywhere
|
||||
|
||||
Once the cwd, attribution, and AGENTS.md pieces land, the UI follows:
|
||||
|
||||
- **Folder chip in `SessionInfoBar`** at the start of the bar (before the working dot + title) shows the active project name with a folder icon.
|
||||
- **Navigation title** reads `Chat · <ProjectName>` when scoped, plain `Chat` otherwise — macOS `Subject — Detail` convention.
|
||||
- **Resumed sessions keep the indicator.** Whether you click a session in the project's Sessions tab or come in from a future deep-link, the attribution is looked up at resume time and the chip renders from the same state.
|
||||
|
||||
### Window-layout fixes
|
||||
|
||||
A pre-existing issue — untracked until v2.3's heavier Chat/Sessions content exposed it — where the window grew past the screen when you switched to content-heavy sections. Fixed by:
|
||||
|
||||
- Setting `WindowGroup.windowResizability(.contentMinSize)` so the window's floor (not ceiling) is derived from content.
|
||||
- Capping `idealHeight` on `RichChatView` and `ProjectSessionsView` so their plain-VStack children (deliberate choice to dodge a LazyVStack whitespace bug) don't report screen-exceeding ideals upward through `NavigationSplitView.detail`.
|
||||
|
||||
Window now stays at a user-draggable size and persists across section switches.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- New models: `SessionProjectMap` — `~/.hermes/scarf/session_project_map.json` serialization (`SessionAttributionService` manages it).
|
||||
- New services: `SessionAttributionService` (reads + writes the sidecar), `ProjectAgentContextService` (writes the AGENTS.md marker block, tests cover prepend/replace/idempotency/secret-redaction).
|
||||
- New view models: `ProjectSessionsViewModel` (per-project session list with attribution filter), `ChatViewModel` gains `currentProjectPath` + `currentProjectName`.
|
||||
- `HermesFileWatcher` now watches the attribution sidecar — file-system events propagate through the VMs as they do for every other Scarf-written file.
|
||||
- `ProjectsViewModel` gains `moveProject / renameProject / archiveProject / unarchiveProject / folders` — rename preserves selection; archive clears it; reorders driven by `localizedCaseInsensitiveCompare` for locale-aware ordering.
|
||||
- **22 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests`. Total: 93 tests.
|
||||
|
||||
### Icon tweak
|
||||
|
||||
App icon files renamed from iOS-template suffixes to macOS-native filenames + paired `Contents.json` update. Pure naming; no visual change at any rendered size.
|
||||
|
||||
### Migrating from 2.2.x
|
||||
|
||||
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched — the v2.3 additions (folders, archive, sidecar) are purely additive; a v2.2.1 projects.json loads cleanly.
|
||||
|
||||
If you had any chat sessions attributed to projects in a pre-release v2.3 build, the forward-only attribution model means those sidecar entries surface correctly in the new Sessions tab on first launch.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **[Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates)** — gained a "How the agent sees the project" section covering the AGENTS.md injection pattern.
|
||||
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, covering the sidecar, the marker contract, invariants, and the template-author contract.
|
||||
- **`scarf-template-author` skill** — pitfall bullet added so future scaffolding agents preserve the marker region when authoring new templates.
|
||||
|
||||
### Thanks
|
||||
|
||||
Thanks to the users who exercised this release through several layout iterations, caught the `fetchSessions` short-circuit on a fresh VM, and pushed on the "agent doesn't know what project it's in" question until the AGENTS.md mechanism clicked. Several of these fixes are small on their own but add up to a much tighter per-project workflow.
|
||||
@@ -56,3 +56,56 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||
|
||||
### 10. Config Editor
|
||||
- Structured form editor for config.yaml with validation
|
||||
|
||||
---
|
||||
|
||||
## Projects System Evolution (post-v2.2.1)
|
||||
|
||||
A parallel backlog specific to the Projects feature. Ordered by dependency: organization first, then per-project attribution via sidecar, then observability built on that attribution, then polish, then platform bets.
|
||||
|
||||
### Shipping in v2.3 (planned — plan file at `~/.claude/plans/`)
|
||||
|
||||
- **Folder hierarchy in the sidebar.** `ProjectEntry` gains optional `folder: String?`. `DisclosureGroup`-based sidebar.
|
||||
- **Rename + archive + search.** Registry verbs + a fuzzy ⌘F search + soft-archive (`archived: Bool?`) with Show/Hide toggle.
|
||||
- **⌘1–⌘9 project jumps.**
|
||||
- **Per-project Sessions tab** alongside Dashboard / Site. Filters the global sessions list by a new `~/.hermes/scarf/session_project_map.json` sidecar that Scarf populates when it starts a chat with a project context.
|
||||
- **New Chat button** on the Sessions tab — spawns `hermes acp` with `cwd = project.path` and attributes the resulting session in the sidecar.
|
||||
|
||||
### v2.4+ — per-project observability
|
||||
|
||||
Depends on v2.3's sidecar being stable. All features below are "filter the existing data by the sidecar's project mapping."
|
||||
|
||||
- **Per-project activity feed.** Extend `ActivityViewModel` with a `projectPath` filter that maps through the sidecar. Dashboard widget type `recent-activity`.
|
||||
- **Per-project token / cost rollup.** `InsightsViewModel.computeAggregates()` already sums over sessions; add a project filter. Widget binding `project.tokens` exposes it to agent-driven dashboards.
|
||||
- **Per-project cron-job filter.** Cron sidebar gains a project dropdown. Template-installed jobs already carry `[tmpl:<id>]` prefixes; match against installed template manifests to attribute.
|
||||
- **Desktop notifications for cron completion.** When a project-attributed cron job finishes (success or failure), fire a `UNUserNotification`. Per-project mute.
|
||||
|
||||
### v2.5+ — platform bets
|
||||
|
||||
Bigger investments with longer arcs.
|
||||
|
||||
- **Hermes upstream: `sessions.cwd` column.** Propose adding a nullable `cwd` (or `workspace_id`) column to Hermes's sessions table, populated on session create. Scarf would prefer the canonical column when available and fall back to the sidecar for pre-upgrade sessions. Requires coordinated Hermes release; filed under platform bets because it cuts the sidecar's blind spot (CLI-started sessions never enter the sidecar today).
|
||||
- **Per-project memory slice.** Hermes reads `MEMORY.md` from a known path. Explore whether Scarf can spawn `hermes acp` with an overridden memory path (per-project `<project>/.scarf/MEMORY.md`) so projects get isolated context. Needs a Hermes-side env var or flag.
|
||||
- **Per-project skills namespace.** Today user-authored skills are flat under `~/.hermes/skills/`. A `~/.hermes/skills/project/<slug>/` namespace parallel to the existing `templates/` namespace would let users install skills *into* a project without a template. Uninstall = drop the folder.
|
||||
- **Cross-project meta-dashboards.** A portfolio view that aggregates widgets from multiple projects — total token spend, combined activity feed, project-health matrix. Useful at 20+ projects.
|
||||
- **Project backup / restore.** One-click zip of `<project>/` + sidecar entries + related Keychain secrets, restorable on another machine. Richer than the existing Export flow (which carries the template shape only).
|
||||
|
||||
### Continuous — UX polish
|
||||
|
||||
Small, shippable at any time. Each is a half-day-to-one-day item.
|
||||
|
||||
- **Drag-and-drop to reorder** projects within a folder and between folders. Would be the first use of `.onDrag`/`.onDrop` in the codebase; establishes the pattern.
|
||||
- **Tags as a secondary axis.** Keep folders as primary, add multi-valued string tags + filter chips at the sidebar top. Decide only if folders feel insufficient after v2.3 lands.
|
||||
- **Favorites / pin** — bubble a project to the top of its folder.
|
||||
- **Recent projects collection** — auto-populated "Recents" row at the top of the sidebar.
|
||||
- **Color labels or SF Symbol icons** per project (Finder-tag-style).
|
||||
- **Project dashboard starter templates** — "blank", "monitor", "feed", "timeline" shapes when creating a bare project (distinct from `.scarftemplate` sharing flow).
|
||||
- **Opportunistic session backfill.** When Scarf loads any session that isn't in the sidecar, peek at first tool call's `working_directory` or `cwd` hint; if it matches a registered project path, write a sidecar entry. Heuristic, not perfect — useful as an "it just works" improvement after v2.3 ships.
|
||||
|
||||
### Research / verification gaps
|
||||
|
||||
Noted during v2.3 planning; chase when relevant:
|
||||
|
||||
- `DisclosureGroup` inside `List(.sidebar)` on macOS — occasional animation glitches with many-rows-expanding. Early prototype will confirm before full commit.
|
||||
- Concurrent sidecar writers from multiple Scarf windows on the same `~/.hermes` — atomic replace handles per-write; reload behavior may lag. Acceptable; revisit if users report stale attribution.
|
||||
- Do Hermes sessions ever persist `cwd` anywhere in `state.db` today that we've missed? If so, we can skip the sidecar and use it directly. Worth a one-hour investigation before starting v2.4 observability work.
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 962 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 742 KiB |
@@ -1,61 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
@@ -55,6 +55,18 @@ struct HermesPathSet: Sendable, Hashable {
|
||||
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
nonisolated var scarfDir: String { home + "/scarf" }
|
||||
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Written by `SessionAttributionService` when
|
||||
/// Scarf spawns `hermes acp` with a project-scoped cwd; read by
|
||||
/// the per-project Sessions tab (v2.3) to filter the session list
|
||||
/// to just those attributed to a given project.
|
||||
///
|
||||
/// Scarf-owned — Hermes never touches this file. Forward-only:
|
||||
/// we only attribute sessions Scarf creates in a project context;
|
||||
/// older / CLI-started sessions stay unattributed and surface in
|
||||
/// the global Sessions sidebar unchanged.
|
||||
nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -11,7 +11,63 @@ struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let path: String
|
||||
|
||||
/// Folder path for sidebar grouping. `nil` means top-level (no
|
||||
/// folder). Introduced in v2.3 — v2.2 registry files have no
|
||||
/// `folder` key, which decodes cleanly as `nil` via
|
||||
/// `decodeIfPresent` below.
|
||||
///
|
||||
/// We leave shape flexible: today this is treated as an opaque
|
||||
/// single-level label (e.g. "Clients"), and the sidebar renders
|
||||
/// one DisclosureGroup per distinct value. If nesting becomes a
|
||||
/// requirement later, we can interpret the string as a slash-
|
||||
/// separated path without a migration (old single-label values
|
||||
/// still mean a top-level folder with that name).
|
||||
var folder: String?
|
||||
|
||||
/// Soft-archive flag. Archived projects are hidden from the
|
||||
/// sidebar by default; a Show Archived toggle surfaces them.
|
||||
/// Non-destructive — nothing is deleted on disk. Introduced in
|
||||
/// v2.3; v2.2 registry files default to `false` via the custom
|
||||
/// decoder below.
|
||||
var archived: Bool
|
||||
|
||||
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||
|
||||
init(name: String, path: String, folder: String? = nil, archived: Bool = false) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.archived = archived
|
||||
}
|
||||
|
||||
// MARK: - Codable (custom for backward compat)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, folder, archived
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try c.decode(String.self, forKey: .name)
|
||||
self.path = try c.decode(String.self, forKey: .path)
|
||||
// Both new fields: tolerate absence for v2.2-era registries.
|
||||
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
|
||||
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(name, forKey: .name)
|
||||
try c.encode(path, forKey: .path)
|
||||
// Only emit optional fields when they carry meaning — keeps
|
||||
// registry files clean for the common (top-level, unarchived)
|
||||
// case and means v2.2 Scarf can still load a v2.3-written
|
||||
// registry of projects that never used the new features.
|
||||
try c.encodeIfPresent(folder, forKey: .folder)
|
||||
if archived {
|
||||
try c.encode(archived, forKey: .archived)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
|
||||
/// project path a chat was started for. Written on session create
|
||||
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
|
||||
/// by the per-project Sessions tab.
|
||||
///
|
||||
/// Hermes's own `state.db` has no `cwd` column on the sessions
|
||||
/// table — the cwd is passed at runtime via ACP but not persisted
|
||||
/// on its side. This sidecar is how we recover the attribution
|
||||
/// without requiring an upstream schema change.
|
||||
///
|
||||
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
|
||||
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||
/// can prefer that and fall back to this file for pre-upgrade
|
||||
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||
struct SessionProjectMap: Codable, Sendable {
|
||||
/// session-id → absolute-project-path. Both strings are opaque
|
||||
/// from this file's perspective; the service validates project
|
||||
/// paths against the live registry when building the reverse
|
||||
/// lookup used by the Sessions tab, so stale entries for
|
||||
/// removed projects are ignored at read time without needing a
|
||||
/// write-side cleanup.
|
||||
var mappings: [String: String]
|
||||
|
||||
/// ISO-8601 timestamp of the most recent write. Informational
|
||||
/// only — not used for any decision logic. Useful when debugging
|
||||
/// a stale sidecar ("when was this last updated?").
|
||||
var updatedAt: String?
|
||||
|
||||
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||
self.mappings = mappings
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Current time in ISO-8601 format, suitable for the
|
||||
/// `updatedAt` field. Matches the format used elsewhere in
|
||||
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||
/// greps across .json files sees consistent timestamps.
|
||||
static func nowISO8601() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,16 @@ final class HermesFileWatcher {
|
||||
paths.errorsLog,
|
||||
paths.gatewayLog,
|
||||
paths.projectsRegistry,
|
||||
// v2.3: sidecar attributing Hermes session IDs to Scarf project
|
||||
// paths. Written by SessionAttributionService when a chat
|
||||
// starts with a project context; read by
|
||||
// ProjectSessionsViewModel to filter the session list. Without
|
||||
// watching this file, the per-project Sessions tab would only
|
||||
// pick up new sessions when the user re-entered the tab
|
||||
// (triggering .task(id:) re-fire) — switching directly back
|
||||
// to the project's Sessions tab after a chat left the tab
|
||||
// stale.
|
||||
paths.sessionProjectMap,
|
||||
paths.mcpTokensDir
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
|
||||
/// that Hermes — which auto-reads `AGENTS.md` from the session's cwd
|
||||
/// at startup — has consistent project identity and metadata in every
|
||||
/// project-scoped chat.
|
||||
///
|
||||
/// **Why this exists.** Hermes has no native "project" concept and ACP
|
||||
/// passes only `(cwd, mcpServers)` at session create — extra params
|
||||
/// are silently dropped on Hermes's side. The documented hook for
|
||||
/// giving the agent context when cwd is set programmatically is the
|
||||
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
|
||||
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
|
||||
/// managed region of the project's AGENTS.md; template-author content
|
||||
/// lives outside that region and is preserved.
|
||||
///
|
||||
/// **Marker contract.** The region sits between:
|
||||
///
|
||||
/// ```
|
||||
/// <!-- scarf-project:begin -->
|
||||
/// …Scarf-managed content…
|
||||
/// <!-- scarf-project:end -->
|
||||
/// ```
|
||||
///
|
||||
/// Same pattern as the v2.2 memory-block appendix — bounded, self-
|
||||
/// declaring, safe to re-generate. Everything outside the markers is
|
||||
/// left byte-identical across refreshes.
|
||||
///
|
||||
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
|
||||
/// (via the cached manifest's schema) but never VALUES. A rendered
|
||||
/// block contains no secrets even for a project whose config.json
|
||||
/// has Keychain-ref URIs.
|
||||
///
|
||||
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
|
||||
/// calls `refresh(for:)` immediately before Hermes opens the session.
|
||||
/// Hermes reads AGENTS.md during session boot, so the marker block
|
||||
/// must have landed on disk first. Non-blocking on failure — a
|
||||
/// failed refresh logs and the chat proceeds without the block.
|
||||
struct ProjectAgentContextService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
||||
|
||||
/// Marker strings. Load-bearing: the format must stay stable
|
||||
/// across releases so existing project AGENTS.md files continue
|
||||
/// to be recognized and rewritten cleanly.
|
||||
static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
static let endMarker = "<!-- scarf-project:end -->"
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Refresh (or create) the Scarf-managed block in the project's
|
||||
/// AGENTS.md. Reads current project state — template manifest,
|
||||
/// config schema, registered cron jobs — and produces a block
|
||||
/// reflecting today's truth. Idempotent: two consecutive calls
|
||||
/// with no intervening state change produce byte-identical
|
||||
/// output.
|
||||
nonisolated func refresh(for project: ProjectEntry) throws {
|
||||
let block = renderBlock(for: project)
|
||||
let path = agentsMdPath(for: project)
|
||||
let transport = context.makeTransport()
|
||||
|
||||
// Ensure the project directory exists — this service is the
|
||||
// first thing that touches the project dir when the user
|
||||
// scaffolds a bare project via `+` + starts a chat. Normally
|
||||
// the dir exists (registered project = dir exists); belt-
|
||||
// and-suspenders for edge cases.
|
||||
if !transport.fileExists(project.path) {
|
||||
try transport.createDirectory(project.path)
|
||||
}
|
||||
|
||||
if !transport.fileExists(path) {
|
||||
// Fresh AGENTS.md with just our block + a trailing
|
||||
// newline so editors render it cleanly.
|
||||
let data = (block + "\n").data(using: .utf8) ?? Data()
|
||||
try transport.writeFile(path, data: data)
|
||||
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing, splice in the new block.
|
||||
let existingData = try transport.readFile(path)
|
||||
let existing = String(data: existingData, encoding: .utf8) ?? ""
|
||||
let rewritten = Self.applyBlock(block: block, to: existing)
|
||||
guard let outData = rewritten.data(using: .utf8) else {
|
||||
throw ProjectAgentContextError.encodingFailed
|
||||
}
|
||||
// Skip the write when nothing changed — avoids unnecessary
|
||||
// file-watcher churn. Matches what disk snapshot shows.
|
||||
guard outData != existingData else { return }
|
||||
try transport.writeFile(path, data: outData)
|
||||
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Marker splice (testable in isolation)
|
||||
|
||||
/// Core text transform: given an existing file and a freshly-
|
||||
/// rendered block, return the file with the block spliced in.
|
||||
///
|
||||
/// Three cases handled:
|
||||
/// 1. Existing file has both markers → replace the inclusive
|
||||
/// region, preserve everything outside untouched.
|
||||
/// 2. Existing file has no markers → prepend the block followed
|
||||
/// by a two-newline separator so it reads as its own section.
|
||||
/// 3. Existing file has a begin marker but no end → we DON'T try
|
||||
/// to be clever; treat as "no markers present" and prepend.
|
||||
/// User intervention or a later refresh can restore shape.
|
||||
/// The stray begin-marker is left in the file; we don't
|
||||
/// truncate to EOF (as the memory-block installer does)
|
||||
/// because an orphaned begin on this file is more likely
|
||||
/// hand-typed than a corrupt Scarf write.
|
||||
nonisolated static func applyBlock(block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
// No well-formed Scarf block present — prepend.
|
||||
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedExisting.isEmpty {
|
||||
return block + "\n"
|
||||
}
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
// Full span: from the begin marker through the end marker
|
||||
// (inclusive). Consumes any trailing whitespace/newlines
|
||||
// immediately following the end marker so a re-render of a
|
||||
// shorter block doesn't leave a dangling blank line.
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
// Preserve the leading whitespace / content structure of
|
||||
// `before` but ensure exactly one blank line separates it
|
||||
// from the new block when there IS prior content.
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: before.trimmingRightNewlines() + "\n\n"
|
||||
// Suffix: a blank line BEFORE the remaining content, ensuring
|
||||
// the template/user content is visually separated from the
|
||||
// Scarf block.
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + after.trimmingLeftNewlines()
|
||||
return prefix + block + suffix
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
/// Build the Markdown block for a given project. Pure function of
|
||||
/// project state — exposed for tests that want to assert on
|
||||
/// rendered content without touching disk.
|
||||
nonisolated func renderBlock(for project: ProjectEntry) -> String {
|
||||
let templateInfo = readTemplateInfo(for: project)
|
||||
let configFieldsLine = renderConfigFieldsLine(for: project)
|
||||
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
|
||||
let lockFilePresent = context.makeTransport().fileExists(
|
||||
project.path + "/.scarf/template.lock.json"
|
||||
)
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append(Self.beginMarker)
|
||||
lines.append("## Scarf project context")
|
||||
lines.append("")
|
||||
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
|
||||
lines.append("")
|
||||
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
|
||||
lines.append("")
|
||||
lines.append("- **Project directory:** `\(project.path)`")
|
||||
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
|
||||
|
||||
if let tpl = templateInfo {
|
||||
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
|
||||
}
|
||||
lines.append("- **Configuration fields:** \(configFieldsLine)")
|
||||
|
||||
if cronLines.isEmpty {
|
||||
lines.append("- **Registered cron jobs:** (none attributed to this project)")
|
||||
} else {
|
||||
lines.append("- **Registered cron jobs:**")
|
||||
for line in cronLines {
|
||||
lines.append(" - \(line)")
|
||||
}
|
||||
}
|
||||
|
||||
if lockFilePresent {
|
||||
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
|
||||
}
|
||||
|
||||
lines.append("")
|
||||
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
|
||||
lines.append(Self.endMarker)
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/AGENTS.md"
|
||||
}
|
||||
|
||||
/// Read `<project>/.scarf/manifest.json` for template id + version.
|
||||
/// Nil when not present (bare project) or when the file is
|
||||
/// unparseable — the block still renders cleanly without the
|
||||
/// template line.
|
||||
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath) else { return nil }
|
||||
guard let data = try? transport.readFile(manifestPath) else { return nil }
|
||||
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
|
||||
return (id: manifest.id, version: manifest.version)
|
||||
}
|
||||
|
||||
/// Build the "Configuration fields" bullet's tail. Returns a
|
||||
/// comma-joined list of backticked field names with inline type
|
||||
/// hints (`(secret)`), or the literal string "(none)" when the
|
||||
/// project has no config schema. **Never** includes values.
|
||||
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath),
|
||||
let data = try? transport.readFile(manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
|
||||
let schema = manifest.config,
|
||||
!schema.fields.isEmpty
|
||||
else {
|
||||
return "(none)"
|
||||
}
|
||||
let fieldList = schema.fields.map { field -> String in
|
||||
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
|
||||
return "`\(field.key)`\(secretTag)"
|
||||
}
|
||||
return fieldList.joined(separator: ", ")
|
||||
}
|
||||
|
||||
/// Return a list of human-readable cron-job descriptions for jobs
|
||||
/// attributed to this project via the `[tmpl:<id>] …` name prefix.
|
||||
/// Empty array when no jobs match (either the project has no
|
||||
/// template or no jobs carry the tag).
|
||||
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
|
||||
guard let templateId else { return [] }
|
||||
let prefix = "[tmpl:\(templateId)]"
|
||||
let jobs = HermesFileService(context: context).loadCronJobs()
|
||||
return jobs
|
||||
.filter { $0.name.hasPrefix(prefix) }
|
||||
.map { job in
|
||||
let scheduleDesc = job.schedule.display
|
||||
?? job.schedule.expression
|
||||
?? job.schedule.kind
|
||||
let pausedDesc = job.enabled ? "enabled" : "paused"
|
||||
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProjectAgentContextError: Error {
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
// MARK: - String helpers (file-scoped)
|
||||
|
||||
private extension String {
|
||||
/// Drop trailing newlines + CRs but preserve other trailing
|
||||
/// whitespace (tabs, non-breaking spaces) that might be
|
||||
/// meaningful in some edge case.
|
||||
func trimmingRightNewlines() -> String {
|
||||
var result = self
|
||||
while let last = result.last, last.isNewline {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Symmetric counterpart: strip leading newlines / CRs.
|
||||
func trimmingLeftNewlines() -> String {
|
||||
var result = self
|
||||
while let first = result.first, first.isNewline {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||
/// project paths. The `cwd` passed to `hermes acp` at session
|
||||
/// creation is ephemeral from Hermes's perspective (not written to
|
||||
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
|
||||
/// Hermes's session store.
|
||||
///
|
||||
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||
/// `HermesPathSet.sessionProjectMap`).
|
||||
///
|
||||
/// Thread safety: all public methods are `nonisolated` and each
|
||||
/// performs a single read-modify-write cycle that's atomic on
|
||||
/// disk. Concurrent writers (two Scarf windows on the same
|
||||
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||
/// but the in-memory read in one window may lag until that window
|
||||
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
|
||||
/// cross-talk becomes a problem.
|
||||
struct SessionAttributionService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
/// Load the current sidecar contents. Missing file or unparseable
|
||||
/// JSON returns an empty map — the sidecar is a convenience
|
||||
/// index, not a source of truth for anything load-bearing.
|
||||
nonisolated func load() -> SessionProjectMap {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return SessionProjectMap()
|
||||
}
|
||||
do {
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||
return SessionProjectMap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the project path a given session was attributed to.
|
||||
/// Returns nil for unattributed sessions (CLI-started, or
|
||||
/// started before v2.3) — those surface in the global Sessions
|
||||
/// sidebar unchanged and don't appear in any project's Sessions
|
||||
/// tab.
|
||||
nonisolated func projectPath(for sessionID: String) -> String? {
|
||||
load().mappings[sessionID]
|
||||
}
|
||||
|
||||
/// Reverse lookup: every session ID attributed to the given
|
||||
/// project path. Used by the per-project Sessions tab to filter
|
||||
/// the global session list. Comparison is exact-string; the
|
||||
/// registry stores absolute paths and we write absolute paths,
|
||||
/// so no normalisation is needed in practice.
|
||||
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||
let map = load()
|
||||
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||
}
|
||||
|
||||
// MARK: - Write
|
||||
|
||||
/// Record that `sessionID` was created under the given project
|
||||
/// path. Idempotent — repeated calls for the same pair are no-
|
||||
/// ops. Replacing an existing mapping (session moved to a
|
||||
/// different project) is legal but expected to be rare; the
|
||||
/// caller decides when that's correct.
|
||||
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||
var map = load()
|
||||
if map.mappings[sessionID] == projectPath {
|
||||
return
|
||||
}
|
||||
map.mappings[sessionID] = projectPath
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
|
||||
/// minimal — we don't currently prune on session delete because
|
||||
/// Hermes owns session lifecycle and we don't observe deletes.
|
||||
/// Exposed for future roadmap items (e.g. explicit "detach
|
||||
/// from project" action) and tests.
|
||||
nonisolated func forget(sessionID: String) {
|
||||
var map = load()
|
||||
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func persist(_ map: SessionProjectMap) {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
let dir = context.paths.scarfDir
|
||||
do {
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(map)
|
||||
try transport.writeFile(path, data: data)
|
||||
} catch {
|
||||
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,20 @@ final class ChatViewModel {
|
||||
let richChatViewModel: RichChatViewModel
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Absolute project path for the current session, when the chat is
|
||||
/// project-scoped (either started via a project's "New Chat" button
|
||||
/// or resumed from a session that was previously attributed via the
|
||||
/// v2.3 sidecar). Nil for plain global chats. Drives the project
|
||||
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
|
||||
private(set) var currentProjectPath: String?
|
||||
|
||||
/// Human-readable name of the active project, resolved from the
|
||||
/// projects registry at session-start time. Stored alongside the
|
||||
/// path so the view renders without hitting disk on every update.
|
||||
/// Nil when `currentProjectPath` is nil OR the path isn't in the
|
||||
/// registry (project was removed after the session was attributed).
|
||||
private(set) var currentProjectName: String?
|
||||
|
||||
// ACP state
|
||||
private var acpClient: ACPClient?
|
||||
private var acpEventTask: Task<Void, Never>?
|
||||
@@ -118,15 +132,20 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession() {
|
||||
func startNewSession(projectPath: String? = nil) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
startACPSession(resume: nil, projectPath: projectPath)
|
||||
} else {
|
||||
// Terminal mode doesn't surface project attribution today —
|
||||
// `hermes chat` uses the shell's cwd, so starting a terminal
|
||||
// chat from a project button would require changing the
|
||||
// shell's cwd too. Out of scope for v2.3 — Rich Chat is
|
||||
// the primary surface for project-scoped sessions.
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
}
|
||||
@@ -289,13 +308,33 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient(context: context)
|
||||
self.acpClient = client
|
||||
let attribution = SessionAttributionService(context: context)
|
||||
|
||||
// If the caller passed a project path, refresh the Scarf-
|
||||
// managed block in the project's AGENTS.md BEFORE starting
|
||||
// ACP — Hermes auto-reads AGENTS.md at session boot, so the
|
||||
// block has to land on disk first. Non-blocking on failure:
|
||||
// we log and proceed without the block. Safe on bare
|
||||
// projects (creates AGENTS.md with just the block); safe on
|
||||
// template-installed projects (splices the block into
|
||||
// existing AGENTS.md without touching template content).
|
||||
if let projectPath {
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
if let project = registry.projects.first(where: { $0.path == projectPath }) {
|
||||
do {
|
||||
try ProjectAgentContextService(context: context).refresh(for: project)
|
||||
} catch {
|
||||
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
@@ -305,7 +344,19 @@ final class ChatViewModel {
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = await context.resolvedUserHome()
|
||||
// Project-scoped chats pass the project's absolute path
|
||||
// as cwd so Hermes tool calls and subsequent ACP ops
|
||||
// resolve relative paths against the project's files.
|
||||
// Falls back to the user's home (existing v2.2 behavior)
|
||||
// when the caller didn't request a project scope.
|
||||
// `??` can't wrap an async autoclosure, so we
|
||||
// materialize the fallback with an if-let.
|
||||
let cwd: String
|
||||
if let projectPath {
|
||||
cwd = projectPath
|
||||
} else {
|
||||
cwd = await context.resolvedUserHome()
|
||||
}
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
@@ -334,6 +385,48 @@ final class ChatViewModel {
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Attribute this session to the project it was started
|
||||
// under, so the per-project Sessions tab can surface it
|
||||
// without a user action. No-op when projectPath is nil.
|
||||
// Idempotent: re-attribution of the same pair is free.
|
||||
if let projectPath {
|
||||
attribution.attribute(
|
||||
sessionID: resolvedSessionId,
|
||||
toProjectPath: projectPath
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve which project (if any) this session belongs
|
||||
// to, so SessionInfoBar + nav title can surface it.
|
||||
// Two inputs — use whichever is non-nil:
|
||||
// * `projectPath` — the caller asked for a project
|
||||
// scope (fresh project chat). Just-attributed;
|
||||
// definitely in the sidecar.
|
||||
// * `attribution.projectPath(for: resolvedSessionId)`
|
||||
// — the resumed session was previously attributed.
|
||||
// Covers "click an old project-attributed session
|
||||
// from the global Sessions sidebar / Resume menu"
|
||||
// where projectPath isn't known at the call site.
|
||||
let attributedPath = projectPath
|
||||
?? attribution.projectPath(for: resolvedSessionId)
|
||||
if let path = attributedPath {
|
||||
// Look up a human-readable name from the projects
|
||||
// registry. Missing project (path in the sidecar,
|
||||
// project since removed) → show the path as a
|
||||
// fallback label so the chip still renders and the
|
||||
// user sees *something* rather than silently losing
|
||||
// the indicator.
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
let name = registry.projects.first(where: { $0.path == path })?.name
|
||||
self.currentProjectPath = path
|
||||
self.currentProjectName = name ?? path
|
||||
} else {
|
||||
// Explicit clear on non-project sessions so the
|
||||
// indicator doesn't leak from a previous chat.
|
||||
self.currentProjectPath = nil
|
||||
self.currentProjectName = nil
|
||||
}
|
||||
|
||||
// Refresh session list so the new ACP session appears in the Resume menu
|
||||
await loadRecentSessions()
|
||||
|
||||
|
||||
@@ -3,25 +3,83 @@ import SwiftUI
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
// Clamp the outer VStack to the detail column's offered
|
||||
// space. Without this, the chat area's intrinsic height (a
|
||||
// RichChatView whose message list grows with content) can
|
||||
// bubble up through NavigationSplitView's detail slot and
|
||||
// push the whole window past the screen. Same pattern as
|
||||
// the Sessions tab fix in the v2.3 branch.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// v2.3: reflect the active Scarf project in the nav title
|
||||
// so the user can see at a glance that the chat is scoped
|
||||
// (complements the folder chip in SessionInfoBar). Falls
|
||||
// back to the plain "Chat" label for global chats.
|
||||
.navigationTitle(
|
||||
viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat"
|
||||
)
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
// Cold-launch handoff: if the user clicked "New Chat" on
|
||||
// a project before ChatView had a chance to render, the
|
||||
// coordinator was already populated. Consume the request
|
||||
// here. The onChange below handles the live case.
|
||||
if let pending = coordinator.pendingProjectChat {
|
||||
coordinator.pendingProjectChat = nil
|
||||
viewModel.startNewSession(projectPath: pending)
|
||||
}
|
||||
// Same story for resume-session handoff: the user clicked
|
||||
// a session in the Projects Sessions tab (routes to `.chat`
|
||||
// rather than `.sessions` so the chat actually reopens).
|
||||
// SessionsView consumes `selectedSessionId` for its own
|
||||
// routing; Chat now consumes it too. Mutually exclusive at
|
||||
// any given render because only one section is active per
|
||||
// `coordinator.selectedSection`. `else if` makes precedence
|
||||
// explicit — pendingProjectChat (new) outranks
|
||||
// selectedSessionId (resume) when both are somehow set.
|
||||
else if let pendingId = coordinator.selectedSessionId {
|
||||
coordinator.selectedSessionId = nil
|
||||
viewModel.resumeSession(pendingId)
|
||||
}
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
// Live handoff from the per-project Sessions tab: the tab
|
||||
// sets `pendingProjectChat` + flips `selectedSection` to
|
||||
// `.chat`; this view consumes the path and starts a fresh
|
||||
// session with cwd=projectPath. Attribution happens inside
|
||||
// ChatViewModel on successful session creation.
|
||||
.onChange(of: coord.pendingProjectChat) { _, new in
|
||||
if let projectPath = new {
|
||||
coordinator.pendingProjectChat = nil
|
||||
viewModel.startNewSession(projectPath: projectPath)
|
||||
}
|
||||
}
|
||||
// Live handoff for resume: user clicked an existing session in
|
||||
// the Projects Sessions tab while already in the Chat section
|
||||
// (or switched back to Chat after). Project-chip rendering
|
||||
// happens automatically inside ChatViewModel.resumeSession ->
|
||||
// startACPSession via the attribution.projectPath(for:) lookup.
|
||||
.onChange(of: coord.selectedSessionId) { _, new in
|
||||
if let sessionId = new {
|
||||
coordinator.selectedSessionId = nil
|
||||
viewModel.resumeSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner rendered between the toolbar and the chat area when either
|
||||
|
||||
@@ -17,7 +17,13 @@ struct RichChatView: View {
|
||||
isWorking: richChat.isAgentWorking,
|
||||
acpInputTokens: richChat.acpInputTokens,
|
||||
acpOutputTokens: richChat.acpOutputTokens,
|
||||
acpThoughtTokens: richChat.acpThoughtTokens
|
||||
acpThoughtTokens: richChat.acpThoughtTokens,
|
||||
// v2.3: surface the active Scarf project (if any) as
|
||||
// a folder chip at the start of the bar. Driven by
|
||||
// ChatViewModel.currentProjectName which is set in
|
||||
// startACPSession on both new project chats and
|
||||
// resumed project-attributed sessions.
|
||||
projectName: chatViewModel.currentProjectName
|
||||
)
|
||||
Divider()
|
||||
|
||||
@@ -42,6 +48,19 @@ struct RichChatView: View {
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
}
|
||||
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
|
||||
// height. Load-bearing: RichChatMessageList uses a plain VStack
|
||||
// (not LazyVStack — see RichChatMessageList.swift:13-24 for the
|
||||
// rationale) inside a ScrollView, so its natural ideal grows
|
||||
// with message count. Under the WindowGroup's
|
||||
// `.windowResizability(.contentMinSize)` policy, that uncapped
|
||||
// ideal would open the window at a height that exceeds the
|
||||
// screen on long conversations, pushing the input bar below
|
||||
// the visible desktop. `maxHeight: .infinity` still lets the
|
||||
// view fill any larger offered space, and `minHeight: 0`
|
||||
// allows it to shrink freely — the ideal cap only affects the
|
||||
// initial-size hint reported up to the window.
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -7,10 +7,28 @@ struct SessionInfoBar: View {
|
||||
var acpInputTokens: Int = 0
|
||||
var acpOutputTokens: Int = 0
|
||||
var acpThoughtTokens: Int = 0
|
||||
/// Name of the Scarf project this session is attributed to, when
|
||||
/// applicable. Nil for plain global chats. Drives the folder-chip
|
||||
/// indicator rendered before the session title. Resolved by
|
||||
/// `ChatViewModel.currentProjectName` — the view just passes it
|
||||
/// through.
|
||||
var projectName: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let session {
|
||||
// Project indicator first — visually anchors the session
|
||||
// as "scoped to project X" before the working dot and
|
||||
// title. Hidden for non-project chats so the bar looks
|
||||
// identical to v2.2.1 behavior.
|
||||
if let projectName {
|
||||
Label(projectName, systemImage: "folder.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tint)
|
||||
.lineLimit(1)
|
||||
.help("Chat is scoped to Scarf project \"\(projectName)\"")
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isWorking ? .green : .secondary)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
|
||||
/// global session list from `HermesDataService`, filters by the
|
||||
/// attribution sidecar, and exposes a minimal surface for the view:
|
||||
/// the filtered sessions array, loading state, and a refresh entry
|
||||
/// point that the view can call on appearance + on file-watcher
|
||||
/// change.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ProjectSessionsViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
||||
|
||||
private let dataService: HermesDataService
|
||||
private let attribution: SessionAttributionService
|
||||
private let project: ProjectEntry
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
self.dataService = HermesDataService(context: context)
|
||||
self.attribution = SessionAttributionService(context: context)
|
||||
self.project = project
|
||||
}
|
||||
|
||||
/// Sessions attributed to the owning project, in the order
|
||||
/// `HermesDataService.fetchSessions` returns them (newest first).
|
||||
var sessions: [HermesSession] = []
|
||||
|
||||
/// True from `load()` start to its completion. The view renders
|
||||
/// a ProgressView during the first fetch; afterwards, re-fetches
|
||||
/// triggered by file-watcher changes happen silently.
|
||||
var isLoading: Bool = false
|
||||
|
||||
/// Short diagnostic string for an empty list — nil when sessions
|
||||
/// are loaded and populated, otherwise explains the empty state
|
||||
/// (no sessions ever created in this project, vs. no sessions
|
||||
/// matched the project's attribution map).
|
||||
var emptyStateHint: String?
|
||||
|
||||
/// Refresh the session list. Safe to call repeatedly; the data
|
||||
/// service reconnects to state.db on demand and the attribution
|
||||
/// service reads the sidecar afresh each call.
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let attributed = attribution.sessionIDs(forProject: project.path)
|
||||
if attributed.isEmpty {
|
||||
sessions = []
|
||||
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
|
||||
return
|
||||
}
|
||||
|
||||
// Open (or re-open for remote) the DB handle before querying.
|
||||
// `HermesDataService` is an actor with a lazily-initialised
|
||||
// SQLite pointer; every query method short-circuits to `[]`
|
||||
// when `db == nil`. This VM constructs its own service
|
||||
// instance (separate from ChatViewModel / InsightsVM /
|
||||
// ActivityVM), so we have to open it ourselves. Same
|
||||
// pattern used by those other VMs (`refresh()` rather than
|
||||
// `open()` because refresh also re-pulls the remote-server
|
||||
// snapshot on each call — local is a cheap no-op).
|
||||
_ = await dataService.refresh()
|
||||
|
||||
// Fetch a generous page; we filter client-side by attribution
|
||||
// map membership. The 200 ceiling matches other feature VMs
|
||||
// (ActivityViewModel, InsightsViewModel). HermesDataService
|
||||
// is an actor so this crosses the isolation boundary — the
|
||||
// SQLite read happens off the MainActor. If a single project
|
||||
// accumulates more than 200 attributed sessions, we'll need
|
||||
// a paged query; roadmap item, not a v2.3 problem.
|
||||
let all = await dataService.fetchSessions(limit: 200)
|
||||
let filtered = all.filter { attributed.contains($0.id) }
|
||||
sessions = filtered
|
||||
|
||||
if filtered.isEmpty {
|
||||
// Attribution map has entries but none appear in the
|
||||
// recent session fetch — likely stale sidecar entries
|
||||
// for sessions Hermes has since deleted. The view shows
|
||||
// an informational empty state; pruning stale entries
|
||||
// is a roadmap follow-up, not a blocker.
|
||||
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
|
||||
} else {
|
||||
emptyStateHint = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the underlying DB handle. Safe to call repeatedly; the
|
||||
/// service re-opens on the next `load()`. Mirrors the pattern in
|
||||
/// ActivityViewModel.swift:80 — view calls this on `.onDisappear`
|
||||
/// so file descriptors and the SQLite cache don't dangle once
|
||||
/// the tab isn't visible.
|
||||
func close() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,101 @@ final class ProjectsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - v2.3 registry verbs (folder / archive / rename)
|
||||
|
||||
/// Move a project into a folder. `nil` folder returns the project
|
||||
/// to the top level. No-op when the target already matches.
|
||||
func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
|
||||
mutateEntry(project) { $0.folder = folder }
|
||||
}
|
||||
|
||||
/// Rename a project. `name` is the registry's unique key + the
|
||||
/// Identifiable id; we reject renames that would collide with
|
||||
/// another project's name. Returns true on success.
|
||||
@discardableResult
|
||||
func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
guard trimmed != project.name else { return true }
|
||||
var registry = service.loadRegistry()
|
||||
// Reject collisions — a second project already owns that name.
|
||||
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
|
||||
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
|
||||
let old = registry.projects[index]
|
||||
registry.projects[index] = ProjectEntry(
|
||||
name: trimmed,
|
||||
path: old.path,
|
||||
folder: old.folder,
|
||||
archived: old.archived
|
||||
)
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
projects = registry.projects
|
||||
// Preserve selection across the rename — the selected project
|
||||
// still exists, it just has a new id.
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = registry.projects[index]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Soft-archive a project. It stays on disk and in the registry;
|
||||
/// the sidebar just hides it unless `showArchived` is on.
|
||||
func archiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = true }
|
||||
// If the archived project was selected, clear selection so
|
||||
// the dashboard doesn't linger on a hidden project.
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
dashboard = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore an archived project to the default view.
|
||||
func unarchiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = false }
|
||||
}
|
||||
|
||||
/// Distinct folder labels across the current project set, sorted
|
||||
/// alphabetically. Drives the sidebar's DisclosureGroups (commit
|
||||
/// 2) and the Move-to-Folder sheet's existing-folder list. An
|
||||
/// "empty" folder (folder with zero projects) can't exist under
|
||||
/// this model — folders are implicit in the data — which is
|
||||
/// intentional: v2.3 doesn't need first-class empty folders.
|
||||
var folders: [String] {
|
||||
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
|
||||
return set.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Fetch the registry, apply `mutation` to the matched entry,
|
||||
/// persist, update in-memory state. Centralises the save +
|
||||
/// re-publish dance shared by `moveProject`, `archiveProject`,
|
||||
/// and `unarchiveProject`. Callers that need different matching
|
||||
/// semantics (rename, remove) handle their own registry mutation.
|
||||
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
|
||||
var registry = service.loadRegistry()
|
||||
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
|
||||
var entry = registry.projects[index]
|
||||
mutation(&entry)
|
||||
registry.projects[index] = entry
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = entry
|
||||
}
|
||||
}
|
||||
|
||||
func refreshDashboard() {
|
||||
guard let project = selectedProject else { return }
|
||||
loadDashboard(for: project)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet for assigning a project to a folder in the sidebar. Folders
|
||||
/// are implicit — they exist because at least one project references
|
||||
/// them via its `folder` field. The "create" action here just seeds
|
||||
/// a new label the user types; it becomes real once any project is
|
||||
/// assigned to it.
|
||||
struct MoveToFolderSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let project: ProjectEntry
|
||||
/// Existing folder labels in the registry, sorted. Computed by
|
||||
/// the caller via `ProjectsViewModel.folders`.
|
||||
let existingFolders: [String]
|
||||
/// Called with the chosen folder. `nil` means "move back to top
|
||||
/// level". Caller wires this through
|
||||
/// `ProjectsViewModel.moveProject(_:toFolder:)`.
|
||||
let onMove: (String?) -> Void
|
||||
|
||||
@State private var mode: Mode
|
||||
@State private var newFolderName: String = ""
|
||||
|
||||
private enum Mode: Hashable {
|
||||
case topLevel
|
||||
case existing(String)
|
||||
case new
|
||||
}
|
||||
|
||||
init(
|
||||
project: ProjectEntry,
|
||||
existingFolders: [String],
|
||||
onMove: @escaping (String?) -> Void
|
||||
) {
|
||||
self.project = project
|
||||
self.existingFolders = existingFolders
|
||||
self.onMove = onMove
|
||||
// Start selection on the project's current folder if any,
|
||||
// otherwise "Top Level". Feels right — Move sheet should
|
||||
// reflect where the project currently lives.
|
||||
if let current = project.folder, existingFolders.contains(current) {
|
||||
_mode = State(initialValue: .existing(current))
|
||||
} else {
|
||||
_mode = State(initialValue: .topLevel)
|
||||
}
|
||||
}
|
||||
|
||||
private var canMove: Bool {
|
||||
switch mode {
|
||||
case .topLevel, .existing:
|
||||
return true
|
||||
case .new:
|
||||
return !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Move \"\(project.name)\" to folder").font(.headline)
|
||||
Text("Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Picker("Destination", selection: $mode) {
|
||||
Text("Top Level").tag(Mode.topLevel)
|
||||
if !existingFolders.isEmpty {
|
||||
Section {
|
||||
ForEach(existingFolders, id: \.self) { folder in
|
||||
Text(folder).tag(Mode.existing(folder))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("New folder…").tag(Mode.new)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.inline)
|
||||
|
||||
if case .new = mode {
|
||||
TextField("New folder name", text: $newFolderName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
if canMove { commit() }
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Move") { commit() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canMove)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
}
|
||||
|
||||
private func commit() {
|
||||
switch mode {
|
||||
case .topLevel:
|
||||
onMove(nil)
|
||||
case .existing(let folder):
|
||||
onMove(folder)
|
||||
case .new:
|
||||
let trimmed = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onMove(trimmed)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and
|
||||
/// Site tabs in the project view; populated from the session
|
||||
/// attribution sidecar maintained by ChatViewModel. A "New Chat"
|
||||
/// button spawns a fresh ACP session at cwd = project.path and
|
||||
/// routes the user into the Chat feature via AppCoordinator.
|
||||
struct ProjectSessionsView: View {
|
||||
let project: ProjectEntry
|
||||
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
@State private var viewModel: ProjectSessionsViewModel?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
// `idealHeight: 400` caps what this subtree reports as its
|
||||
// ideal height. Without it, the inner List's row-materialised
|
||||
// intrinsic height bubbles up through NavigationSplitView's
|
||||
// detail slot and, under `.windowResizability(.contentMinSize)`,
|
||||
// opens the window at a height that exceeds the screen on
|
||||
// busy projects — the Sessions tab header + "New Chat" button
|
||||
// end up below the visible desktop edge. `maxHeight: .infinity`
|
||||
// still lets the List fill any taller offered space, and
|
||||
// `minHeight: 0` allows it to shrink. Mirrors the same pattern
|
||||
// applied in RichChatView.
|
||||
.frame(minHeight: 0, idealHeight: 400, maxHeight: .infinity)
|
||||
.task(id: project.id) {
|
||||
// Rebuild the VM when the project changes so stale state
|
||||
// from a previously-selected project doesn't bleed
|
||||
// through.
|
||||
viewModel = ProjectSessionsViewModel(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
await viewModel?.load()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel?.load() }
|
||||
}
|
||||
.onDisappear {
|
||||
// Release the SQLite handle so it doesn't dangle once
|
||||
// the user leaves this tab. `load()` will re-open next
|
||||
// time. Mirrors ActivityView's disappear cleanup.
|
||||
Task { await viewModel?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Sessions in this project")
|
||||
.font(.headline)
|
||||
Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
// Route into the Chat feature with a cwd override.
|
||||
// ChatView observes this via its onChange and starts
|
||||
// a fresh session with projectPath = our project.
|
||||
coordinator.pendingProjectChat = project.path
|
||||
coordinator.selectedSection = .chat
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let vm = viewModel {
|
||||
if vm.isLoading && vm.sessions.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.sessions.isEmpty {
|
||||
emptyState(hint: vm.emptyStateHint)
|
||||
} else {
|
||||
sessionList(vm.sessions)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyState(hint: String?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(hint ?? "No sessions yet.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func sessionList(_ sessions: [HermesSession]) -> some View {
|
||||
List(sessions) { session in
|
||||
ProjectSessionRow(session: session)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
// Route into the Chat feature with this session
|
||||
// as a resume target. Existing ChatView logic
|
||||
// handles ACP reconnect.
|
||||
coordinator.selectedSessionId = session.id
|
||||
coordinator.selectedSection = .chat
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Single row in the per-project Sessions list. Intentionally small
|
||||
/// and self-contained so it can evolve independently of the global
|
||||
/// Sessions sidebar's row UI — if the two visualisations diverge
|
||||
/// (e.g. the project tab wants to hide the `source` badge that's
|
||||
/// useful in the global list), they don't pull each other along.
|
||||
private struct ProjectSessionRow: View {
|
||||
let session: HermesSession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: iconForSource(session.source))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(session.id.prefix(12))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let started = formattedStart {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(started)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(session.messageCount)")
|
||||
.font(.caption.monospaced())
|
||||
Text("msgs")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
if let t = session.title, !t.isEmpty { return t }
|
||||
return "Untitled session"
|
||||
}
|
||||
|
||||
private var formattedStart: String? {
|
||||
// `startedAt` is `Date?` — the DB column can be null for
|
||||
// sessions in unusual states. Locale-aware short form keeps
|
||||
// us consistent with Insights + Activity.
|
||||
guard let date = session.startedAt else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func iconForSource(_ source: String) -> String {
|
||||
switch source.lowercased() {
|
||||
case "cli", "acp": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sidebar view for the Projects feature. Renders the registry as:
|
||||
/// - A search field at the top (⌘F focus).
|
||||
/// - Top-level (folder-less) projects.
|
||||
/// - Collapsible DisclosureGroups, one per folder.
|
||||
/// - An "Archived" DisclosureGroup at the bottom, hidden unless the
|
||||
/// Show Archived toggle is on.
|
||||
///
|
||||
/// Selection is bound to `viewModel.selectedProject` so the
|
||||
/// dashboard area stays in sync with clicks anywhere in the hierarchy.
|
||||
/// Context-menu actions delegate back to the parent view via closures
|
||||
/// so the sheets / confirmation dialogs stay co-located with the rest
|
||||
/// of ProjectsView's state.
|
||||
struct ProjectsSidebar: View {
|
||||
@Bindable var viewModel: ProjectsViewModel
|
||||
|
||||
// Predicates hoisted from the parent — avoid reaching down into
|
||||
// service objects from this view.
|
||||
let canConfigureProject: (ProjectEntry) -> Bool
|
||||
let isTemplateInstalled: (ProjectEntry) -> Bool
|
||||
|
||||
// Context-menu + bottom-bar callbacks. Parent owns sheet state
|
||||
// (install, uninstall, rename, move-to-folder, remove-from-list
|
||||
// confirmation dialog) — this view just routes user intent.
|
||||
let onConfigure: (ProjectEntry) -> Void
|
||||
let onUninstallTemplate: (ProjectEntry) -> Void
|
||||
let onRemoveFromList: (ProjectEntry) -> Void
|
||||
let onRename: (ProjectEntry) -> Void
|
||||
let onMoveToFolder: (ProjectEntry) -> Void
|
||||
let onAddProject: () -> Void
|
||||
|
||||
/// Per-view UI state — filter text, show-archived toggle, and
|
||||
/// which folders are expanded. Folder expansion defaults to all
|
||||
/// open so a new user sees everything; they can collapse what
|
||||
/// they don't want.
|
||||
@State private var filterText: String = ""
|
||||
@State private var showArchived: Bool = false
|
||||
@State private var expandedFolders: Set<String> = []
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
searchField
|
||||
Divider()
|
||||
list
|
||||
Divider()
|
||||
bottomBar
|
||||
}
|
||||
.onAppear {
|
||||
// Start with every folder expanded on first render. If
|
||||
// users collapse, that choice persists for the lifetime
|
||||
// of the view instance (window open).
|
||||
expandedFolders = Set(viewModel.folders)
|
||||
}
|
||||
.onChange(of: viewModel.folders) { _, newFolders in
|
||||
// When a new folder appears (user just moved a project
|
||||
// into one), start it expanded so the move is visibly
|
||||
// reflected.
|
||||
expandedFolders.formUnion(newFolders)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
private var searchField: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
TextField("Filter projects", text: $filterText)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.font(.caption)
|
||||
if !filterText.isEmpty {
|
||||
Button {
|
||||
filterText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedProject },
|
||||
set: { if let p = $0 { viewModel.selectProject(p) } }
|
||||
)) {
|
||||
// Top-level projects first — matches the Finder-like
|
||||
// mental model where top-level items sit above folders.
|
||||
ForEach(topLevelVisible) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
|
||||
// Per-folder collapsible sections.
|
||||
ForEach(visibleFolders, id: \.self) { folder in
|
||||
let children = folderProjects(folder)
|
||||
if !children.isEmpty {
|
||||
DisclosureGroup(
|
||||
isExpanded: Binding(
|
||||
get: { expandedFolders.contains(folder) },
|
||||
set: { expanded in
|
||||
if expanded {
|
||||
expandedFolders.insert(folder)
|
||||
} else {
|
||||
expandedFolders.remove(folder)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
ForEach(children) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
} label: {
|
||||
Label(folder, systemImage: "folder")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Archived section — only surfaces under the toggle.
|
||||
if showArchived, !archivedVisible.isEmpty {
|
||||
DisclosureGroup {
|
||||
ForEach(archivedVisible) { project in
|
||||
projectRow(project)
|
||||
.opacity(0.7)
|
||||
}
|
||||
} label: {
|
||||
Label("Archived (\(archivedVisible.count))", systemImage: "archivebox")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func projectRow(_ project: ProjectEntry) -> some View {
|
||||
HStack {
|
||||
Image(
|
||||
systemName: viewModel.dashboard != nil
|
||||
&& viewModel.selectedProject == project
|
||||
? "square.grid.2x2.fill"
|
||||
: "square.grid.2x2"
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(project.name)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.tag(project)
|
||||
.contextMenu {
|
||||
projectContextMenu(project)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func projectContextMenu(_ project: ProjectEntry) -> some View {
|
||||
if canConfigureProject(project) {
|
||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||
onConfigure(project)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("Rename…", systemImage: "pencil") { onRename(project) }
|
||||
Button("Move to Folder…", systemImage: "folder") { onMoveToFolder(project) }
|
||||
if project.archived {
|
||||
Button("Unarchive", systemImage: "tray.and.arrow.up") {
|
||||
viewModel.unarchiveProject(project)
|
||||
}
|
||||
} else {
|
||||
Button("Archive", systemImage: "archivebox") {
|
||||
viewModel.archiveProject(project)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
if isTemplateInstalled(project) {
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
onUninstallTemplate(project)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
onRemoveFromList(project)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bottom bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
Button(action: onAddProject) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Add a project")
|
||||
|
||||
Toggle(isOn: $showArchived) {
|
||||
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
|
||||
.font(.caption)
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.buttonStyle(.borderless)
|
||||
.help(showArchived ? "Hide archived projects" : "Show archived projects")
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button(action: { onRemoveFromList(selected) }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
// MARK: - Derived data
|
||||
|
||||
/// Fuzzy-match on name + path + folder label. Case-insensitive,
|
||||
/// substring — not a true fuzzy search, but matches the project
|
||||
/// count scale (tens, not thousands). Upgradable to a Levenshtein
|
||||
/// scorer later without changing the call sites.
|
||||
private func matches(_ project: ProjectEntry) -> Bool {
|
||||
let needle = filterText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard !needle.isEmpty else { return true }
|
||||
if project.name.lowercased().contains(needle) { return true }
|
||||
if project.path.lowercased().contains(needle) { return true }
|
||||
if let folder = project.folder, folder.lowercased().contains(needle) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Visible top-level projects (no folder, not archived, passes
|
||||
/// the current filter). Sort is stable by name — the registry
|
||||
/// already preserves insertion order, but showing a sorted list
|
||||
/// of homogeneous top-level entries feels cleaner.
|
||||
private var topLevelVisible: [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { ($0.folder ?? "").isEmpty && !$0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Folders that currently have at least one matching, non-
|
||||
/// archived project. Folders with only archived projects move
|
||||
/// into the Archived section's items; empty folders disappear.
|
||||
private var visibleFolders: [String] {
|
||||
viewModel.folders.filter { !folderProjects($0).isEmpty }
|
||||
}
|
||||
|
||||
private func folderProjects(_ folder: String) -> [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { $0.folder == folder && !$0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private var archivedVisible: [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { $0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,21 @@ import UniformTypeIdentifiers
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
case dashboard = "Dashboard"
|
||||
case site = "Site"
|
||||
case sessions = "Sessions"
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .dashboard: return "Dashboard"
|
||||
case .site: return "Site"
|
||||
case .sessions: return "Sessions"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .site: return "globe"
|
||||
case .sessions: return "bubble.left.and.bubble.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +44,16 @@ struct ProjectsView: View {
|
||||
/// drop from the registry.
|
||||
@State private var pendingRemoveFromList: ProjectEntry?
|
||||
|
||||
/// Project queued for the rename sheet (v2.3). Sheet state lives
|
||||
/// on the parent view so the sidebar stays a pure presentation
|
||||
/// layer; rename logic routes through `ProjectsViewModel.renameProject`.
|
||||
@State private var renameTarget: ProjectEntry?
|
||||
|
||||
/// Project queued for the move-to-folder sheet (v2.3). Same
|
||||
/// pattern as renameTarget: parent owns sheet state, sidebar
|
||||
/// delegates up.
|
||||
@State private var moveTarget: ProjectEntry?
|
||||
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
init(context: ServerContext) {
|
||||
@@ -263,79 +283,47 @@ struct ProjectsView: View {
|
||||
// MARK: - Project List
|
||||
|
||||
private var projectList: some View {
|
||||
VStack(spacing: 0) {
|
||||
List(viewModel.projects, selection: Binding(
|
||||
get: { viewModel.selectedProject },
|
||||
set: { project in
|
||||
if let project {
|
||||
viewModel.selectProject(project)
|
||||
}
|
||||
}
|
||||
)) { project in
|
||||
HStack {
|
||||
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
|
||||
? "square.grid.2x2.fill" : "square.grid.2x2")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(project.name)
|
||||
}
|
||||
.tag(project)
|
||||
.contextMenu {
|
||||
if isConfigurable(project) {
|
||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||
configEditorProject = project
|
||||
}
|
||||
}
|
||||
if uninstaller.isTemplateInstalled(project: project) {
|
||||
// "Uninstall Template…" only appears for projects
|
||||
// installed from a `.scarftemplate`. Trailing
|
||||
// ellipsis signals a confirmation sheet follows
|
||||
// (macOS HIG convention); the sheet itself lists
|
||||
// every file/cron/skill that will be removed.
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
// "Remove from List" used to be "Remove from Scarf",
|
||||
// which users read as a full delete. Clarified label +
|
||||
// ellipsis + confirmation dialog all spell out that
|
||||
// this is registry-only; nothing on disk is touched.
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
pendingRemoveFromList = project
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Button(action: { showingAddSheet = true }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
if let selected = viewModel.selectedProject {
|
||||
// Route through the same confirmation dialog as the
|
||||
// context-menu "Remove from List" entry. The minus
|
||||
// icon is a drive-by click target right next to "+" —
|
||||
// confirming before mutating the registry stops the
|
||||
// "I clicked by accident and my project's gone" case.
|
||||
Button(action: { pendingRemoveFromList = selected }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
// Sidebar is an extracted view; this view stays the owner of
|
||||
// sheet state (add / rename / move / uninstall / remove-from-
|
||||
// list confirmation) and routes intents down as closures.
|
||||
ProjectsSidebar(
|
||||
viewModel: viewModel,
|
||||
canConfigureProject: { isConfigurable($0) },
|
||||
isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
|
||||
onConfigure: { configEditorProject = $0 },
|
||||
onUninstallTemplate: { project in
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
},
|
||||
onRemoveFromList: { pendingRemoveFromList = $0 },
|
||||
onRename: { renameTarget = $0 },
|
||||
onMoveToFolder: { moveTarget = $0 },
|
||||
onAddProject: { showingAddSheet = true }
|
||||
)
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddProjectSheet { name, path in
|
||||
viewModel.addProject(name: name, path: path)
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
}
|
||||
.sheet(item: $renameTarget) { target in
|
||||
RenameProjectSheet(
|
||||
project: target,
|
||||
existingNames: viewModel.projects
|
||||
.filter { $0.name != target.name }
|
||||
.map(\.name)
|
||||
) { newName in
|
||||
viewModel.renameProject(target, to: newName)
|
||||
}
|
||||
}
|
||||
.sheet(item: $moveTarget) { target in
|
||||
MoveToFolderSheet(
|
||||
project: target,
|
||||
existingFolders: viewModel.folders
|
||||
) { newFolder in
|
||||
viewModel.moveProject(target, toFolder: newFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard Area
|
||||
@@ -355,11 +343,13 @@ struct ProjectsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.padding(.bottom, 8)
|
||||
if siteWidget != nil {
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
// Sessions tab is always present in v2.3, so the tab
|
||||
// bar always renders when a dashboard is loaded.
|
||||
// Site tab filters out when there's no webview widget
|
||||
// (existing v2.2 behavior preserved).
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
widgetsTab(dashboard)
|
||||
@@ -369,8 +359,24 @@ struct ProjectsView: View {
|
||||
} else {
|
||||
widgetsTab(dashboard)
|
||||
}
|
||||
case .sessions:
|
||||
if let project = viewModel.selectedProject {
|
||||
ProjectSessionsView(project: project)
|
||||
} else {
|
||||
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamp the container VStack to the detail column's
|
||||
// offered space. Without it, any tab whose content is
|
||||
// taller than the window (long Sessions list, tall
|
||||
// README block in a dashboard's text widget, etc.) can
|
||||
// bubble its intrinsic height up through
|
||||
// NavigationSplitView's detail slot and push the whole
|
||||
// window past the screen. widgetsTab's own ScrollView
|
||||
// and siteTab's explicit maxHeight both cooperate; the
|
||||
// sessions tab needs this as well.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = viewModel.dashboardError {
|
||||
ContentUnavailableView {
|
||||
Label("No Dashboard", systemImage: "square.grid.2x2")
|
||||
@@ -394,14 +400,23 @@ struct ProjectsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabs that should appear for the current project. `.site` is
|
||||
/// gated on the dashboard actually containing a webview widget,
|
||||
/// per v2.2 behavior — the Site tab is meaningless without one.
|
||||
private var visibleTabs: [DashboardTab] {
|
||||
DashboardTab.allCases.filter { tab in
|
||||
tab != .site || siteWidget != nil
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||
Image(systemName: tab.systemImage)
|
||||
.font(.caption)
|
||||
Text(tab.displayName)
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Sheet for renaming a project in the registry. Preserves the
|
||||
/// project's `path`, `folder`, and `archived` fields — the rename
|
||||
/// only changes the user-visible name (and therefore the Identifiable
|
||||
/// id). Duplicate-name / empty-name rejection lives in the VM.
|
||||
struct RenameProjectSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let project: ProjectEntry
|
||||
/// Current set of project names in the registry, used to flag
|
||||
/// duplicates before the user tries to Save. Excludes the
|
||||
/// project being renamed so same-name is a no-op (accepted).
|
||||
let existingNames: [String]
|
||||
/// Called with the trimmed new name. Caller is responsible for
|
||||
/// calling `ProjectsViewModel.renameProject(_:to:)`; this sheet
|
||||
/// just gathers input + validates inline.
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var newName: String
|
||||
|
||||
init(
|
||||
project: ProjectEntry,
|
||||
existingNames: [String],
|
||||
onSave: @escaping (String) -> Void
|
||||
) {
|
||||
self.project = project
|
||||
self.existingNames = existingNames
|
||||
self.onSave = onSave
|
||||
_newName = State(initialValue: project.name)
|
||||
}
|
||||
|
||||
/// Validation for the live input. Empty / whitespace-only / a
|
||||
/// collision with another project's name all disable Save.
|
||||
private var validation: (isValid: Bool, message: String?) {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return (false, nil) // no error message — just disabled
|
||||
}
|
||||
if trimmed != project.name && existingNames.contains(trimmed) {
|
||||
return (false, String(localized: "A project named \"\(trimmed)\" already exists."))
|
||||
}
|
||||
return (true, nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rename project").font(.headline)
|
||||
Text("The project directory on disk isn't changed — only the label Scarf shows in the sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
TextField("Project name", text: $newName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
if validation.isValid {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
if let message = validation.message {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Save") { save() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!validation.isValid)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onSave(trimmed)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1028,10 @@
|
||||
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"A project named \"%@\" already exists." : {
|
||||
"comment" : "A warning message that appears in a Rename Project sheet if the user-provided name is a duplicate of an existing project. The argument is the name of the duplicate project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -1391,6 +1395,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add a project" : {
|
||||
"comment" : "A button that adds a new project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2521,6 +2529,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Archived (%lld)" : {
|
||||
"comment" : "A label that opens a group of archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Args (one per line)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3745,6 +3757,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chat · %@" : {
|
||||
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat is scoped to Scarf project \"%@\"" : {
|
||||
"comment" : "Tooltip for the folder-chip indicator.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat Messages" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3785,6 +3805,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
|
||||
"comment" : "A description of the purpose of the Sessions tab.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Check" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -6874,6 +6898,10 @@
|
||||
},
|
||||
"Description" : {
|
||||
|
||||
},
|
||||
"Destination" : {
|
||||
"comment" : "A label for the folder picker in the move-to-folder sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Details" : {
|
||||
"localizations" : {
|
||||
@@ -8802,6 +8830,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Filter projects" : {
|
||||
"comment" : "A label for a search field in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Filter servers..." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9006,6 +9038,10 @@
|
||||
"comment" : "A placeholder for a comma-separated list of tags.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
|
||||
"comment" : "A description of how folders affect project grouping.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Full copy of active profile (all state)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9698,6 +9734,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hide archived projects" : {
|
||||
"comment" : "A toggle that hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hide details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -12186,6 +12226,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Move" : {
|
||||
"comment" : "A button that moves a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Move \"%@\" to folder" : {
|
||||
"comment" : "A heading for a dialog that lets the user move a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Move to Folder…" : {
|
||||
"comment" : "A context menu action that moves a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"msgs" : {
|
||||
"comment" : "A label for the number of messages in a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"my_server" : {
|
||||
|
||||
},
|
||||
@@ -12309,6 +12365,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Chat" : {
|
||||
"comment" : "A button that starts a new chat session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"New folder name" : {
|
||||
|
||||
},
|
||||
"New folder…" : {
|
||||
"comment" : "A label for a new folder name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"New name for '%@'" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -13327,6 +13394,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No project selected" : {
|
||||
"comment" : "A label that indicates that no project is selected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No Projects" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15458,6 +15529,10 @@
|
||||
},
|
||||
"Project folder kept" : {
|
||||
|
||||
},
|
||||
"Project name" : {
|
||||
"comment" : "A label for a text field that lets the user enter a project name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Project Name" : {
|
||||
"localizations" : {
|
||||
@@ -16870,6 +16945,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename project" : {
|
||||
"comment" : "A title for a sheet that renames a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rename Session" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16949,6 +17028,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename…" : {
|
||||
|
||||
},
|
||||
"required" : {
|
||||
|
||||
@@ -19379,6 +19461,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sessions in this project" : {
|
||||
"comment" : "A heading for the list of sessions in a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set as default — open this server when Scarf launches." : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -19623,6 +19709,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show archived projects" : {
|
||||
"comment" : "A toggle that shows/hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21435,6 +21525,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"The project directory on disk isn't changed — only the label Scarf shows in the sidebar." : {
|
||||
"comment" : "A description of the project name field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22422,6 +22516,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Top Level" : {
|
||||
"comment" : "A folder in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Top Tools" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22582,6 +22680,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unarchive" : {
|
||||
"comment" : "A button that unarchives a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Uninstall" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
|
||||
@@ -91,4 +91,15 @@ final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: String?
|
||||
var selectedProjectName: String?
|
||||
|
||||
/// When non-nil, ChatView should start a fresh ACP session with
|
||||
/// this absolute project path as cwd and then clear the value.
|
||||
/// Wired from the per-project Sessions tab's "New Chat" button
|
||||
/// (v2.3): the tab sets this, switches `selectedSection` to
|
||||
/// `.chat`, and ChatView reacts on its next render.
|
||||
///
|
||||
/// Separate from `selectedSessionId` (which resumes an existing
|
||||
/// session) — a new session needs a cwd override Scarf doesn't
|
||||
/// yet have an id for.
|
||||
var pendingProjectChat: String?
|
||||
}
|
||||
|
||||
@@ -86,6 +86,19 @@ struct ScarfApp: App {
|
||||
registry.defaultServerID
|
||||
}
|
||||
.defaultSize(width: 1100, height: 700)
|
||||
// Without an explicit resizability, `WindowGroup` defaults to
|
||||
// `.automatic` which on macOS evaluates to `.contentSize` —
|
||||
// meaning the window is BOUND to its content's ideal size
|
||||
// rather than bounded-below by it. Any section whose content's
|
||||
// intrinsic height changes (Chat's message list, the v2.3
|
||||
// per-project Sessions tab, Insights charts) would resize the
|
||||
// window on every section switch, snap back against user
|
||||
// resize, and sometimes push the whole window past the
|
||||
// screen. `.contentMinSize` turns the content's ideal height
|
||||
// into a minimum floor: user resize works freely, the window
|
||||
// stays put across section switches, and it still can't shrink
|
||||
// smaller than a section's minimum render.
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the Scarf-managed AGENTS.md marker block logic added in
|
||||
/// v2.3. Tests operate on isolated temp directories — no dependency
|
||||
/// on ~/.hermes contents, no cross-suite lock needed.
|
||||
@Suite struct ProjectAgentContextServiceTests {
|
||||
|
||||
// MARK: - applyBlock pure-text transform
|
||||
|
||||
@Test func applyBlockPrependsWhenNoMarkersPresent() {
|
||||
let existing = "# My Template\n\nSome instructions.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
|
||||
#expect(result.contains("<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("# My Template"))
|
||||
#expect(result.contains("Some instructions."))
|
||||
// Exactly one blank line between block and original content.
|
||||
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
|
||||
}
|
||||
|
||||
@Test func applyBlockWritesFreshFileWhenEmpty() {
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: "")
|
||||
// Empty input → just the block + trailing newline; no weird
|
||||
// leading whitespace.
|
||||
#expect(result == block + "\n")
|
||||
}
|
||||
|
||||
@Test func applyBlockReplacesExistingMarkerRegion() {
|
||||
let existing = """
|
||||
<!-- scarf-project:begin -->
|
||||
old content line 1
|
||||
old content line 2
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template docs preserved
|
||||
|
||||
Template behavior.
|
||||
"""
|
||||
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing)
|
||||
|
||||
#expect(result.contains("fresh content"))
|
||||
// Old content is gone.
|
||||
#expect(!result.contains("old content line 1"))
|
||||
#expect(!result.contains("old content line 2"))
|
||||
// Template content outside markers is preserved.
|
||||
#expect(result.contains("# Template docs preserved"))
|
||||
#expect(result.contains("Template behavior."))
|
||||
}
|
||||
|
||||
@Test func applyBlockIsIdempotent() {
|
||||
let existing = "# Project\n\nContent.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
|
||||
let once = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
let twice = ProjectAgentContextService.applyBlock(block: block, to: once)
|
||||
#expect(once == twice)
|
||||
}
|
||||
|
||||
@Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() {
|
||||
// Stray begin with no end: treat as "no well-formed block,"
|
||||
// prepend. Leaves the orphan in place — it was probably
|
||||
// hand-typed, not a corrupt Scarf write. Conservative.
|
||||
let existing = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
|
||||
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("stray text with no end marker"))
|
||||
}
|
||||
|
||||
// MARK: - renderBlock content
|
||||
|
||||
@Test func renderBlockIncludesProjectIdentity() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "My Project", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
#expect(block.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(block.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(block.contains("\"My Project\""))
|
||||
#expect(block.contains(dir))
|
||||
#expect(block.contains("dashboard.json"))
|
||||
}
|
||||
|
||||
@Test func renderBlockOmitsTemplateSectionForBareProject() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Bare", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(!block.contains("**Template:**"))
|
||||
#expect(block.contains("**Configuration fields:** (none)"))
|
||||
}
|
||||
|
||||
@Test func renderBlockIncludesTemplateWhenManifestPresent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Minimal valid v1 manifest — no config schema.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "author/example",
|
||||
"name": "Example",
|
||||
"version": "1.2.3",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true }
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Example", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(block.contains("**Template:** `author/example` v1.2.3"))
|
||||
}
|
||||
|
||||
@Test func renderBlockListsConfigFieldNamesNotValues() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Schema-bearing manifest with one string field and one secret.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "x/y",
|
||||
"name": "Y",
|
||||
"version": "1.0.0",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||
"config": {
|
||||
"schema": [
|
||||
{ "key": "site_url", "type": "string", "label": "Site URL", "required": true },
|
||||
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
// A config.json with a "secret" VALUE — the block must NOT
|
||||
// echo this value. If it does, secrets leak into an agent-
|
||||
// readable file, which is exactly the thing to avoid.
|
||||
let configJSON = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"templateId": "x/y",
|
||||
"values": {
|
||||
"site_url": { "type": "string", "value": "https://example.com" },
|
||||
"api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" }
|
||||
},
|
||||
"updatedAt": "2026-04-24T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Y", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
// Field names present with type hints.
|
||||
#expect(block.contains("`site_url`"))
|
||||
#expect(block.contains("`api_token`"))
|
||||
#expect(block.contains("(secret — name only, value stored in Keychain)"))
|
||||
// CRITICAL: no VALUES appear — not the site URL, not the
|
||||
// keychain ref. The block is safe to drop into an agent
|
||||
// context.
|
||||
#expect(!block.contains("https://example.com"))
|
||||
#expect(!block.contains("keychain://"))
|
||||
#expect(!block.contains("abc123"))
|
||||
}
|
||||
|
||||
// MARK: - refresh end-to-end (temp dir on local filesystem)
|
||||
|
||||
@Test func refreshCreatesAGENTSMdWhenMissing() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Fresh", path: dir)
|
||||
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
#expect(FileManager.default.fileExists(atPath: agentsMd))
|
||||
let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(contents.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(contents.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(contents.contains("\"Fresh\""))
|
||||
}
|
||||
|
||||
@Test func refreshPreservesUserContentBelow() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
let userContent = "# Template\n\nDo the thing.\n"
|
||||
try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Preserved", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(after.contains("# Template"))
|
||||
#expect(after.contains("Do the thing."))
|
||||
// Block goes FIRST; user content follows.
|
||||
let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound
|
||||
let userIdx = after.range(of: "# Template")!.lowerBound
|
||||
#expect(beginIdx < userIdx)
|
||||
}
|
||||
|
||||
@Test func refreshIsFullyIdempotent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Twice", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
try svc.refresh(for: project)
|
||||
let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
try svc.refresh(for: project)
|
||||
let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test func refreshRewritesStaleBlock() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
// Pre-seed a stale Scarf block with a different project name
|
||||
// and a user section below.
|
||||
let seed = """
|
||||
<!-- scarf-project:begin -->
|
||||
Old stale content — project was called "Something Else".
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template
|
||||
"""
|
||||
try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Current Name", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains("\"Current Name\""))
|
||||
#expect(!after.contains("Something Else"))
|
||||
#expect(after.contains("# Template"))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated static func makeTempDir() throws -> String {
|
||||
let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
|
||||
/// Both are optional/defaulted at the decoder so v2.2-era
|
||||
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
|
||||
/// v2.3-written files are forward-compatible with v2.2 readers
|
||||
/// (which ignore unknown keys). These tests lock in both ends of
|
||||
/// that contract.
|
||||
///
|
||||
/// No disk or Hermes dependency — we work entirely with in-memory
|
||||
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
|
||||
/// needed. Safe to run in parallel with every other test suite.
|
||||
@Suite struct ProjectRegistryMigrationTests {
|
||||
|
||||
@Test func decodesV22RegistryWithoutNewFields() throws {
|
||||
// v2.2-era file: just name + path. No folder, no archived.
|
||||
let json = """
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "Legacy", "path": "/Users/x/legacy" },
|
||||
{ "name": "Another", "path": "/Users/x/another" }
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
||||
|
||||
#expect(registry.projects.count == 2)
|
||||
#expect(registry.projects[0].name == "Legacy")
|
||||
#expect(registry.projects[0].path == "/Users/x/legacy")
|
||||
// Defaults hydrate for absent v2.3 fields.
|
||||
#expect(registry.projects[0].folder == nil)
|
||||
#expect(registry.projects[0].archived == false)
|
||||
}
|
||||
|
||||
@Test func decodesV23RegistryWithFolderAndArchived() throws {
|
||||
let json = """
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
|
||||
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
|
||||
{ "name": "Personal", "path": "/Users/x/p" }
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
||||
|
||||
#expect(registry.projects.count == 3)
|
||||
#expect(registry.projects[0].folder == "Clients")
|
||||
#expect(registry.projects[0].archived == false)
|
||||
#expect(registry.projects[1].folder == "Clients")
|
||||
#expect(registry.projects[1].archived == true)
|
||||
#expect(registry.projects[2].folder == nil)
|
||||
#expect(registry.projects[2].archived == false)
|
||||
}
|
||||
|
||||
@Test func encodeOmitsDefaultedFields() throws {
|
||||
// A top-level, non-archived project should encode with ONLY
|
||||
// name + path keys. This keeps v2.3-written registries
|
||||
// loadable by v2.2 Scarf (which ignores unknown keys), and
|
||||
// keeps the file clean for the common case.
|
||||
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(entry)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
|
||||
}
|
||||
|
||||
@Test func encodeIncludesFolderWhenPresent() throws {
|
||||
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(entry)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s.contains(#""folder":"Clients""#))
|
||||
// archived still omitted when false — cleanliness matters.
|
||||
#expect(!s.contains(#""archived""#))
|
||||
}
|
||||
|
||||
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
|
||||
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(archived)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s.contains(#""archived":true"#))
|
||||
|
||||
let active = ProjectEntry(name: "New", path: "/n", archived: false)
|
||||
let data2 = try encoder.encode(active)
|
||||
let s2 = try #require(String(data: data2, encoding: .utf8))
|
||||
#expect(!s2.contains(#""archived""#))
|
||||
}
|
||||
|
||||
@Test func roundTripPreservesAllFields() throws {
|
||||
let original = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: "Top", path: "/t"),
|
||||
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
|
||||
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
|
||||
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
|
||||
])
|
||||
|
||||
let encoded = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
|
||||
|
||||
#expect(decoded.projects.count == 4)
|
||||
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
|
||||
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
|
||||
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
|
||||
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
|
||||
}
|
||||
|
||||
@Test func identityStaysKeyedOnName() throws {
|
||||
// ProjectEntry.id should remain `name`, so selecting by id
|
||||
// across a folder-move or archive-flip still works without
|
||||
// a reselection step.
|
||||
let a = ProjectEntry(name: "Foo", path: "/p")
|
||||
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
|
||||
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
|
||||
#expect(a.id == "Foo")
|
||||
#expect(b.id == "Foo")
|
||||
#expect(c.id == "Foo")
|
||||
#expect(a.id == b.id)
|
||||
#expect(a.id == c.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
|
||||
/// moveProject, renameProject, archiveProject, unarchiveProject,
|
||||
/// + the derived `folders` list. All verbs write through to
|
||||
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
|
||||
/// each test uses TestRegistryLock to snapshot + restore the real
|
||||
/// file. Cross-suite serialization ensures we don't race with other
|
||||
/// registry-touching tests.
|
||||
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
|
||||
|
||||
@Test func moveProjectSetsFolder() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Alpha", path: "/a"),
|
||||
ProjectEntry(name: "Beta", path: "/b")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
#expect(vm.projects.count == 2)
|
||||
|
||||
vm.moveProject(vm.projects[0], toFolder: "Clients")
|
||||
|
||||
#expect(vm.projects.count == 2)
|
||||
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
|
||||
|
||||
// Round-trip: reload from disk and confirm the move persisted.
|
||||
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||
}
|
||||
|
||||
@Test func moveProjectToNilReturnsToTopLevel() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.moveProject(vm.projects[0], toFolder: nil)
|
||||
|
||||
#expect(vm.projects[0].folder == nil)
|
||||
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||
#expect(fresh.projects[0].folder == nil)
|
||||
}
|
||||
|
||||
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.selectProject(vm.projects[0])
|
||||
|
||||
let ok = vm.renameProject(vm.projects[0], to: "NewName")
|
||||
#expect(ok == true)
|
||||
#expect(vm.projects.count == 1)
|
||||
#expect(vm.projects[0].name == "NewName")
|
||||
#expect(vm.projects[0].folder == "Work")
|
||||
#expect(vm.projects[0].archived == false)
|
||||
// Selection follows the rename — the user stays on the same
|
||||
// project they were on.
|
||||
#expect(vm.selectedProject?.name == "NewName")
|
||||
}
|
||||
|
||||
@Test func renameProjectRejectsDuplicateName() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "A", path: "/a"),
|
||||
ProjectEntry(name: "B", path: "/b")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
// Renaming A to B should be refused — B already exists.
|
||||
let ok = vm.renameProject(vm.projects[0], to: "B")
|
||||
#expect(ok == false)
|
||||
// Registry unchanged.
|
||||
#expect(vm.projects.map(\.name) == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func renameProjectRejectsEmptyName() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Foo", path: "/f")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.renameProject(vm.projects[0], to: "") == false)
|
||||
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
|
||||
#expect(vm.projects[0].name == "Foo")
|
||||
}
|
||||
|
||||
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Foo", path: "/f")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
|
||||
// Whitespace around matching name also no-ops.
|
||||
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
|
||||
#expect(vm.projects[0].name == "Foo")
|
||||
}
|
||||
|
||||
@Test func archiveAndUnarchiveProject() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Target", path: "/t")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.selectProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == false)
|
||||
#expect(vm.selectedProject != nil)
|
||||
|
||||
vm.archiveProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == true)
|
||||
// Archiving clears the selection so the dashboard doesn't
|
||||
// linger on a project the sidebar will hide.
|
||||
#expect(vm.selectedProject == nil)
|
||||
|
||||
vm.unarchiveProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == false)
|
||||
// Unarchive doesn't re-select — the user chose to hide it,
|
||||
// surfacing it doesn't mean they want focus back.
|
||||
#expect(vm.selectedProject == nil)
|
||||
}
|
||||
|
||||
@Test func foldersListIsSortedAndDeduped() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "A", path: "/a", folder: "Work"),
|
||||
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
|
||||
ProjectEntry(name: "C", path: "/c", folder: "Work"),
|
||||
ProjectEntry(name: "D", path: "/d"), // top-level
|
||||
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.folders == ["Personal", "Work"])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func seedRegistry(_ registry: ProjectRegistry) throws {
|
||||
try ProjectDashboardService(context: .local).saveRegistry(registry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json`
|
||||
/// via the real `ServerContext.local`. Each test snapshots + restores
|
||||
/// the file through `TestRegistryLock` (reused — the sidecar lives
|
||||
/// in the same scarf/ dir as projects.json, so serialising on one
|
||||
/// lock prevents both cross-suite races).
|
||||
///
|
||||
/// We scope the shared lock to this file's registry helper so tests
|
||||
/// here don't step on the real registry either.
|
||||
@Suite(.serialized) struct SessionAttributionServiceTests {
|
||||
|
||||
@Test func loadOnMissingFileReturnsEmptyMap() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
let map = svc.load()
|
||||
#expect(map.mappings.isEmpty)
|
||||
#expect(svc.projectPath(for: "anything") == nil)
|
||||
#expect(svc.sessionIDs(forProject: "/anything").isEmpty)
|
||||
}
|
||||
|
||||
@Test func attributeWritesMappingAndPersists() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a")
|
||||
|
||||
// Read back via a fresh service instance — confirms the
|
||||
// write actually landed on disk, not just the in-memory map.
|
||||
let fresh = SessionAttributionService(context: .local)
|
||||
#expect(fresh.projectPath(for: "sess-1") == "/proj/a")
|
||||
|
||||
// updatedAt populated on write.
|
||||
let map = fresh.load()
|
||||
let ts = try #require(map.updatedAt)
|
||||
#expect(!ts.isEmpty)
|
||||
}
|
||||
|
||||
@Test func attributeIsIdempotent() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
let firstStamp = svc.load().updatedAt
|
||||
// Call again with the same pair — should short-circuit, NOT
|
||||
// bump updatedAt. We check that the timestamp didn't change
|
||||
// even if the file would have been rewritten.
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
let secondStamp = svc.load().updatedAt
|
||||
#expect(firstStamp == secondStamp)
|
||||
}
|
||||
|
||||
@Test func reattributeChangesMapping() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/a")
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/b")
|
||||
#expect(svc.projectPath(for: "s") == "/b")
|
||||
#expect(svc.sessionIDs(forProject: "/a").isEmpty)
|
||||
#expect(svc.sessionIDs(forProject: "/b") == ["s"])
|
||||
}
|
||||
|
||||
@Test func reverseLookupReturnsAllAttributedSessions() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s1", toProjectPath: "/proj")
|
||||
svc.attribute(sessionID: "s2", toProjectPath: "/proj")
|
||||
svc.attribute(sessionID: "s3", toProjectPath: "/other")
|
||||
|
||||
#expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"])
|
||||
#expect(svc.sessionIDs(forProject: "/other") == ["s3"])
|
||||
#expect(svc.sessionIDs(forProject: "/nobody").isEmpty)
|
||||
}
|
||||
|
||||
@Test func forgetRemovesMapping() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
#expect(svc.projectPath(for: "s") == "/p")
|
||||
|
||||
svc.forget(sessionID: "s")
|
||||
#expect(svc.projectPath(for: "s") == nil)
|
||||
// Forget on a missing session is a no-op, not an error.
|
||||
svc.forget(sessionID: "s")
|
||||
#expect(svc.projectPath(for: "s") == nil)
|
||||
}
|
||||
|
||||
@Test func corruptedFileReturnsEmptyMap() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
// Write garbage to the sidecar path and confirm the service
|
||||
// treats it as "no attributions" rather than crashing. Users
|
||||
// hand-editing the JSON shouldn't soft-brick the Sessions tab.
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: (path as NSString).deletingLastPathComponent,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path))
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
let map = svc.load()
|
||||
#expect(map.mappings.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Snapshot + restore the sidecar file (and delete if missing).
|
||||
/// Uses the shared TestRegistryLock so this suite serialises
|
||||
/// with any other registry-writing suite — both touch scarfDir.
|
||||
static func snapshot() -> (lockToken: Any, data: Data?) {
|
||||
// Re-use the ProjectTemplateTests lock implementation —
|
||||
// same NSLock gates all scarfDir writes across suites.
|
||||
let projectSnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
return (lockToken: projectSnapshot as Any, data: sidecarData)
|
||||
}
|
||||
|
||||
static func restore(_ snapshot: (lockToken: Any, data: Data?)) {
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
if let data = snapshot.data {
|
||||
try? data.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
// Release the shared lock via the existing helper.
|
||||
TestRegistryLock.restore(snapshot.lockToken as? Data)
|
||||
}
|
||||
|
||||
static func deleteSidecar() {
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
@@ -397,6 +397,7 @@ Things to check before declaring the scaffold done:
|
||||
- [ ] `dashboard.json` has `version: 1` at the top.
|
||||
- [ ] `AGENTS.md` documents every config field, every updated widget, and the cron behaviour — the user relies on it as the source of truth when things drift.
|
||||
- [ ] **No raw URLs in field descriptions.** Use `[link text](https://…)` markdown syntax instead — raw URLs read as long unbreakable tokens in the Configuration sheet. Same rule for long paths and other unbreakable strings; wrap in `` ` `` if they must appear verbatim.
|
||||
- [ ] **Leave the `<!-- scarf-project:begin -->` / `<!-- scarf-project:end -->` region alone in the project's `AGENTS.md`.** As of Scarf v2.3, the app auto-injects a project-identity block at chat-start time (project name, directory, template id, configuration field names, cron jobs). Anything you write inside that region will be overwritten on the next chat start. Put template-specific agent instructions BELOW the block so they're preserved across refreshes.
|
||||
|
||||
## Reference — source of truth files
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
"name": "Alan Wizemann",
|
||||
"url": "https://github.com/awizemann"
|
||||
},
|
||||
"bundleSha256": "bebc30551dc92717da96608bbdf448c5d7c47bdb66807037b139a242ef8c3b74",
|
||||
"bundleSize": 14423,
|
||||
"bundleSha256": "56ab97eeb45ab7b9e6715ce9c88ec2c953bf795698cd19628d300d5b8cffd475",
|
||||
"bundleSize": 14610,
|
||||
"category": "developer-tools",
|
||||
"config": null,
|
||||
"contents": {
|
||||
|
||||