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:
2026-09-20 08:16:42 -04:00
parent d3abd4416e
commit 65daedc902
12 changed files with 579 additions and 11 deletions
+18
View File
@@ -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
View File
@@ -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)))
+139
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('activity-feed', ActivityFeed);
+28
View File
@@ -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.
+7
View File
@@ -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() {
+3
View File
@@ -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
+154
View File
@@ -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:
}
}
}
+43
View File
@@ -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
}
+60 -1
View File
@@ -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.
+44 -3
View File
@@ -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()
}
+9
View File
@@ -53,6 +53,15 @@
most recent commits.
</p>
<h2>Active project &amp; 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 -5
View File
@@ -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>