diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c968d..f346185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` component (live via EventSource); `` 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`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 6bc3b1d..df28347 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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))) diff --git a/components/activity-feed/activity-feed.js b/components/activity-feed/activity-feed.js new file mode 100644 index 0000000..69d6d10 --- /dev/null +++ b/components/activity-feed/activity-feed.js @@ -0,0 +1,139 @@ +// — 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 = ` + ${this.#esc(ev.actor)} + ${this.#esc(this.#label(ev.kind))} + ${repo ? `${this.#esc(repo)}` : ''} + ${ev.detail ? `${this.#esc(ev.detail)}` : ''} + ${this.#time(ev.time)}`; + return li; + })); + } + + #renderError(err) { + this.shadowRoot.getElementById('feed').innerHTML = + `
  • Could not load activity: ${this.#esc(err.message)}
  • `; + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + +
    +
    Active project: none
    +

    Activity

    +
    • No activity yet.
    +
    `; + } + + #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); diff --git a/components/activity-feed/activity-feed.md b/components/activity-feed/activity-feed.md new file mode 100644 index 0000000..a0a7445 --- /dev/null +++ b/components/activity-feed/activity-feed.md @@ -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:** `` +- **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. diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js index bbe7661..4bcf272 100644 --- a/components/repo-list/repo-list.js +++ b/components/repo-list/repo-list.js @@ -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() { diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md index 6f8834c..905917d 100644 --- a/components/repo-list/repo-list.md +++ b/components/repo-list/repo-list.md @@ -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 `` 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 `` and + readable by Claude via `get_active_project`. Fire-and-forget. ## Notes / gotchas - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all diff --git a/internal/activity/activity.go b/internal/activity/activity.go new file mode 100644 index 0000000..2c6c43b --- /dev/null +++ b/internal/activity/activity.go @@ -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: + } + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 66abe33..8005364 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -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 } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 17d0794..01a9d6f 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -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. diff --git a/internal/service/service.go b/internal/service/service.go index 4342d80..3e926ae 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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() +} diff --git a/web/templates/help.html b/web/templates/help.html index 8de2d07..bdc11a4 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -53,6 +53,15 @@ most recent commits.

    +

    Active project & activity

    +

    + Clicking a repository also makes it your active project — + 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. +

    +

    Background refresh

    The dashboard refreshes on its own. By default it does not reach diff --git a/web/templates/index.html b/web/templates/index.html index 9137875..1620e8c 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -9,6 +9,7 @@ (AGENT.md §1.1). Each fetches its own data on connect. --> + @@ -39,8 +40,11 @@

    - - +
    + + +
    +