Slice 3: activity feed and active project (SSE + MCP tools)
Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,3 +98,21 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
|
||||
endpoint directly — no public exposure, no HTTPS needed for this path.
|
||||
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
|
||||
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
|
||||
|
||||
## 2026-09-20 — Slice 3: activity feed + active project (§8.2)
|
||||
- **What:** Added `internal/activity` (thread-safe active project + bounded event
|
||||
feed with subscriber fan-out, mirrored to logs, no datastore). Service gained
|
||||
`ActiveProject`/`SetActiveProject`/`RecordActivity`/`Activity`/`SubscribeActivity`
|
||||
(and `service.New` now takes the feed). New MCP tools `get_active_project`,
|
||||
`set_active_project`, `get_activity` (object-wrapped outputs). New HTTP:
|
||||
`GET/POST /api/active-project`, `GET /api/activity`, and `GET /events` (SSE).
|
||||
New `<activity-feed>` component (live via EventSource); `<repo-list>` now sets
|
||||
the active project on selection (a user action). Extended the MCP test to cover
|
||||
the new tools; help page documents the feature.
|
||||
- **Why:** The coordination foundation for the graceful project handoff (§8.3):
|
||||
the app and Claude share one active-project + activity view. User actions are
|
||||
recorded as `actor:user`, Claude's as `actor:claude`, so each side can see what
|
||||
the other did.
|
||||
- **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`.
|
||||
|
||||
+65
-2
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/config"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/logging"
|
||||
@@ -51,8 +53,11 @@ func main() {
|
||||
go scanner.Run(scanCtx)
|
||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
||||
|
||||
// Coordination state: active project + activity feed (§8.2).
|
||||
feed := activity.New(log, 200)
|
||||
|
||||
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
||||
svc := service.New(g, scanner.Index)
|
||||
svc := service.New(g, scanner.Index, feed)
|
||||
|
||||
tmpl, err := render.New("web/templates")
|
||||
if err != nil {
|
||||
@@ -95,7 +100,65 @@ func main() {
|
||||
return c.JSON(http.StatusOK, detail)
|
||||
})
|
||||
|
||||
// MCP server — Claude connects here as a custom connector (§8.1). Same
|
||||
// Active project + activity (§8.2).
|
||||
e.GET("/api/active-project", func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
||||
})
|
||||
e.POST("/api/active-project", func(c echo.Context) error {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
||||
}
|
||||
// A user action in the UI (actor=user) — distinct from Claude's own switches.
|
||||
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
||||
})
|
||||
e.GET("/api/activity", func(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, svc.Activity(0))
|
||||
})
|
||||
|
||||
// SSE stream of activity events for live UI (§8.2).
|
||||
e.GET("/events", func(c echo.Context) error {
|
||||
w := c.Response()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Flush()
|
||||
|
||||
ch, unsub := svc.SubscribeActivity()
|
||||
defer unsub()
|
||||
|
||||
keepalive := time.NewTicker(25 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request().Context().Done():
|
||||
return nil
|
||||
case <-keepalive.C:
|
||||
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
||||
return nil
|
||||
}
|
||||
w.Flush()
|
||||
case ev := <-ch:
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
|
||||
return nil
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// MCP server — Claude connects via the local stdio bridge (§8.1). Same
|
||||
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
|
||||
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
||||
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// <activity-feed> — shows the active project and a live feed of what happened
|
||||
// (user AND Claude actions). A self-contained control (AGENT.md §1.1): shadow
|
||||
// DOM, fetches its own initial data, subscribes to the /events SSE stream, and
|
||||
// cleans up on disconnect. It reflects the coordination state that Claude reads
|
||||
// over MCP (§8.2), so the user can see the two staying in sync.
|
||||
|
||||
class ActivityFeed extends HTMLElement {
|
||||
#es = null;
|
||||
#controller = null;
|
||||
#events = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#renderShell();
|
||||
this.#loadInitial();
|
||||
// Live updates. EventSource auto-reconnects if the stream drops.
|
||||
this.#es = new EventSource('/events');
|
||||
this.#es.addEventListener('activity', (e) => {
|
||||
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore malformed */ }
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#es?.close();
|
||||
this.#controller?.abort();
|
||||
}
|
||||
|
||||
async #loadInitial() {
|
||||
this.#controller?.abort();
|
||||
this.#controller = new AbortController();
|
||||
try {
|
||||
const [apRes, actRes] = await Promise.all([
|
||||
fetch('/api/active-project', { signal: this.#controller.signal }),
|
||||
fetch('/api/activity', { signal: this.#controller.signal }),
|
||||
]);
|
||||
const ap = await apRes.json();
|
||||
const events = await actRes.json();
|
||||
this.#events = Array.isArray(events) ? events : [];
|
||||
this.#renderActive(ap.path || '');
|
||||
this.#renderFeed();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') this.#renderError(err);
|
||||
}
|
||||
}
|
||||
|
||||
#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 || '');
|
||||
this.#renderFeed();
|
||||
}
|
||||
|
||||
#renderActive(path) {
|
||||
const el = this.shadowRoot.getElementById('active');
|
||||
el.textContent = path ? this.#base(path) : 'none';
|
||||
el.title = path;
|
||||
}
|
||||
|
||||
#renderFeed() {
|
||||
const ul = this.shadowRoot.getElementById('feed');
|
||||
// Newest first.
|
||||
ul.replaceChildren(...[...this.#events].reverse().map((ev) => {
|
||||
const li = document.createElement('li');
|
||||
const repo = ev.repo ? this.#base(ev.repo) : '';
|
||||
li.innerHTML = `
|
||||
<span class="actor ${this.#esc(ev.actor)}">${this.#esc(ev.actor)}</span>
|
||||
<span class="kind">${this.#esc(this.#label(ev.kind))}</span>
|
||||
${repo ? `<span class="repo">${this.#esc(repo)}</span>` : ''}
|
||||
${ev.detail ? `<span class="detail">${this.#esc(ev.detail)}</span>` : ''}
|
||||
<span class="time">${this.#time(ev.time)}</span>`;
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
|
||||
#renderError(err) {
|
||||
this.shadowRoot.getElementById('feed').innerHTML =
|
||||
`<li class="error">Could not load activity: ${this.#esc(err.message)}</li>`;
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
.box {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.active { margin-bottom: 8px; color: var(--color-fg-muted); }
|
||||
.active strong { color: var(--fill-accent); }
|
||||
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
||||
color: var(--color-fg-muted); margin: 8px 0 6px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px;
|
||||
max-height: 220px; overflow-y: auto; }
|
||||
li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
|
||||
.actor { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); text-transform: uppercase; }
|
||||
.actor.user { color: var(--git-ahead); border-color: var(--git-ahead); }
|
||||
.actor.claude { color: var(--color-success); border-color: var(--color-success); }
|
||||
.actor.system { color: var(--color-fg-muted); }
|
||||
.repo { font-weight: 600; }
|
||||
.detail { color: var(--color-fg-muted); }
|
||||
.time { margin-left: auto; color: var(--color-fg-muted); font-size: 11px; white-space: nowrap; }
|
||||
.error { color: var(--color-danger); }
|
||||
.empty { color: var(--color-fg-muted); }
|
||||
</style>
|
||||
<div class="box">
|
||||
<div class="active">Active project: <strong id="active">none</strong></div>
|
||||
<h3>Activity</h3>
|
||||
<ul id="feed"><li class="empty">No activity yet.</li></ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
#base(p) {
|
||||
return String(p).split(/[/\\]/).filter(Boolean).pop() || p;
|
||||
}
|
||||
|
||||
#label(kind) {
|
||||
return String(kind || '').replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
#time(t) {
|
||||
const d = new Date(t);
|
||||
return isNaN(d) ? '' : d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
#esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('activity-feed', ActivityFeed);
|
||||
@@ -0,0 +1,28 @@
|
||||
# activity-feed
|
||||
|
||||
## Intent
|
||||
Shows the **active project** and a **live feed** of what happened — both user and
|
||||
Claude actions. It makes the coordination state visible (AGENT.md §8.2): the same
|
||||
active project and events Claude reads over MCP (`get_active_project`,
|
||||
`get_activity`), so the user can watch the two stay in sync. Groundwork for the
|
||||
graceful project handoff (§8.3).
|
||||
|
||||
## Public surface
|
||||
- **Tag:** `<activity-feed>`
|
||||
- **Attributes/properties:** none.
|
||||
- **Fetches (initial):** `GET /api/active-project`, `GET /api/activity`.
|
||||
- **Subscribes:** `EventSource('/events')` — SSE stream; listens for `activity`
|
||||
events (auto-reconnects if the stream drops). Closed on disconnect.
|
||||
- **Renders:** active project (basename, full path on hover) + newest-first list
|
||||
of events, each with an actor badge (user/claude/system), a humanized kind,
|
||||
the repo, optional detail, and a timestamp.
|
||||
|
||||
## History
|
||||
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
|
||||
|
||||
## Notes / gotchas
|
||||
- All server-derived text is escaped before insertion (repo paths, details,
|
||||
kinds) — treat as untrusted (§1.1).
|
||||
- Uses shared design tokens for colors/radii — no hardcoded hex.
|
||||
- The feed is capped client-side at 200 events to mirror the server ring buffer;
|
||||
it does not paginate history.
|
||||
@@ -47,6 +47,13 @@ class RepoList extends HTMLElement {
|
||||
this.dispatchEvent(new CustomEvent('repo:select', {
|
||||
detail: repo, bubbles: true, composed: true,
|
||||
}));
|
||||
// Selecting a repo makes it the active project (a user action, §8.2). Fire
|
||||
// and forget — the SSE feed reflects the change; failure just skips it.
|
||||
fetch('/api/active-project', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: repo.path }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
#renderShell() {
|
||||
|
||||
@@ -21,6 +21,9 @@ component pattern the rest of the UI follows.
|
||||
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
|
||||
accent border/background (the item that `<repo-detail>` is showing). Caches the
|
||||
last `/api/repos` payload so re-selecting re-renders without a refetch.
|
||||
- 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it
|
||||
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
|
||||
readable by Claude via `get_active_project`. Fire-and-forget.
|
||||
|
||||
## Notes / gotchas
|
||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package activity holds the app's coordination state: the single active project
|
||||
// (the repo/task currently in focus) and a bounded feed of what happened — user
|
||||
// AND Claude actions. Both are in-memory (mirrored to the logs, no datastore —
|
||||
// AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new
|
||||
// events also fan out to subscribers for the browser SSE stream (§8.2). This is
|
||||
// the foundation the graceful project handoff (§8.3) builds on.
|
||||
package activity
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Actor is who caused an event.
|
||||
type Actor string
|
||||
|
||||
const (
|
||||
ActorUser Actor = "user"
|
||||
ActorClaude Actor = "claude"
|
||||
ActorSystem Actor = "system"
|
||||
)
|
||||
|
||||
// Event is one entry in the activity feed.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Actor Actor `json:"actor"`
|
||||
Kind string `json:"kind"` // e.g. "active-project-changed"
|
||||
Repo string `json:"repo,omitempty"` // repo path, when relevant
|
||||
Detail string `json:"detail,omitempty"` // human-readable extra context
|
||||
}
|
||||
|
||||
// Feed is the concurrency-safe active-project + activity store with fan-out.
|
||||
type Feed struct {
|
||||
mu sync.RWMutex
|
||||
active string // active project path ("" = none)
|
||||
events []Event
|
||||
maxEvents int
|
||||
nextID int64
|
||||
subs map[chan Event]struct{}
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Feed keeping at most maxEvents recent events.
|
||||
func New(log *slog.Logger, maxEvents int) *Feed {
|
||||
if maxEvents <= 0 {
|
||||
maxEvents = 200
|
||||
}
|
||||
return &Feed{
|
||||
maxEvents: maxEvents,
|
||||
nextID: 1,
|
||||
subs: make(map[chan Event]struct{}),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveProject returns the current active project path ("" if none).
|
||||
func (f *Feed) ActiveProject() string {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
return f.active
|
||||
}
|
||||
|
||||
// SetActiveProject sets the active project and records an event. It is a no-op
|
||||
// (changed=false, zero Event) when path already matches, so repeated sets don't
|
||||
// spam the feed.
|
||||
func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
|
||||
f.mu.Lock()
|
||||
if f.active == path {
|
||||
f.mu.Unlock()
|
||||
return Event{}, false
|
||||
}
|
||||
f.active = path
|
||||
ev, subs := f.appendLocked(actor, "active-project-changed", path, "")
|
||||
f.mu.Unlock()
|
||||
|
||||
publish(subs, ev)
|
||||
return ev, true
|
||||
}
|
||||
|
||||
// Record adds an arbitrary event to the feed.
|
||||
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
|
||||
f.mu.Lock()
|
||||
ev, subs := f.appendLocked(actor, kind, repo, detail)
|
||||
f.mu.Unlock()
|
||||
|
||||
publish(subs, ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
// Events returns up to limit of the most recent events, oldest first. limit<=0
|
||||
// returns all retained events.
|
||||
func (f *Feed) Events(limit int) []Event {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
if limit <= 0 || limit > len(f.events) {
|
||||
limit = len(f.events)
|
||||
}
|
||||
out := make([]Event, limit)
|
||||
copy(out, f.events[len(f.events)-limit:])
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of future events and an unsubscribe func the
|
||||
// caller MUST invoke when done (e.g. via defer) to avoid leaking the channel.
|
||||
func (f *Feed) Subscribe() (<-chan Event, func()) {
|
||||
ch := make(chan Event, 16)
|
||||
f.mu.Lock()
|
||||
f.subs[ch] = struct{}{}
|
||||
f.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
unsub := func() {
|
||||
once.Do(func() {
|
||||
f.mu.Lock()
|
||||
delete(f.subs, ch)
|
||||
f.mu.Unlock()
|
||||
close(ch)
|
||||
})
|
||||
}
|
||||
return ch, unsub
|
||||
}
|
||||
|
||||
// appendLocked assigns id/time, appends (trimming to maxEvents), logs, and
|
||||
// returns the event plus a snapshot of subscriber channels to publish to after
|
||||
// the lock is released. Caller must hold f.mu.
|
||||
func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) {
|
||||
ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail}
|
||||
f.nextID++
|
||||
f.events = append(f.events, ev)
|
||||
if len(f.events) > f.maxEvents {
|
||||
f.events = f.events[len(f.events)-f.maxEvents:]
|
||||
}
|
||||
if f.log != nil {
|
||||
f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail)
|
||||
}
|
||||
subs := make([]chan Event, 0, len(f.subs))
|
||||
for ch := range f.subs {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
return ev, subs
|
||||
}
|
||||
|
||||
// publish does a non-blocking send to each subscriber; a full channel (slow
|
||||
// consumer) drops the event rather than stalling the producer.
|
||||
func publish(subs []chan Event, ev Event) {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
)
|
||||
@@ -28,6 +29,21 @@ type listReposOutput struct {
|
||||
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
|
||||
}
|
||||
|
||||
// setActiveProjectInput is the argument schema for set_active_project.
|
||||
type setActiveProjectInput struct {
|
||||
Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"`
|
||||
}
|
||||
|
||||
// activeProjectOutput reports the active project path (object, per the rule above).
|
||||
type activeProjectOutput struct {
|
||||
Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"`
|
||||
}
|
||||
|
||||
// activityOutput wraps the activity feed (object, per the rule above).
|
||||
type activityOutput struct {
|
||||
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -57,6 +73,33 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||
return nil, detail, nil
|
||||
})
|
||||
|
||||
// get_active_project — the repo/task currently in focus (§8.2).
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "get_active_project",
|
||||
Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
|
||||
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
|
||||
})
|
||||
|
||||
// set_active_project — Claude switches the focus to another repo.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "set_active_project",
|
||||
Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
|
||||
if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil {
|
||||
return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err)
|
||||
}
|
||||
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
|
||||
})
|
||||
|
||||
// get_activity — recent user + Claude actions, so Claude can catch up.
|
||||
mcpsdk.AddTool(s, &mcpsdk.Tool{
|
||||
Name: "get_activity",
|
||||
Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.",
|
||||
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) {
|
||||
return nil, activityOutput{Events: svc.Activity(50)}, nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
@@ -41,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||
scanner.Refresh(context.Background())
|
||||
|
||||
svc := service.New(g, scanner.Index)
|
||||
svc := service.New(g, scanner.Index, activity.New(log, 200))
|
||||
srv := NewServer(svc, "test")
|
||||
|
||||
// Wire an in-memory client<->server session.
|
||||
@@ -100,6 +101,64 @@ func TestMCPRoundTrip(t *testing.T) {
|
||||
if !res.IsError {
|
||||
t.Fatalf("expected IsError for unknown repo, got success")
|
||||
}
|
||||
|
||||
// active project starts empty.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
||||
if err != nil {
|
||||
t.Fatalf("get_active_project: %v", err)
|
||||
}
|
||||
var ap struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
decodeResult(t, res, &ap)
|
||||
if ap.Path != "" {
|
||||
t.Fatalf("expected empty active project, got %q", ap.Path)
|
||||
}
|
||||
|
||||
// set_active_project to our repo, then read it back.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "set_active_project",
|
||||
Arguments: map[string]any{"path": states[0].Path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set_active_project: %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
|
||||
}
|
||||
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
|
||||
decodeResult(t, res, &ap)
|
||||
if ap.Path != states[0].Path {
|
||||
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
|
||||
}
|
||||
|
||||
// setting an unknown project is a tool error.
|
||||
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
|
||||
Name: "set_active_project",
|
||||
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set_active_project(bad) protocol error: %v", err)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("expected IsError for unknown active project")
|
||||
}
|
||||
|
||||
// the activity feed should now contain the active-project-changed event.
|
||||
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
|
||||
var act struct {
|
||||
Events []activity.Event `json:"events"`
|
||||
}
|
||||
decodeResult(t, res, &act)
|
||||
found := false
|
||||
for _, ev := range act.Events {
|
||||
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeResult unmarshals the JSON text content of a tool result into v.
|
||||
|
||||
@@ -7,8 +7,10 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"gitmanager/internal/activity"
|
||||
"gitmanager/internal/git"
|
||||
"gitmanager/internal/repos"
|
||||
)
|
||||
@@ -17,11 +19,13 @@ import (
|
||||
type Service struct {
|
||||
git *git.CLI
|
||||
index *repos.Index
|
||||
feed *activity.Feed
|
||||
}
|
||||
|
||||
// New builds a Service over the git boundary and the scanner's repo index.
|
||||
func New(g *git.CLI, index *repos.Index) *Service {
|
||||
return &Service{git: g, index: index}
|
||||
// New builds a Service over the git boundary, the scanner's repo index, and the
|
||||
// activity feed.
|
||||
func New(g *git.CLI, index *repos.Index, feed *activity.Feed) *Service {
|
||||
return &Service{git: g, index: index, feed: feed}
|
||||
}
|
||||
|
||||
// ListRepos returns a snapshot of every discovered repository.
|
||||
@@ -45,3 +49,40 @@ func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bo
|
||||
}
|
||||
return repos.BuildDetail(ctx, s.git, base), true
|
||||
}
|
||||
|
||||
// --- Activity & active project (§8.2) --------------------------------------
|
||||
|
||||
// ActiveProject returns the current active project path ("" if none).
|
||||
func (s *Service) ActiveProject() string {
|
||||
return s.feed.ActiveProject()
|
||||
}
|
||||
|
||||
// SetActiveProject makes path the active project (or clears it when empty). It
|
||||
// rejects a path that is not an indexed repository — the active project must be
|
||||
// a real repo (§1.3). Returns the recorded event and whether it changed.
|
||||
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
|
||||
if path != "" {
|
||||
path = filepath.Clean(path)
|
||||
if _, ok := s.index.Get(path); !ok {
|
||||
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
|
||||
}
|
||||
}
|
||||
ev, changed := s.feed.SetActiveProject(actor, path)
|
||||
return ev, changed, nil
|
||||
}
|
||||
|
||||
// RecordActivity appends an arbitrary event to the feed.
|
||||
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
|
||||
return s.feed.Record(actor, kind, repo, detail)
|
||||
}
|
||||
|
||||
// Activity returns up to limit recent events, oldest first.
|
||||
func (s *Service) Activity(limit int) []activity.Event {
|
||||
return s.feed.Events(limit)
|
||||
}
|
||||
|
||||
// SubscribeActivity returns a channel of future events plus an unsubscribe func
|
||||
// the caller must invoke when done.
|
||||
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
|
||||
return s.feed.Subscribe()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@
|
||||
most recent commits.
|
||||
</p>
|
||||
|
||||
<h2>Active project & activity</h2>
|
||||
<p>
|
||||
Clicking a repository also makes it your <strong>active project</strong> —
|
||||
the one you're currently focused on — shown in the activity panel at the
|
||||
bottom. That panel also lists recent actions by both you and Claude, updating
|
||||
live. When GitManager is connected to Claude, Claude can see your active
|
||||
project and this activity, so you stay on the same page.
|
||||
</p>
|
||||
|
||||
<h2>Background refresh</h2>
|
||||
<p>
|
||||
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
(AGENT.md §1.1). Each fetches its own data on connect. -->
|
||||
<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>
|
||||
<style>
|
||||
header {
|
||||
display: flex;
|
||||
@@ -22,14 +23,14 @@
|
||||
header nav { margin-left: auto; }
|
||||
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
|
||||
A real dockable layout is layered on later; this is the static default. */
|
||||
main {
|
||||
main { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -39,8 +40,11 @@
|
||||
<nav><a href="/help">Help</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<repo-list></repo-list>
|
||||
<repo-detail></repo-detail>
|
||||
<div class="cols">
|
||||
<repo-list></repo-list>
|
||||
<repo-detail></repo-detail>
|
||||
</div>
|
||||
<activity-feed></activity-feed>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user