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 <handoff-bar> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<handoff-bar>` 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.
|
||||
|
||||
@@ -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 `<handoff-bar>` component:
|
||||
"Ask Claude to switch to <active>", the "waiting for a good stopping point"
|
||||
state with Cancel, and the completion notice; `<activity-feed>` 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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// <handoff-bar> — 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 = `
|
||||
<span class="spinner">⏳</span>
|
||||
<span>Waiting for Claude to reach a good stopping point to switch to
|
||||
<strong>${this.#esc(this.#base(this.#pending.target))}</strong>…</span>
|
||||
<button id="cancel" class="ghost">Cancel</button>`;
|
||||
this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const done = this.#completed
|
||||
? `<span class="done">✅ Claude switched to
|
||||
<strong>${this.#esc(this.#base(this.#completed.target))}</strong>${
|
||||
this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
|
||||
}</span>`
|
||||
: '';
|
||||
|
||||
if (!this.#active) {
|
||||
body.innerHTML = `${done}<span class="hint">Select a repository to make it the active project, then hand it off to Claude.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
${done}
|
||||
<button id="ask" class="primary">Ask Claude to switch to ${this.#esc(this.#base(this.#active))}</button>`;
|
||||
this.shadowRoot.getElementById('ask').onclick = () => this.#request();
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
#body {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
button { font: inherit; border-radius: var(--radius-sm); cursor: pointer;
|
||||
padding: 5px 12px; border: 1px solid var(--border-strong);
|
||||
background: var(--surface-2); color: var(--color-fg); }
|
||||
button.primary { border-color: var(--fill-accent); color: var(--fill-accent); }
|
||||
button.ghost { color: var(--color-fg-muted); }
|
||||
button:hover { border-color: var(--fill-accent); }
|
||||
.spinner { font-size: 15px; }
|
||||
.done { color: var(--color-success); }
|
||||
.hint { color: var(--color-fg-muted); }
|
||||
strong { color: var(--fill-accent); }
|
||||
#cancel { margin-left: auto; }
|
||||
</style>
|
||||
<div id="body"></div>`;
|
||||
}
|
||||
|
||||
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
|
||||
|
||||
#esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('handoff-bar', HandoffBar);
|
||||
@@ -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:** `<handoff-bar>`
|
||||
- **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 `<repo-list>`).
|
||||
- The completion message persists until the next request; it is informational.
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@
|
||||
project and this activity, so you stay on the same page.
|
||||
</p>
|
||||
|
||||
<h2>Handing a project off to Claude</h2>
|
||||
<p>
|
||||
When GitManager is connected to Claude, you can hand the current project
|
||||
off. Select the repository (that makes it your active project), then click
|
||||
<strong>"Ask Claude to switch to …"</strong> 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 <strong>Cancel</strong> a
|
||||
pending request any time before Claude completes it.
|
||||
</p>
|
||||
|
||||
<h2>Background refresh</h2>
|
||||
<p>
|
||||
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<script type="module" src="/components/repo-list/repo-list.js"></script>
|
||||
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
|
||||
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
|
||||
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
|
||||
<style>
|
||||
header {
|
||||
display: flex;
|
||||
@@ -40,6 +41,7 @@
|
||||
<nav><a href="/help">Help</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<handoff-bar></handoff-bar>
|
||||
<div class="cols">
|
||||
<repo-list></repo-list>
|
||||
<repo-detail></repo-detail>
|
||||
|
||||
Reference in New Issue
Block a user