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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user