From 2b77e15b36e25a512578f816b322acbd8d29ba57 Mon Sep 17 00:00:00 2001
From: Thomas Nilles
Date: Sun, 20 Sep 2026 08:27:22 -0400
Subject: [PATCH] Slice 4: graceful project handoff (pending switch + ack)
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE.
Co-Authored-By: Claude Opus 4.8
---
AGENT.md | 18 +--
CHANGELOG.md | 22 +++
cmd/server/main.go | 29 ++++
components/activity-feed/activity-feed.js | 4 +-
components/activity-feed/activity-feed.md | 2 +
components/handoff-bar/handoff-bar.js | 155 ++++++++++++++++++++++
components/handoff-bar/handoff-bar.md | 28 ++++
internal/activity/activity.go | 72 +++++++++-
internal/mcp/mcp.go | 44 ++++++
internal/mcp/mcp_test.go | 42 ++++++
internal/service/service.go | 29 ++++
web/templates/help.html | 12 ++
web/templates/index.html | 2 +
13 files changed, 449 insertions(+), 10 deletions(-)
create mode 100644 components/handoff-bar/handoff-bar.js
create mode 100644 components/handoff-bar/handoff-bar.md
diff --git a/AGENT.md b/AGENT.md
index 457e2d4..7166133 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -395,18 +395,20 @@ obeys the safety rules (§1.4).
When the user switches project/task in the app, it is a **request**, not an
instant yank. The cooperative protocol:
-1. **User** picks a new project/task in the app → the app records a
- **pending‑switch request** (target + optional note) and the UI shows
- "waiting for Claude to reach a good stopping point."
+1. **User** asks (in the app) for Claude to switch to a project →
+ `POST /api/switch` records a **pending‑switch request** (target + optional
+ note) and `` shows "waiting for Claude to reach a good stopping
+ point." (The request is a **user‑only** action — there is no MCP tool to raise
+ it; Claude fulfils requests, it doesn't create them.)
2. **Claude** sees the pending request (it checks at its natural turn‑boundary
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
point and preserves work** — never abandons uncommitted changes to switch;
it completes the in‑flight step and commits/stashes as appropriate — then
- performs the switch (`set_active_project`, moving its working context to the
- new repo) and calls **`ack_switch`** with a short summary of where it left
- the previous project.
-3. **App** marks the request fulfilled and **notifies the user** over SSE
- ("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds.
+ calls **`ack_switch`** with a short summary of where it left the previous
+ project. `ack_switch` **atomically** makes the requested target the active
+ project and clears the request.
+3. **App** records `switch-completed` and **notifies the user** over SSE
+ ("Claude switched to *ProjectB* — *left at: …*"). The user proceeds.
**Rule:** the switch is Claude‑completed at a checkpoint, not app‑forced. Losing
or interrupting uncommitted work to satisfy a switch is a §1.4‑class violation.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f346185..6010329 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -116,3 +116,25 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
- **Affects:** `internal/activity` (new), `internal/service`, `internal/mcp`
(+test), `cmd/server/main.go`, `components/activity-feed` (new),
`components/repo-list`, `web/templates/{index,help}.html`.
+
+## 2026-09-20 — Slice 4: graceful project handoff (§8.3)
+- **What:** `internal/activity` gained a pending-switch model
+ (`RequestSwitch`/`PendingSwitch`/`AckSwitch`/`CancelSwitch`); `AckSwitch`
+ atomically sets the active project to the requested target and records a
+ `switch-completed` event with Claude's summary. Service methods added. New MCP
+ tools `get_pending_switch` and `ack_switch` (request is user-only — no MCP tool
+ raises it). HTTP: `GET/POST/DELETE /api/switch`. New `` component:
+ "Ask Claude to switch to ", the "waiting for a good stopping point"
+ state with Cancel, and the completion notice; `` also updates the
+ active project on `switch-completed`. Extended the MCP test to cover the full
+ request→ack→clear flow. Help page + AGENT.md §8.3 updated.
+- **Why:** The headline feature — the user asks Claude to switch projects; Claude
+ finishes to a safe stopping point, then `ack_switch` completes it and the app
+ notifies the user over SSE. The switch is Claude-completed at a checkpoint,
+ never app-forced (§1.4-class rule).
+- **Affects:** `internal/activity`, `internal/service`, `internal/mcp` (+test),
+ `cmd/server/main.go`, `components/handoff-bar` (new), `components/activity-feed`,
+ `web/templates/{index,help}.html`, `AGENT.md` (§8.3).
+- **Verified live:** request → "waiting" → cancel, all over SSE with activity
+ logging. The ack/completion path is covered by the test; its live ✅ notice
+ needs the two new MCP tools, which appear after the next Claude Desktop restart.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index df28347..6091d6d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -121,6 +121,35 @@ func main() {
return c.JSON(http.StatusOK, svc.Activity(0))
})
+ // Graceful handoff (§8.3): the user requests a switch; Claude completes it.
+ e.GET("/api/switch", func(c echo.Context) error {
+ p, ok := svc.PendingSwitch()
+ if !ok {
+ return c.JSON(http.StatusOK, map[string]any{"pending": false})
+ }
+ return c.JSON(http.StatusOK, map[string]any{
+ "pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
+ })
+ })
+ e.POST("/api/switch", func(c echo.Context) error {
+ var body struct {
+ Target string `json:"target"`
+ Note string `json:"note"`
+ }
+ if err := c.Bind(&body); err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
+ }
+ p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
+ }
+ return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
+ })
+ e.DELETE("/api/switch", func(c echo.Context) error {
+ svc.CancelSwitch(activity.ActorUser)
+ return c.JSON(http.StatusOK, map[string]any{"pending": false})
+ })
+
// SSE stream of activity events for live UI (§8.2).
e.GET("/events", func(c echo.Context) error {
w := c.Response()
diff --git a/components/activity-feed/activity-feed.js b/components/activity-feed/activity-feed.js
index 69d6d10..7d1e75b 100644
--- a/components/activity-feed/activity-feed.js
+++ b/components/activity-feed/activity-feed.js
@@ -50,7 +50,9 @@ class ActivityFeed extends HTMLElement {
#onEvent(ev) {
this.#events.push(ev);
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
- if (ev.kind === 'active-project-changed') this.#renderActive(ev.repo || '');
+ if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
+ this.#renderActive(ev.repo || '');
+ }
this.#renderFeed();
}
diff --git a/components/activity-feed/activity-feed.md b/components/activity-feed/activity-feed.md
index a0a7445..58b7f5b 100644
--- a/components/activity-feed/activity-feed.md
+++ b/components/activity-feed/activity-feed.md
@@ -19,6 +19,8 @@ graceful project handoff (§8.3).
## History
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
+- 2026-09-20: also update the active-project display on `switch-completed` events
+ (slice 4 handoff), not only `active-project-changed`.
## Notes / gotchas
- All server-derived text is escaped before insertion (repo paths, details,
diff --git a/components/handoff-bar/handoff-bar.js b/components/handoff-bar/handoff-bar.js
new file mode 100644
index 0000000..5cb2975
--- /dev/null
+++ b/components/handoff-bar/handoff-bar.js
@@ -0,0 +1,155 @@
+// — the graceful project handoff control (AGENT.md §8.3).
+//
+// A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE,
+// cleans up on disconnect. It lets the user ask Claude to switch to the active
+// project, shows the "waiting for a good stopping point" state while a request
+// is pending, and announces the completion (with Claude's summary of where it
+// left off) when Claude calls ack_switch.
+
+class HandoffBar extends HTMLElement {
+ #es = null;
+ #controller = null;
+ #active = '';
+ #pending = null;
+ #completed = null;
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.#renderShell();
+ this.#loadInitial();
+ this.#es = new EventSource('/events');
+ this.#es.addEventListener('activity', (e) => {
+ try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
+ });
+ }
+
+ disconnectedCallback() {
+ this.#es?.close();
+ this.#controller?.abort();
+ }
+
+ async #loadInitial() {
+ this.#controller?.abort();
+ this.#controller = new AbortController();
+ try {
+ const [swRes, apRes] = await Promise.all([
+ fetch('/api/switch', { signal: this.#controller.signal }),
+ fetch('/api/active-project', { signal: this.#controller.signal }),
+ ]);
+ const sw = await swRes.json();
+ const ap = await apRes.json();
+ this.#pending = sw.pending ? sw : null;
+ this.#active = ap.path || '';
+ this.#render();
+ } catch (err) {
+ if (err.name !== 'AbortError') { /* keep default UI */ }
+ }
+ }
+
+ #onEvent(ev) {
+ switch (ev.kind) {
+ case 'switch-requested':
+ this.#pending = { target: ev.repo, note: ev.detail };
+ this.#completed = null;
+ break;
+ case 'switch-completed':
+ this.#pending = null;
+ this.#active = ev.repo || this.#active;
+ this.#completed = { target: ev.repo, summary: ev.detail };
+ break;
+ case 'switch-cancelled':
+ this.#pending = null;
+ break;
+ case 'active-project-changed':
+ this.#active = ev.repo || '';
+ break;
+ default:
+ return;
+ }
+ this.#render();
+ }
+
+ async #request() {
+ if (!this.#active) return;
+ await fetch('/api/switch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ target: this.#active }),
+ }).catch(() => {});
+ // The SSE switch-requested event updates the UI.
+ }
+
+ async #cancel() {
+ await fetch('/api/switch', { method: 'DELETE' }).catch(() => {});
+ }
+
+ #render() {
+ const body = this.shadowRoot.getElementById('body');
+ if (this.#pending) {
+ body.innerHTML = `
+ ⏳
+ Waiting for Claude to reach a good stopping point to switch to
+ ${this.#esc(this.#base(this.#pending.target))}…
+ `;
+ this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
+ return;
+ }
+
+ const done = this.#completed
+ ? `✅ Claude switched to
+ ${this.#esc(this.#base(this.#completed.target))}${
+ this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
+ }`
+ : '';
+
+ if (!this.#active) {
+ body.innerHTML = `${done}Select a repository to make it the active project, then hand it off to Claude.`;
+ return;
+ }
+
+ body.innerHTML = `
+ ${done}
+ `;
+ this.shadowRoot.getElementById('ask').onclick = () => this.#request();
+ }
+
+ #renderShell() {
+ this.shadowRoot.innerHTML = `
+
+ `;
+ }
+
+ #base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
+
+ #esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, (c) => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
+ ));
+ }
+}
+
+customElements.define('handoff-bar', HandoffBar);
diff --git a/components/handoff-bar/handoff-bar.md b/components/handoff-bar/handoff-bar.md
new file mode 100644
index 0000000..5a19886
--- /dev/null
+++ b/components/handoff-bar/handoff-bar.md
@@ -0,0 +1,28 @@
+# handoff-bar
+
+## Intent
+The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It
+lets the user ask Claude to switch to the active project, shows the "waiting for a
+good stopping point" state while the request is pending, and announces completion
+(with Claude's summary of where it left off) when Claude calls `ack_switch`. The
+switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule).
+
+## Public surface
+- **Tag:** ``
+- **Attributes/properties:** none.
+- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`.
+- **Writes:** `POST /api/switch {target}` to request a handoff to the active
+ project (actor=user); `DELETE /api/switch` to cancel.
+- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`,
+ `switch-completed` (shows Claude's summary), `switch-cancelled`, and
+ `active-project-changed`. Closed on disconnect.
+
+## History
+- 2026-09-20: created — slice 4; request/pending/completed UI over the
+ `/api/switch` endpoints and SSE.
+
+## Notes / gotchas
+- All server-derived text is escaped before insertion (targets, summaries).
+- The request targets the current **active project**; select a repo first (that
+ sets the active project via ``).
+- The completion message persists until the next request; it is informational.
diff --git a/internal/activity/activity.go b/internal/activity/activity.go
index 2c6c43b..f49f1c8 100644
--- a/internal/activity/activity.go
+++ b/internal/activity/activity.go
@@ -31,10 +31,21 @@ type Event struct {
Detail string `json:"detail,omitempty"` // human-readable extra context
}
+// PendingSwitch is a user's request for Claude to switch to another project.
+// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
+// safe stopping point via AckSwitch.
+type PendingSwitch struct {
+ Target string `json:"target"` // requested repo path
+ Note string `json:"note,omitempty"`
+ RequestedBy Actor `json:"requestedBy"` // normally the user
+ RequestedAt time.Time `json:"requestedAt"`
+}
+
// Feed is the concurrency-safe active-project + activity store with fan-out.
type Feed struct {
mu sync.RWMutex
- active string // active project path ("" = none)
+ active string // active project path ("" = none)
+ pending *PendingSwitch // an outstanding switch request, if any
events []Event
maxEvents int
nextID int64
@@ -79,6 +90,65 @@ func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
return ev, true
}
+// RequestSwitch records a user's request for Claude to switch to target. The
+// latest request wins (it overwrites any outstanding one).
+func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
+ f.mu.Lock()
+ p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
+ f.pending = &p
+ ev, subs := f.appendLocked(actor, "switch-requested", target, note)
+ f.mu.Unlock()
+
+ publish(subs, ev)
+ return p
+}
+
+// PendingSwitch returns the outstanding switch request, if any.
+func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
+ f.mu.RLock()
+ defer f.mu.RUnlock()
+ if f.pending == nil {
+ return PendingSwitch{}, false
+ }
+ return *f.pending, true
+}
+
+// AckSwitch completes a pending switch: it makes the requested target the active
+// project, clears the request, and records a "switch-completed" event carrying
+// Claude's summary of where it left the previous project. Returns false if there
+// was nothing pending.
+func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
+ f.mu.Lock()
+ if f.pending == nil {
+ f.mu.Unlock()
+ return PendingSwitch{}, false
+ }
+ p := *f.pending
+ f.pending = nil
+ f.active = p.Target
+ ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
+ f.mu.Unlock()
+
+ publish(subs, ev)
+ return p, true
+}
+
+// CancelSwitch clears a pending switch (e.g. the user changed their mind).
+func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
+ f.mu.Lock()
+ if f.pending == nil {
+ f.mu.Unlock()
+ return PendingSwitch{}, false
+ }
+ p := *f.pending
+ f.pending = nil
+ ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
+ f.mu.Unlock()
+
+ publish(subs, ev)
+ return p, true
+}
+
// Record adds an arbitrary event to the feed.
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
f.mu.Lock()
diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go
index 8005364..62f290b 100644
--- a/internal/mcp/mcp.go
+++ b/internal/mcp/mcp.go
@@ -9,6 +9,7 @@ import (
"context"
"fmt"
"net/http"
+ "time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -44,6 +45,25 @@ type activityOutput struct {
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
}
+// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
+type pendingSwitchOutput struct {
+ Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
+ Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
+ Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
+ RequestedAt time.Time `json:"requestedAt,omitempty"`
+}
+
+// ackSwitchInput is the argument schema for ack_switch.
+type ackSwitchInput struct {
+ Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
+}
+
+// ackSwitchOutput reports the completed switch.
+type ackSwitchOutput struct {
+ Switched bool `json:"switched"`
+ Target string `json:"target,omitempty" jsonschema:"the repository now active"`
+}
+
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -100,6 +120,30 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, activityOutput{Events: svc.Activity(50)}, nil
})
+ // get_pending_switch — has the user asked you to switch projects? (§8.3)
+ mcpsdk.AddTool(s, &mcpsdk.Tool{
+ Name: "get_pending_switch",
+ Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
+ }, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
+ p, ok := svc.PendingSwitch()
+ if !ok {
+ return nil, pendingSwitchOutput{Pending: false}, nil
+ }
+ return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
+ })
+
+ // ack_switch — complete a pending handoff and tell the user where you left off.
+ mcpsdk.AddTool(s, &mcpsdk.Tool{
+ Name: "ack_switch",
+ Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
+ }, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
+ p, ok := svc.AckSwitch(in.Summary)
+ if !ok {
+ return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
+ }
+ return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
+ })
+
return s
}
diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
index 01a9d6f..45f40cf 100644
--- a/internal/mcp/mcp_test.go
+++ b/internal/mcp/mcp_test.go
@@ -159,6 +159,48 @@ func TestMCPRoundTrip(t *testing.T) {
if !found {
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
}
+
+ // --- graceful handoff (§8.3): user requests, Claude acks -----------------
+ svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
+
+ res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
+ var ps struct {
+ Pending bool `json:"pending"`
+ Target string `json:"target"`
+ }
+ decodeResult(t, res, &ps)
+ if !ps.Pending || ps.Target != states[0].Path {
+ t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
+ }
+
+ res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "ack_switch",
+ Arguments: map[string]any{"summary": "left tests green"},
+ })
+ if err != nil {
+ t.Fatalf("ack_switch: %v", err)
+ }
+ if res.IsError {
+ t.Fatalf("ack_switch tool error: %+v", res.Content)
+ }
+
+ res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
+ decodeResult(t, res, &ps)
+ if ps.Pending {
+ t.Fatalf("expected no pending switch after ack, got %+v", ps)
+ }
+
+ // ack with nothing pending is a tool error.
+ res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
+ Name: "ack_switch",
+ Arguments: map[string]any{"summary": "nothing"},
+ })
+ if err != nil {
+ t.Fatalf("ack_switch(empty) protocol error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatalf("expected IsError acking with no pending switch")
+ }
}
// decodeResult unmarshals the JSON text content of a tool result into v.
diff --git a/internal/service/service.go b/internal/service/service.go
index 3e926ae..ffca5f4 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -86,3 +86,32 @@ func (s *Service) Activity(limit int) []activity.Event {
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
return s.feed.Subscribe()
}
+
+// --- Graceful project handoff (§8.3) ---------------------------------------
+
+// RequestSwitch records a user's request for Claude to switch to target. The
+// target must be an indexed repository.
+func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
+ target = filepath.Clean(target)
+ if _, ok := s.index.Get(target); !ok {
+ return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
+ }
+ return s.feed.RequestSwitch(actor, target, note), nil
+}
+
+// PendingSwitch returns the outstanding switch request, if any.
+func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
+ return s.feed.PendingSwitch()
+}
+
+// AckSwitch completes the pending handoff on Claude's behalf: sets the active
+// project to the requested target and records Claude's summary. Returns false if
+// nothing was pending.
+func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
+ return s.feed.AckSwitch(activity.ActorClaude, summary)
+}
+
+// CancelSwitch clears a pending switch request.
+func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
+ return s.feed.CancelSwitch(actor)
+}
diff --git a/web/templates/help.html b/web/templates/help.html
index bdc11a4..f0c2026 100644
--- a/web/templates/help.html
+++ b/web/templates/help.html
@@ -62,6 +62,18 @@
project and this activity, so you stay on the same page.
+
Handing a project off to Claude
+
+ When GitManager is connected to Claude, you can hand the current project
+ off. Select the repository (that makes it your active project), then click
+ "Ask Claude to switch to …" in the handoff bar. Claude
+ won't drop what it's doing — it finishes to a safe stopping point (saving
+ any in-progress work) and then switches. You'll see a "waiting…" message
+ while it wraps up, and a confirmation with a short note of where it left the
+ previous project once it has switched. You can Cancel a
+ pending request any time before Claude completes it.
+
+
Background refresh
The dashboard refreshes on its own. By default it does not reach
diff --git a/web/templates/index.html b/web/templates/index.html
index 1620e8c..c2e5699 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -10,6 +10,7 @@
+