Merge branch 'feature/miniapp-git-tab'
This commit is contained in:
commit
31fc7a3937
4 changed files with 185 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -231,7 +232,7 @@ func gatewayCmd() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if webAppURL != "" {
|
if webAppURL != "" {
|
||||||
provider := &agentLoopDataProvider{loop: agentLoop}
|
provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
|
||||||
sender := &telegramCommandSender{bus: msgBus}
|
sender := &telegramCommandSender{bus: msgBus}
|
||||||
miniappNotifier = miniapp.NewStateNotifier()
|
miniappNotifier = miniapp.NewStateNotifier()
|
||||||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
|
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
|
||||||
|
|
@ -308,9 +309,15 @@ func setupCronTool(
|
||||||
|
|
||||||
// agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface.
|
// agentLoopDataProvider adapts AgentLoop to the miniapp.DataProvider interface.
|
||||||
type agentLoopDataProvider struct {
|
type agentLoopDataProvider struct {
|
||||||
loop *agent.AgentLoop
|
loop *agent.AgentLoop
|
||||||
|
workspace string
|
||||||
|
|
||||||
|
gitCache miniapp.GitInfo
|
||||||
|
gitCacheAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
|
func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo {
|
||||||
return p.loop.ListSkills()
|
return p.loop.ListSkills()
|
||||||
}
|
}
|
||||||
|
|
@ -368,6 +375,68 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *agentLoopDataProvider) GetGitInfo() miniapp.GitInfo {
|
||||||
|
if time.Since(p.gitCacheAt) < gitCacheTTL {
|
||||||
|
return p.gitCache
|
||||||
|
}
|
||||||
|
|
||||||
|
info := miniapp.GitInfo{}
|
||||||
|
if p.workspace == "" {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect git root from workspace
|
||||||
|
topOut, err := exec.Command("git", "-C", p.workspace, "rev-parse", "--show-toplevel").Output()
|
||||||
|
if err != nil {
|
||||||
|
return info // not a git repo
|
||||||
|
}
|
||||||
|
gitRoot := strings.TrimSpace(string(topOut))
|
||||||
|
|
||||||
|
// Exclude ~/.picoclaw/workspace* (picoclaw's own managed workspace)
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
picoDir := filepath.Join(home, ".picoclaw")
|
||||||
|
if strings.HasPrefix(gitRoot, picoDir) {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current branch
|
||||||
|
out, err := exec.Command("git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||||
|
if err == nil {
|
||||||
|
info.Branch = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent commits (20 entries)
|
||||||
|
out, err = exec.Command("git", "-C", gitRoot, "log", "--pretty=format:%h\x1f%s\x1f%an\x1f%cr", "-20").Output()
|
||||||
|
if err == nil {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
parts := strings.SplitN(line, "\x1f", 4)
|
||||||
|
if len(parts) == 4 {
|
||||||
|
info.Commits = append(info.Commits, miniapp.GitCommit{
|
||||||
|
Hash: parts[0], Subject: parts[1], Author: parts[2], Date: parts[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified/untracked files (git status --porcelain)
|
||||||
|
out, err = exec.Command("git", "-C", gitRoot, "status", "--porcelain").Output()
|
||||||
|
if err == nil && len(out) > 0 {
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||||
|
if len(line) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info.Modified = append(info.Modified, miniapp.GitChange{
|
||||||
|
Status: strings.TrimSpace(line[:2]),
|
||||||
|
Path: line[3:],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.gitCache = info
|
||||||
|
p.gitCacheAt = time.Now()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
// telegramCommandSender injects Mini App commands into the message bus.
|
// telegramCommandSender injects Mini App commands into the message bus.
|
||||||
type telegramCommandSender struct {
|
type telegramCommandSender struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,34 @@ type SessionInfo struct {
|
||||||
AgeSec int `json:"age_sec"`
|
AgeSec int `json:"age_sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitInfo represents the git repository state exposed via the API.
|
||||||
|
type GitInfo struct {
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Commits []GitCommit `json:"commits"`
|
||||||
|
Modified []GitChange `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitCommit represents a single commit entry.
|
||||||
|
type GitCommit struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitChange represents a modified/untracked file entry.
|
||||||
|
type GitChange struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
// DataProvider is the read-only interface to agent state for the Mini App API.
|
// DataProvider is the read-only interface to agent state for the Mini App API.
|
||||||
type DataProvider interface {
|
type DataProvider interface {
|
||||||
ListSkills() []skills.SkillInfo
|
ListSkills() []skills.SkillInfo
|
||||||
GetPlanInfo() PlanInfo
|
GetPlanInfo() PlanInfo
|
||||||
GetSessionStats() *stats.Stats
|
GetSessionStats() *stats.Stats
|
||||||
GetActiveSessions() []SessionInfo
|
GetActiveSessions() []SessionInfo
|
||||||
|
GetGitInfo() GitInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandSender injects a command into the message bus on behalf of a user.
|
// CommandSender injects a command into the message bus on behalf of a user.
|
||||||
|
|
@ -154,6 +176,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||||
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||||
|
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +232,10 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, s)
|
writeJSON(w, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, h.provider.GetGitInfo())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,9 @@ func (m *mockDataProvider) GetSessionStats() *stats.Stats {
|
||||||
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
func (m *mockDataProvider) GetGitInfo() GitInfo {
|
||||||
|
return GitInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
type mockSender struct{}
|
type mockSender struct{}
|
||||||
|
|
||||||
|
|
@ -445,6 +448,9 @@ func (m *mutatingDataProvider) GetSessionStats() *stats.Stats { return nil }
|
||||||
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
func (m *mutatingDataProvider) GetGitInfo() GitInfo {
|
||||||
|
return GitInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
// drainEvents reads SSE event lines until it collects `want` distinct event names or times out.
|
||||||
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
func drainEvents(t *testing.T, scanner *bufio.Scanner, want int, timeout time.Duration) map[string]bool {
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
top: 2px;
|
top: 2px;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
left: 2px;
|
left: 2px;
|
||||||
width: calc(25% - 2px);
|
width: calc(20% - 2px);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--tab-pill-bg);
|
background: var(--tab-pill-bg);
|
||||||
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
|
box-shadow: 0 0.5px 2px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
|
||||||
|
|
@ -564,6 +564,36 @@
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Git log */
|
||||||
|
.git-commit {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.git-commit:last-child { border-bottom: none; }
|
||||||
|
.git-hash {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--btn);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.git-subject { flex: 1; color: var(--text); }
|
||||||
|
.git-meta { color: var(--hint); font-size: 11px; flex-shrink: 0; text-align: right; }
|
||||||
|
.git-status {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.git-status-m { color: #e2b93d; }
|
||||||
|
.git-status-a { color: #4caf50; }
|
||||||
|
.git-status-d { color: #ef5350; }
|
||||||
|
.git-status-u { color: var(--hint); }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -575,6 +605,7 @@
|
||||||
<button class="tab" data-panel="skills">Skills</button>
|
<button class="tab" data-panel="skills">Skills</button>
|
||||||
<button class="tab" data-panel="session">Session</button>
|
<button class="tab" data-panel="session">Session</button>
|
||||||
<button class="tab" data-panel="config">Config</button>
|
<button class="tab" data-panel="config">Config</button>
|
||||||
|
<button class="tab" data-panel="git">Git</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -611,6 +642,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="git" class="panel">
|
||||||
|
<div class="loading" id="git-loading">Loading git log...</div>
|
||||||
|
<div id="git-content" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="send-bar hidden" id="send-bar">
|
<div class="send-bar hidden" id="send-bar">
|
||||||
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
|
<input id="skill-msg" class="send-input glass glass-interactive" placeholder="Message for skill...">
|
||||||
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
|
<button class="send-btn" id="send-skill-btn" onclick="sendSkillCommand()">Send</button>
|
||||||
|
|
@ -649,6 +685,7 @@ tabs.forEach((tab, index) => {
|
||||||
if (p === 'plan' && !fresh) loadPlan();
|
if (p === 'plan' && !fresh) loadPlan();
|
||||||
if (p === 'skills' && !fresh) loadSkills();
|
if (p === 'skills' && !fresh) loadSkills();
|
||||||
if (p === 'session' && !fresh) loadSession();
|
if (p === 'session' && !fresh) loadSession();
|
||||||
|
if (p === 'git') loadGit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1079,6 +1116,49 @@ function escapeAttr(s) {
|
||||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Git tab ──
|
||||||
|
function loadGit() {
|
||||||
|
return loadTab('git-loading', 'git-content', 'git log',
|
||||||
|
function() { return apiFetch('/miniapp/api/git'); },
|
||||||
|
renderGitFromData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGitFromData(data) {
|
||||||
|
var loading = document.getElementById('git-loading');
|
||||||
|
var el = document.getElementById('git-content');
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
|
||||||
|
var html = '<div class="card glass"><div class="card-title">Branch: ' +
|
||||||
|
escapeHtml(data.branch || 'unknown') + '</div>';
|
||||||
|
|
||||||
|
if (data.commits && data.commits.length > 0) {
|
||||||
|
data.commits.forEach(function(c) {
|
||||||
|
html += '<div class="git-commit">' +
|
||||||
|
'<span class="git-hash">' + escapeHtml(c.hash) + '</span>' +
|
||||||
|
'<span class="git-subject">' + escapeHtml(c.subject) + '</span>' +
|
||||||
|
'<span class="git-meta">' + escapeHtml(c.date) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html += '<div style="padding:12px;color:var(--hint)">No commits found.</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (data.modified && data.modified.length > 0) {
|
||||||
|
html += '<div class="card glass" style="margin-top:12px"><div class="card-title">Changes (' + data.modified.length + ')</div>';
|
||||||
|
data.modified.forEach(function(f) {
|
||||||
|
html += '<div class="git-commit">' +
|
||||||
|
'<span class="git-status git-status-' + (f.status === '??' ? 'u' : f.status.toLowerCase()) + '">' + escapeHtml(f.status) + '</span>' +
|
||||||
|
'<span class="git-subject">' + escapeHtml(f.path) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
// ── SSE real-time updates ──
|
// ── SSE real-time updates ──
|
||||||
var eventSource = null;
|
var eventSource = null;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue