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.
|
endpoint directly — no public exposure, no HTTPS needed for this path.
|
||||||
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
|
- **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.
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/config"
|
"gitmanager/internal/config"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/logging"
|
"gitmanager/internal/logging"
|
||||||
@@ -51,8 +53,11 @@ func main() {
|
|||||||
go scanner.Run(scanCtx)
|
go scanner.Run(scanCtx)
|
||||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
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).
|
// 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")
|
tmpl, err := render.New("web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -95,7 +100,65 @@ func main() {
|
|||||||
return c.JSON(http.StatusOK, detail)
|
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.
|
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
|
||||||
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
|
||||||
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
|
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', {
|
this.dispatchEvent(new CustomEvent('repo:select', {
|
||||||
detail: repo, bubbles: true, composed: true,
|
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() {
|
#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
|
- 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
|
accent border/background (the item that `<repo-detail>` is showing). Caches the
|
||||||
last `/api/repos` payload so re-selecting re-renders without a refetch.
|
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
|
## Notes / gotchas
|
||||||
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
|
- 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"
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
)
|
)
|
||||||
@@ -28,6 +29,21 @@ type listReposOutput struct {
|
|||||||
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
|
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.
|
// NewServer builds the MCP server and registers the (currently read-only) tools.
|
||||||
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
||||||
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
s := mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||||
@@ -57,6 +73,33 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
|
|||||||
return nil, detail, nil
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"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 := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
|
||||||
scanner.Refresh(context.Background())
|
scanner.Refresh(context.Background())
|
||||||
|
|
||||||
svc := service.New(g, scanner.Index)
|
svc := service.New(g, scanner.Index, activity.New(log, 200))
|
||||||
srv := NewServer(svc, "test")
|
srv := NewServer(svc, "test")
|
||||||
|
|
||||||
// Wire an in-memory client<->server session.
|
// Wire an in-memory client<->server session.
|
||||||
@@ -100,6 +101,64 @@ func TestMCPRoundTrip(t *testing.T) {
|
|||||||
if !res.IsError {
|
if !res.IsError {
|
||||||
t.Fatalf("expected IsError for unknown repo, got success")
|
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.
|
// decodeResult unmarshals the JSON text content of a tool result into v.
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
)
|
)
|
||||||
@@ -17,11 +19,13 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
git *git.CLI
|
git *git.CLI
|
||||||
index *repos.Index
|
index *repos.Index
|
||||||
|
feed *activity.Feed
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Service over the git boundary and the scanner's repo index.
|
// New builds a Service over the git boundary, the scanner's repo index, and the
|
||||||
func New(g *git.CLI, index *repos.Index) *Service {
|
// activity feed.
|
||||||
return &Service{git: g, index: index}
|
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.
|
// 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
|
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.
|
most recent commits.
|
||||||
</p>
|
</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>
|
<h2>Background refresh</h2>
|
||||||
<p>
|
<p>
|
||||||
The dashboard refreshes on its own. By default it does <em>not</em> reach
|
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. -->
|
(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-list/repo-list.js"></script>
|
||||||
<script type="module" src="/components/repo-detail/repo-detail.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>
|
<style>
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -22,14 +23,14 @@
|
|||||||
header nav { margin-left: auto; }
|
header nav { margin-left: auto; }
|
||||||
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
|
/* 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. */
|
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;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 20px;
|
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) { main { grid-template-columns: 1fr; } }
|
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -39,8 +40,11 @@
|
|||||||
<nav><a href="/help">Help</a></nav>
|
<nav><a href="/help">Help</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
<div class="cols">
|
||||||
<repo-list></repo-list>
|
<repo-list></repo-list>
|
||||||
<repo-detail></repo-detail>
|
<repo-detail></repo-detail>
|
||||||
|
</div>
|
||||||
|
<activity-feed></activity-feed>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user