From ce227df5be85ff63ec9c82401cd09d448a16e5d7 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 1 Jun 2026 16:29:22 +0700 Subject: [PATCH 1/9] feat(index): project visibility allowlist with Manage Projects modal Only show enabled projects on the homepage. Preferences persist server-side in a new project_prefs SQLite table: first run seeds every project enabled, projects discovered later default to hidden, and folders can be registered to pre-approve them. Adds GET/POST /api/projects (list, enable/disable, register, remove, bulk enable-all/disable-all) and a Manage Projects modal with search, select/deselect-all, and per-project toggles. Fixes WCO drag-region swallowing clicks on top menu items. --- docs/architecture/backend.md | 9 +- docs/architecture/system-overview.md | 21 +- internal/server/handlers.go | 3 + internal/server/projects.go | 309 ++++++++++++++++++++ internal/server/projects_test.go | 255 ++++++++++++++++ internal/server/server.go | 10 + internal/ui/live_menu.go | 1 + internal/ui/live_templates/index.html | 32 ++ internal/ui/live_templates/styles/index.css | 149 ++++++++++ web/src/index/index.js | 228 +++++++++++++++ web/src/index/sessions-page.js | 30 ++ web/src/index/sessions-page.test.js | 69 +++++ 12 files changed, 1111 insertions(+), 5 deletions(-) create mode 100644 internal/server/projects.go create mode 100644 internal/server/projects_test.go diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 0158fcc..140d8e0 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -51,6 +51,7 @@ pi-web/ │ │ ├── new_session.go # New-session creation logic │ │ ├── git.go # /api/git/info, /api/git/rename-branch handlers │ │ ├── scratchpad.go # Per-project scratchpad get/save (SQLite) +│ │ ├── projects.go # Project visibility prefs: list/toggle/register + index filtering (SQLite) │ │ ├── sound.go # /api/sounds + /sounds/ asset serving │ │ ├── push.go # PushManager: VAPID, subscribe/unsubscribe, NotifyDone │ │ ├── update.go # /api/version, check-update, update, restart handlers @@ -116,9 +117,10 @@ and `RunRestart`. When `Updater` is nil the version/update routes are not regist when `RunInstall`/`RunRestart` are nil the corresponding endpoints respond `503`. On `New`, the server opens (and migrates) a SQLite database at -`~/.pi/agent/pi-web.sqlite` — currently a single `scratchpads` table keyed by -project path. A `PushManager` (when configured) persists web-push subscriptions and -VAPID keys under the agent dir. +`~/.pi/agent/pi-web.sqlite` — a `scratchpads` table keyed by project path and a +`project_prefs` table that records which projects are shown on the index page +(see `projects.go`). A `PushManager` (when configured) persists web-push +subscriptions and VAPID keys under the agent dir. ### `sessions.Session` @@ -208,6 +210,7 @@ type piRPCWorker struct { | `/api/git/info` | GET | `handleGitInfo` | Branch / dirty / PR-URL info for a project | | `/api/git/rename-branch` | POST | `handleGitRenameBranch` | Rename the current git branch | | `/api/scratchpad` | GET/POST | `handleGetScratchpad` / `handleSaveScratchpad` | Per-project scratchpad (SQLite) | +| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects; enable/disable/register/remove visibility (SQLite) | | `/api/sounds` | GET | `handleApiSounds` | List available notification sounds | | `/sounds/` | GET | `handleSounds` | Serve a sound asset (no auth) | | `/custom-themes.css` | GET | `handleCustomThemes` | User custom theme CSS | diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index b57d86e..a253a80 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -16,7 +16,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod | Live Updates | Server-Sent Events (SSE) | | Chat RPC | JSONL over stdin/stdout via `pi --mode rpc` | | Session Storage | JSONL files on disk; pi-web creates new session files and appends `session_info` for browser rename | -| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads | +| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads and project visibility prefs | | Auth | Token cookie/query/header (optional on localhost) | ## Component Diagram @@ -56,6 +56,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod │ GET /api/worker-status → handleWorkerStatus │ │ GET /api/git/info / POST /api/git/rename-branch │ │ GET/POST /api/scratchpad → scratchpad (SQLite) │ +│ GET/POST /api/projects → project visibility prefs (SQLite) │ │ GET /api/sounds / GET /sounds/… (notification sounds) │ │ POST /share → handleShare (GitHub Gist) │ │ GET /events → handleEvents (SSE) │ @@ -127,7 +128,7 @@ name, while pi-web itself continues listening only on localhost. ├── session-status/ │ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl ← terminal writes here │ └── … -├── pi-web.sqlite ← scratchpads (and future local state) +├── pi-web.sqlite ← scratchpads + project visibility prefs └── pi-web/ ├── pi-web-state.json ← server state file ├── custom-themes.css ← optional user custom theme @@ -135,6 +136,22 @@ name, while pi-web itself continues listening only on localhost. └── push-subs.json ← web-push subscriptions (when push enabled) ``` +## Project Visibility (Allowlist) + +The index page only renders sessions whose project is **enabled**. Preferences +live in the `project_prefs` SQLite table (`internal/server/projects.go`) and sync +across devices since they are server-side. + +- **First run** (empty table): every discovered project is seeded enabled, so the + homepage looks unchanged until the user curates. +- **New projects** that appear after the first run default to **disabled** (hidden) + — an allowlist, so noise from one-off folders stays out of view. +- **Registering** a folder path (`action: register`) pre-approves it so sessions + that later land there show immediately, even before any session exists. +- Filtering is applied server-side in both `handleIndex` and `handleApiSessions` + (no client flash). Manage projects via the index menu → **Manage Projects**, + backed by `GET/POST /api/projects`. + ## Startup Order 1. Parse CLI flags (`-p`, `-host`, `-o`, `-insecure`, `-version`) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 901ce7b..61655e0 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -29,6 +29,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 500) return } + summaries = s.filterEnabledSummaries(summaries) sessions.SortSummariesByActivity(summaries) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.renderIndex(w, summaries); err != nil { @@ -169,6 +170,8 @@ func (s *Server) handleApiSessions(w http.ResponseWriter, r *http.Request) { } } summaries = filtered + } else { + summaries = s.filterEnabledSummaries(summaries) } sessions.SortSummariesByActivity(summaries) diff --git a/internal/server/projects.go b/internal/server/projects.go new file mode 100644 index 0000000..f919758 --- /dev/null +++ b/internal/server/projects.go @@ -0,0 +1,309 @@ +package server + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "pi-web/internal/sessions" +) + +var ( + errEmptyPath = errors.New("path is required") + errRelativePath = errors.New("path must be absolute") +) + +// projectPrefsSchema creates the table that records which projects are shown on +// the index page. A project is "enabled" when it should appear; new projects +// discovered after the first run default to disabled (allowlist), while the +// very first run seeds every existing project as enabled so the homepage looks +// unchanged until the user starts curating. +const projectPrefsSchema = `CREATE TABLE IF NOT EXISTS project_prefs ( + project_path TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT 'discovered', + updated_at DATETIME +)` + +type projectEntry struct { + Path string `json:"path"` + Enabled bool `json:"enabled"` + SessionCount int `json:"sessionCount"` + Source string `json:"source"` +} + +func (s *Server) nowTime() time.Time { + if s.now != nil { + return s.now() + } + return time.Now() +} + +// distinctProjects returns the unique, non-empty project paths in first-seen +// order. +func distinctProjects(summaries []sessions.SessionSummary) []string { + seen := make(map[string]bool) + out := make([]string, 0) + for _, sum := range summaries { + if sum.Project == "" || seen[sum.Project] { + continue + } + seen[sum.Project] = true + out = append(out, sum.Project) + } + return out +} + +// syncProjectPrefs records any not-yet-tracked discovered projects. On the very +// first run (empty table) every discovered project is enabled; afterwards new +// projects are inserted disabled so they stay hidden until the user enables +// them. Existing rows are never modified. +func (s *Server) syncProjectPrefs(discovered []string) { + if s.db == nil || len(discovered) == 0 { + return + } + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM project_prefs").Scan(&count); err != nil { + return + } + defaultEnabled := 0 + if count == 0 { + defaultEnabled = 1 + } + now := s.nowTime() + for _, p := range discovered { + if p == "" { + continue + } + _, _ = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, ?, 'discovered', ?) + ON CONFLICT(project_path) DO NOTHING`, p, defaultEnabled, now) + } +} + +// enabledProjectSet returns the set of enabled project paths. The second return +// value is false when preferences are unavailable (no database), in which case +// callers should treat every project as enabled. +func (s *Server) enabledProjectSet() (map[string]bool, bool) { + if s.db == nil { + return nil, false + } + rows, err := s.db.Query("SELECT project_path FROM project_prefs WHERE enabled = 1") + if err != nil { + return nil, false + } + defer rows.Close() + set := make(map[string]bool) + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, false + } + set[p] = true + } + return set, true +} + +// filterEnabledSummaries drops sessions whose project is disabled. Sessions with +// an empty project are always kept. With no database it is a no-op. +func (s *Server) filterEnabledSummaries(summaries []sessions.SessionSummary) []sessions.SessionSummary { + if s.db == nil { + return summaries + } + s.syncProjectPrefs(distinctProjects(summaries)) + enabled, ok := s.enabledProjectSet() + if !ok { + return summaries + } + out := make([]sessions.SessionSummary, 0, len(summaries)) + for _, sum := range summaries { + if sum.Project == "" || enabled[sum.Project] { + out = append(out, sum) + } + } + return out +} + +func (s *Server) handleApiProjects(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + summaries, err := s.loadSummaries() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + counts := make(map[string]int) + for _, sum := range summaries { + if sum.Project != "" { + counts[sum.Project]++ + } + } + s.syncProjectPrefs(distinctProjects(summaries)) + + enabled := make(map[string]bool) + source := make(map[string]string) + if s.db != nil { + rows, err := s.db.Query("SELECT project_path, enabled, source FROM project_prefs") + if err == nil { + defer rows.Close() + for rows.Next() { + var p, src string + var en int + if err := rows.Scan(&p, &en, &src); err != nil { + continue + } + enabled[p] = en == 1 + source[p] = src + } + } + } + + // Union of projects that have sessions and projects recorded in prefs + // (e.g. registered paths without sessions yet). + paths := make(map[string]bool) + for p := range counts { + paths[p] = true + } + for p := range enabled { + paths[p] = true + } + + entries := make([]projectEntry, 0, len(paths)) + for p := range paths { + src := source[p] + if src == "" { + src = "discovered" + } + en := enabled[p] + // Without a database we cannot persist prefs; report everything enabled. + if s.db == nil { + en = true + } + entries = append(entries, projectEntry{ + Path: p, + Enabled: en, + SessionCount: counts[p], + Source: src, + }) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].SessionCount != entries[j].SessionCount { + return entries[i].SessionCount > entries[j].SessionCount + } + return entries[i].Path < entries[j].Path + }) + + writeJSON(w, 0, map[string]any{"projects": entries}) +} + +func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var body struct { + Path string `json:"path"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid json body") + return + } + if s.db == nil { + writeJSONError(w, http.StatusInternalServerError, "preferences are unavailable") + return + } + + if body.Action == "enable-all" || body.Action == "disable-all" { + s.setAllProjectsEnabled(body.Action == "enable-all") + writeJSON(w, 0, map[string]any{"ok": true}) + return + } + + path := body.Path + if body.Action == "register" { + normalized, err := normalizeProjectPath(path) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) + return + } + path = normalized + } + if strings.TrimSpace(path) == "" { + writeJSONError(w, http.StatusBadRequest, "path is required") + return + } + + now := s.nowTime() + var err error + switch body.Action { + case "enable": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 1, 'discovered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=1, updated_at=excluded.updated_at`, path, now) + case "disable": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 0, 'discovered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=0, updated_at=excluded.updated_at`, path, now) + case "register": + _, err = s.db.Exec(`INSERT INTO project_prefs (project_path, enabled, source, updated_at) + VALUES (?, 1, 'registered', ?) + ON CONFLICT(project_path) DO UPDATE SET enabled=1, updated_at=excluded.updated_at`, path, now) + case "remove": + _, err = s.db.Exec("DELETE FROM project_prefs WHERE project_path = ?", path) + default: + writeJSONError(w, http.StatusBadRequest, "unknown action") + return + } + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to update project: "+err.Error()) + return + } + writeJSON(w, 0, map[string]any{"ok": true, "path": path}) +} + +// setAllProjectsEnabled flips every known project (discovered ∪ registered) to +// enabled or disabled in one shot. Discovered projects are synced first so they +// exist as rows before the bulk update. +func (s *Server) setAllProjectsEnabled(enabled bool) { + if s.db == nil { + return + } + if summaries, err := s.loadSummaries(); err == nil { + s.syncProjectPrefs(distinctProjects(summaries)) + } + val := 0 + if enabled { + val = 1 + } + _, _ = s.db.Exec("UPDATE project_prefs SET enabled = ?, updated_at = ?", val, s.nowTime()) +} + +// normalizeProjectPath expands a leading ~ and cleans the path so a registered +// project matches the cwd recorded in future session headers. +func normalizeProjectPath(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", errEmptyPath + } + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + path = filepath.Join(home, path[2:]) + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return "", errRelativePath + } + return path, nil +} diff --git a/internal/server/projects_test.go b/internal/server/projects_test.go new file mode 100644 index 0000000..883fdb2 --- /dev/null +++ b/internal/server/projects_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "pi-web/internal/sessions" + + _ "modernc.org/sqlite" +) + +func newProjectPrefsDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := db.Exec(projectPrefsSchema); err != nil { + t.Fatalf("create project_prefs: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func enabledSet(t *testing.T, s *Server) map[string]bool { + t.Helper() + set, ok := s.enabledProjectSet() + if !ok { + t.Fatal("enabledProjectSet not available") + } + return set +} + +func TestSyncProjectPrefs_FirstRunEnablesAll(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a", "/b"}) + + set := enabledSet(t, s) + if !set["/a"] || !set["/b"] { + t.Fatalf("first run should enable all discovered projects, got %v", set) + } +} + +func TestSyncProjectPrefs_NewProjectsHiddenAfterBootstrap(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a"}) // bootstrap: /a enabled + + s.syncProjectPrefs([]string{"/a", "/c"}) // /c appears later + set := enabledSet(t, s) + if !set["/a"] { + t.Fatal("/a should remain enabled") + } + if set["/c"] { + t.Fatal("/c discovered after bootstrap should be hidden by default") + } +} + +func TestSyncProjectPrefs_PreservesExistingState(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + s.syncProjectPrefs([]string{"/a"}) + // User disables /a. + if _, err := s.db.Exec("UPDATE project_prefs SET enabled = 0 WHERE project_path = ?", "/a"); err != nil { + t.Fatal(err) + } + // A later sync must not re-enable it. + s.syncProjectPrefs([]string{"/a", "/b"}) + set := enabledSet(t, s) + if set["/a"] { + t.Fatal("/a should stay disabled across syncs") + } +} + +func TestFilterEnabledSummaries(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + // Seed: /a enabled, /b disabled. + s.syncProjectPrefs([]string{"/a"}) + s.syncProjectPrefs([]string{"/a", "/b"}) // /b hidden + + summaries := []sessions.SessionSummary{ + {ID: "1", Project: "/a"}, + {ID: "2", Project: "/b"}, + {ID: "3", Project: ""}, // empty project always kept + } + out := s.filterEnabledSummaries(summaries) + got := map[string]bool{} + for _, sum := range out { + got[sum.ID] = true + } + if !got["1"] || got["2"] || !got["3"] { + t.Fatalf("unexpected filter result: %v", got) + } +} + +func TestFilterEnabledSummaries_NoDBIsNoOp(t *testing.T) { + s := &Server{} + summaries := []sessions.SessionSummary{{ID: "1", Project: "/a"}} + if len(s.filterEnabledSummaries(summaries)) != 1 { + t.Fatal("without db, filtering should be a no-op") + } +} + +func TestHandleUpdateProject(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + + post := func(path, action string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"path": path, "action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + return w + } + + if w := post("/a", "enable"); w.Code != http.StatusOK { + t.Fatalf("enable status = %d", w.Code) + } + if !enabledSet(t, s)["/a"] { + t.Fatal("/a should be enabled") + } + + if w := post("/a", "disable"); w.Code != http.StatusOK { + t.Fatalf("disable status = %d", w.Code) + } + if enabledSet(t, s)["/a"] { + t.Fatal("/a should be disabled") + } + + // register stores the path with source=registered and enabled. + if w := post("/home/user/proj", "register"); w.Code != http.StatusOK { + t.Fatalf("register status = %d", w.Code) + } + var source string + if err := s.db.QueryRow("SELECT source FROM project_prefs WHERE project_path = ?", "/home/user/proj").Scan(&source); err != nil { + t.Fatal(err) + } + if source != "registered" { + t.Fatalf("source = %q, want registered", source) + } + + // remove deletes the row. + if w := post("/home/user/proj", "remove"); w.Code != http.StatusOK { + t.Fatalf("remove status = %d", w.Code) + } + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM project_prefs WHERE project_path = ?", "/home/user/proj").Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("removed project should be gone") + } + + if w := post("/a", "bogus"); w.Code != http.StatusBadRequest { + t.Fatalf("unknown action status = %d, want 400", w.Code) + } +} + +func TestHandleUpdateProject_BulkToggle(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t), cache: sessions.NewCache(), sessionsDir: t.TempDir()} + s.syncProjectPrefs([]string{"/a", "/b", "/c"}) + + post := func(action string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + return w + } + + if w := post("disable-all"); w.Code != http.StatusOK { + t.Fatalf("disable-all status = %d", w.Code) + } + if len(enabledSet(t, s)) != 0 { + t.Fatal("disable-all should disable every project") + } + + if w := post("enable-all"); w.Code != http.StatusOK { + t.Fatalf("enable-all status = %d", w.Code) + } + set := enabledSet(t, s) + if !set["/a"] || !set["/b"] || !set["/c"] { + t.Fatalf("enable-all should enable every project, got %v", set) + } +} + +func TestHandleApiProjects(t *testing.T) { + sessionsDir := t.TempDir() + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub1"), "a.jsonl", "/home/user/project-a") + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub1"), "b.jsonl", "/home/user/project-a") + writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub2"), "c.jsonl", "/home/user/project-b") + + s := &Server{db: newProjectPrefsDB(t), sessionsDir: sessionsDir, cache: sessions.NewCache()} + + req := httptest.NewRequest(http.MethodGet, "/api/projects", nil) + w := httptest.NewRecorder() + s.handleApiProjects(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + + var payload struct { + Projects []projectEntry `json:"projects"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + byPath := map[string]projectEntry{} + for _, p := range payload.Projects { + byPath[p.Path] = p + } + a, ok := byPath["/home/user/project-a"] + if !ok || a.SessionCount != 2 || !a.Enabled { + t.Fatalf("project-a entry wrong: %+v", a) + } + b, ok := byPath["/home/user/project-b"] + if !ok || b.SessionCount != 1 || !b.Enabled { + t.Fatalf("project-b entry wrong: %+v", b) + } +} + +func TestNormalizeProjectPath(t *testing.T) { + home, _ := os.UserHomeDir() + cases := []struct { + in string + want string + wantErr bool + }{ + {"/abs/path", "/abs/path", false}, + {"/abs/path/", "/abs/path", false}, + {"~/proj", filepath.Join(home, "proj"), false}, + {" /spaced ", "/spaced", false}, + {"relative/path", "", true}, + {"", "", true}, + } + for _, tc := range cases { + got, err := normalizeProjectPath(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("normalizeProjectPath(%q) expected error", tc.in) + } + continue + } + if err != nil { + t.Errorf("normalizeProjectPath(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("normalizeProjectPath(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 84c2ec6..d6c8948 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -117,6 +117,9 @@ func New(deps Deps) *Server { if err != nil { fmt.Fprintf(os.Stderr, "failed to create scratchpads table: %v\n", err) } + if _, err := db.Exec(projectPrefsSchema); err != nil { + fmt.Fprintf(os.Stderr, "failed to create project_prefs table: %v\n", err) + } } s := &Server{ @@ -188,6 +191,13 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession)) mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession)) mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations)) + mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + s.auth.Wrap(s.handleUpdateProject)(w, r) + } else { + s.auth.Wrap(s.handleApiProjects)(w, r) + } + }) mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo)) mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch)) mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes)) diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index a59f1a2..3611500 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -55,6 +55,7 @@ func homeMenuHTML() template.HTML { Sections: []liveMenuSection{ {Items: []liveMenuItem{ {Label: "New Session", Attrs: `data-new-session-btn role="menuitem"`}, + {Label: "Manage Projects", Attrs: `id="manage-projects-btn" data-manage-projects-btn role="menuitem"`}, {Label: "Import Session", Muted: true, Attrs: `role="menuitem"`}, }}, {Items: []liveMenuItem{ diff --git a/internal/ui/live_templates/index.html b/internal/ui/live_templates/index.html index e72abb7..1a9814f 100644 --- a/internal/ui/live_templates/index.html +++ b/internal/ui/live_templates/index.html @@ -37,6 +37,12 @@

Sessions + {{ if eq (len .) 0 }} +
+

No sessions to show

+

Enable projects from the menu (⋯ → Manage Projects) to see their sessions.

+
+ {{ end }} {{ $currentProject := "" }} {{ range . }} {{ if ne .Project $currentProject }} @@ -87,6 +93,32 @@

Start a new session

+ {{ liveServiceWorkerScript }} diff --git a/internal/ui/live_templates/styles/index.css b/internal/ui/live_templates/styles/index.css index f5f657f..5a33c07 100644 --- a/internal/ui/live_templates/styles/index.css +++ b/internal/ui/live_templates/styles/index.css @@ -72,6 +72,16 @@ a { color: inherit; } -webkit-app-region: no-drag; app-region: no-drag; } +/* The dropdown menu and modals are layered above the draggable header. Without + an explicit no-drag, the OS title-bar drag region swallows clicks on any of + their controls that overlap the header band (e.g. the top menu items). */ +:root.wco .web-menu, +:root.wco .web-menu *, +:root.wco .modal-overlay, +:root.wco .modal-overlay * { + -webkit-app-region: no-drag; + app-region: no-drag; +} .header-top { display: flex; align-items: center; @@ -494,6 +504,145 @@ body.modal-sheet-open { overflow: hidden; } } .modal-error { color: var(--danger); min-height: 16px; margin-top: 8px; font-size: 11px; } +.modal-hint { color: var(--muted); font-size: 12px; margin-bottom: 12px; line-height: 1.5; } +.projects-toolbar { + display: flex; + align-items: stretch; + gap: 8px; + margin-bottom: 10px; +} +.projects-search { + flex: 1 1 auto; + min-width: 0; + height: 36px; + box-sizing: border-box; + background: var(--body-bg); + border: 1px solid var(--dim); + color: var(--text); + padding: 0 12px; + border-radius: 6px; + outline: none; + font-size: 12px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.projects-search:focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.projects-bulk-btn { + flex: 0 0 auto; + height: 36px; + box-sizing: border-box; + border: 1px solid var(--dim); + background: transparent; + color: var(--text-soft); + cursor: pointer; + font-size: 11px; + padding: 0 14px; + border-radius: 6px; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} +.projects-bulk-btn:hover:not(:disabled) { color: var(--text); border-color: var(--muted); background: var(--surface-2); } +.projects-bulk-btn:disabled { opacity: 0.5; cursor: default; } +.projects-list { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + max-height: 320px; + overflow-y: auto; + margin-bottom: 12px; +} +.projects-empty { color: var(--muted); font-size: 12px; padding: 12px 4px; } +.project-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 6px; + border-radius: 6px; + box-sizing: border-box; +} +.project-row.has-remove { grid-template-columns: auto minmax(0, 1fr) auto auto; } +.project-row:hover { background: var(--surface-2); } +.project-row input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + margin: 0; + border: 1.5px solid var(--muted); + border-radius: 5px; + background: var(--body-bg); + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: background 0.15s ease, border-color 0.15s ease; +} +.project-row input[type="checkbox"]:hover { border-color: var(--text-soft); } +.project-row input[type="checkbox"]:checked { + background: var(--accent); + border-color: var(--accent); +} +.project-row input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + top: 46%; + left: 50%; + width: 5px; + height: 9px; + border: solid var(--body-bg); + border-width: 0 2px 2px 0; + transform: translate(-50%, -50%) rotate(45deg); +} +.project-row input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); +} +.project-row-name { + color: var(--text); + font-size: 12px; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + /* Truncate from the left so the distinguishing tail of long, common-prefixed + paths (the project folder) stays visible. */ + direction: rtl; + text-align: left; +} +.project-row-name bdi { direction: ltr; unicode-bidi: bidi-override; } +.project-row-count { color: var(--muted); font-size: 11px; white-space: nowrap; } +.project-row-remove { + border: 1px solid var(--dim); + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 11px; + padding: 3px 8px; + border-radius: 6px; + flex-shrink: 0; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} +.project-row-remove:hover { color: var(--danger); border-color: var(--danger); } +.project-row.hidden, .projects-empty.hidden { display: none; } +.projects-footer { + margin-top: 10px; + padding-top: 16px; + border-top: 1px solid var(--dim); +} +.projects-footer-label { + display: block; + color: var(--text-soft); + font-size: 12px; + font-weight: 560; + margin-bottom: 8px; +} +.projects-footer input { margin-bottom: 0; } +#projectsModalError { min-height: 0; margin: 8px 0 0; } +#projectsModalError:empty { display: none; } +#projectsModalOverlay .modal-actions { margin-top: 14px; } + ::-webkit-scrollbar { width: 10px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--dim); } diff --git a/web/src/index/index.js b/web/src/index/index.js index 335e558..1515559 100644 --- a/web/src/index/index.js +++ b/web/src/index/index.js @@ -49,6 +49,16 @@ export function runIndexPage({ const spinnerStatus = documentImpl.getElementById('index-spinner-status'); const layoutBtns = Array.from(documentImpl.querySelectorAll('[data-layout-btn]')); const layoutStorageKey = 'pi-sessions:view-layout'; + const manageProjectsBtns = Array.from(documentImpl.querySelectorAll('[data-manage-projects-btn]')); + const projectsModalOverlay = documentImpl.getElementById('projectsModalOverlay'); + const projectsModalBackBtn = documentImpl.getElementById('projectsModalBackBtn'); + const projectsDoneBtn = documentImpl.getElementById('projectsDoneBtn'); + const projectsList = documentImpl.getElementById('projectsList'); + const projectsAddPath = documentImpl.getElementById('projectsAddPath'); + const projectsAddBtn = documentImpl.getElementById('projectsAddBtn'); + const projectsModalError = documentImpl.getElementById('projectsModalError'); + const projectsSearch = documentImpl.getElementById('projectsSearch'); + const projectsToggleAllBtn = documentImpl.getElementById('projectsToggleAllBtn'); let modalHideTimer = null; let sessionPalette = null; @@ -330,6 +340,7 @@ export function runIndexPage({ const paletteOverlay = documentImpl.getElementById('sessionPalette'); if (paletteOverlay?.classList.contains('open')) closePalette(); else if (webMenu && !webMenu.hidden) closeMenu(); + else if (projectsModalOpen) hideProjectsModal(); else if (page.modal) hideModal(); } }); @@ -366,6 +377,223 @@ export function runIndexPage({ createBtn.addEventListener('click', doCreate); } + // ── Manage projects modal ── + + let projectsModalHideTimer = null; + let projectsModalOpen = false; + + function showProjectsModal() { + closeMenu(); + if (!projectsModalOverlay) return; + if (projectsModalHideTimer) { + clearTimeoutImpl(projectsModalHideTimer); + projectsModalHideTimer = null; + } + projectsModalOpen = true; + projectsModalOverlay.classList.add('visible'); + documentImpl.body?.classList.add('modal-sheet-open'); + const requestFrame = windowImpl.requestAnimationFrame?.bind(windowImpl) || ((fn) => setTimeoutImpl(fn, 0)); + requestFrame(() => projectsModalOverlay.classList.add('open')); + } + + function hideProjectsModal() { + projectsModalOpen = false; + if (projectsModalOverlay) { + projectsModalOverlay.classList.remove('open'); + if (projectsModalHideTimer) clearTimeoutImpl(projectsModalHideTimer); + projectsModalHideTimer = setTimeoutImpl(() => { + projectsModalOverlay.classList.remove('visible'); + projectsModalHideTimer = null; + }, 300); + } + documentImpl.body?.classList.remove('modal-sheet-open'); + } + + let projectsCache = []; + + function applyProjectsSearch() { + if (!projectsList) return; + const q = (projectsSearch?.value || '').trim().toLowerCase(); + const rows = projectsList.querySelectorAll('.project-row'); + let anyVisible = false; + rows.forEach((row) => { + const match = !q || (row.dataset.path || '').toLowerCase().includes(q); + row.classList.toggle('hidden', !match); + if (match) anyVisible = true; + }); + let noResults = projectsList.querySelector('[data-projects-no-results]'); + if (q && rows.length && !anyVisible) { + if (!noResults) { + noResults = documentImpl.createElement('div'); + noResults.className = 'projects-empty'; + noResults.setAttribute('data-projects-no-results', ''); + noResults.textContent = 'No projects match your search.'; + projectsList.appendChild(noResults); + } + noResults.classList.remove('hidden'); + } else if (noResults) { + noResults.classList.add('hidden'); + } + } + + function updateToggleAllLabel() { + if (!projectsToggleAllBtn) return; + const allEnabled = projectsCache.length > 0 && projectsCache.every((p) => p.enabled); + projectsToggleAllBtn.textContent = allEnabled ? 'Deselect all' : 'Select all'; + projectsToggleAllBtn.dataset.target = allEnabled ? 'disable' : 'enable'; + projectsToggleAllBtn.disabled = projectsCache.length === 0; + } + + function renderProjectsList(projects) { + if (!projectsList) return; + projectsCache = projects; + updateToggleAllLabel(); + projectsList.innerHTML = ''; + if (!projects.length) { + const empty = documentImpl.createElement('div'); + empty.className = 'projects-empty'; + empty.textContent = 'No projects found yet.'; + projectsList.appendChild(empty); + return; + } + for (const project of projects) { + const row = documentImpl.createElement('div'); + row.className = 'project-row'; + row.dataset.path = project.path; + + const checkbox = documentImpl.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = !!project.enabled; + const applyToggle = async () => { + checkbox.disabled = true; + try { + await page.setProjectEnabled(project.path, checkbox.checked); + } catch (err) { + checkbox.checked = !checkbox.checked; + if (projectsModalError) projectsModalError.textContent = err.message || 'Failed to update project'; + } finally { + checkbox.disabled = false; + } + }; + checkbox.addEventListener('change', applyToggle); + + const name = documentImpl.createElement('span'); + name.className = 'project-row-name'; + const nameText = documentImpl.createElement('bdi'); + nameText.textContent = project.path; + name.appendChild(nameText); + name.title = project.path; + name.addEventListener('click', () => { + if (checkbox.disabled) return; + checkbox.checked = !checkbox.checked; + applyToggle(); + }); + + const meta = documentImpl.createElement('span'); + meta.className = 'project-row-count'; + const count = project.sessionCount || 0; + meta.textContent = count === 1 ? '1 session' : `${count} sessions`; + + row.appendChild(checkbox); + row.appendChild(name); + row.appendChild(meta); + + if (project.source === 'registered') { + row.classList.add('has-remove'); + const remove = documentImpl.createElement('button'); + remove.type = 'button'; + remove.className = 'project-row-remove'; + remove.textContent = 'Remove'; + remove.addEventListener('click', async () => { + remove.disabled = true; + try { + await page.removeProject(project.path); + await refreshProjectsList(); + } catch (err) { + remove.disabled = false; + if (projectsModalError) projectsModalError.textContent = err.message || 'Failed to remove project'; + } + }); + row.appendChild(remove); + } + + projectsList.appendChild(row); + } + applyProjectsSearch(); + } + + async function refreshProjectsList() { + if (projectsModalError) projectsModalError.textContent = ''; + try { + const projects = await page.loadProjects(); + renderProjectsList(projects); + } catch (err) { + if (projectsModalError) projectsModalError.textContent = err.message || 'Failed to load projects'; + } + } + + async function openProjectsModal() { + showProjectsModal(); + if (projectsAddPath) projectsAddPath.value = ''; + if (projectsSearch) projectsSearch.value = ''; + await refreshProjectsList(); + } + + async function doRegisterProject() { + if (!projectsAddPath) return; + const path = projectsAddPath.value.trim(); + if (!path) return; + if (projectsModalError) projectsModalError.textContent = ''; + if (projectsAddBtn) projectsAddBtn.disabled = true; + try { + await page.registerProject(path); + projectsAddPath.value = ''; + await refreshProjectsList(); + } catch (err) { + if (projectsModalError) projectsModalError.textContent = err.message || 'Failed to add project'; + } finally { + if (projectsAddBtn) projectsAddBtn.disabled = false; + } + } + + async function doToggleAll() { + if (!projectsToggleAllBtn) return; + const enable = projectsToggleAllBtn.dataset.target !== 'disable'; + projectsToggleAllBtn.disabled = true; + if (projectsModalError) projectsModalError.textContent = ''; + try { + await page.setAllProjectsEnabled(enable); + await refreshProjectsList(); + } catch (err) { + if (projectsModalError) projectsModalError.textContent = err.message || 'Failed to update projects'; + projectsToggleAllBtn.disabled = false; + } + } + + manageProjectsBtns.forEach((btn) => { + btn.addEventListener('click', openProjectsModal); + }); + + if (projectsSearch) projectsSearch.addEventListener('input', applyProjectsSearch); + if (projectsToggleAllBtn) projectsToggleAllBtn.addEventListener('click', doToggleAll); + + if (projectsModalBackBtn) projectsModalBackBtn.addEventListener('click', hideProjectsModal); + if (projectsDoneBtn) projectsDoneBtn.addEventListener('click', hideProjectsModal); + if (projectsAddBtn) projectsAddBtn.addEventListener('click', doRegisterProject); + if (projectsAddPath) { + projectsAddPath.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + doRegisterProject(); + } + }); + } + if (projectsModalOverlay) { + projectsModalOverlay.addEventListener('click', (e) => { + if (e.target === projectsModalOverlay) hideProjectsModal(); + }); + } + let initialLayout = 'timeline'; try { initialLayout = windowImpl.localStorage.getItem(layoutStorageKey) === 'projects' ? 'projects' : 'timeline'; diff --git a/web/src/index/sessions-page.js b/web/src/index/sessions-page.js index e4b444a..865286a 100644 --- a/web/src/index/sessions-page.js +++ b/web/src/index/sessions-page.js @@ -148,6 +148,8 @@ export function createSessionsPage({ fetchRecent = () => getJSON('/api/recent-locations'), fetchSessions = () => getJSON('/api/sessions'), createSession = (path) => postJSON('/api/new-session', { path }), + fetchProjects = () => getJSON('/api/projects'), + updateProject = (path, action) => postJSON('/api/projects', { path, action }), createStatusEvents = defaultCreateStatusEvents, navigate = defaultNavigate, setTimeoutImpl = setTimeout, @@ -300,6 +302,34 @@ export function createSessionsPage({ return this.recent; }, + async loadProjects() { + const response = await fetchProjects(); + return Array.isArray(response.projects) ? response.projects : []; + }, + + async setProjectEnabled(path, enabled) { + await updateProject(path, enabled ? 'enable' : 'disable'); + await this.refreshSessions(); + }, + + async setAllProjectsEnabled(enabled) { + await updateProject('', enabled ? 'enable-all' : 'disable-all'); + await this.refreshSessions(); + }, + + async registerProject(path) { + const p = (path || '').trim(); + if (!p) return false; + await updateProject(p, 'register'); + await this.refreshSessions(); + return true; + }, + + async removeProject(path) { + await updateProject(path, 'remove'); + await this.refreshSessions(); + }, + async create() { const p = this.path.trim(); if (!p) { diff --git a/web/src/index/sessions-page.test.js b/web/src/index/sessions-page.test.js index 72010f9..06022d1 100644 --- a/web/src/index/sessions-page.test.js +++ b/web/src/index/sessions-page.test.js @@ -230,3 +230,72 @@ describe('createSessionsPage scalable state', () => { expect(page._reloadTimer).toBeNull(); }); }); + +describe('createSessionsPage project management', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('loads projects from the API', async () => { + const fetchProjects = vi.fn(async () => ({ + projects: [ + { path: '/a', enabled: true, sessionCount: 2, source: 'discovered' }, + { path: '/b', enabled: false, sessionCount: 0, source: 'registered' } + ] + })); + const page = createSessionsPage({ fetchProjects }); + const projects = await page.loadProjects(); + expect(projects).toHaveLength(2); + expect(projects[0].path).toBe('/a'); + }); + + it('toggles a project then refreshes the session list', async () => { + const updateProject = vi.fn(async () => ({ ok: true })); + const fetchSessions = vi.fn(async () => ({ sessions: [] })); + const page = createSessionsPage({ updateProject, fetchSessions }); + + await page.setProjectEnabled('/a', false); + expect(updateProject).toHaveBeenCalledWith('/a', 'disable'); + expect(fetchSessions).toHaveBeenCalled(); + + await page.setProjectEnabled('/a', true); + expect(updateProject).toHaveBeenCalledWith('/a', 'enable'); + }); + + it('registers a project and ignores blank paths', async () => { + const updateProject = vi.fn(async () => ({ ok: true })); + const fetchSessions = vi.fn(async () => ({ sessions: [] })); + const page = createSessionsPage({ updateProject, fetchSessions }); + + const ok = await page.registerProject(' ~/proj '); + expect(ok).toBe(true); + expect(updateProject).toHaveBeenCalledWith('~/proj', 'register'); + + updateProject.mockClear(); + const blank = await page.registerProject(' '); + expect(blank).toBe(false); + expect(updateProject).not.toHaveBeenCalled(); + }); + + it('removes a project then refreshes', async () => { + const updateProject = vi.fn(async () => ({ ok: true })); + const fetchSessions = vi.fn(async () => ({ sessions: [] })); + const page = createSessionsPage({ updateProject, fetchSessions }); + + await page.removeProject('/b'); + expect(updateProject).toHaveBeenCalledWith('/b', 'remove'); + expect(fetchSessions).toHaveBeenCalled(); + }); + + it('enables/disables all projects then refreshes', async () => { + const updateProject = vi.fn(async () => ({ ok: true })); + const fetchSessions = vi.fn(async () => ({ sessions: [] })); + const page = createSessionsPage({ updateProject, fetchSessions }); + + await page.setAllProjectsEnabled(false); + expect(updateProject).toHaveBeenCalledWith('', 'disable-all'); + await page.setAllProjectsEnabled(true); + expect(updateProject).toHaveBeenCalledWith('', 'enable-all'); + expect(fetchSessions).toHaveBeenCalledTimes(2); + }); +}); From 9b1e9261a85272a9f58449737473c878086bccf4 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 1 Jun 2026 16:54:20 +0700 Subject: [PATCH 2/9] feat(index): make project filtering an opt-in master switch Add a "Filter projects" toggle (app_settings.project_filter_enabled, default off) to the Manage Projects modal. While off, every project and session shows and new sessions appear immediately like before; when on, the homepage is restricted to enabled projects (allowlist). Removes the auto-enable-on-create hack now that the default no longer hides anything. Adds enable-filter/ disable-filter actions and a filterEnabled flag to /api/projects. --- docs/architecture/backend.md | 11 ++--- docs/architecture/system-overview.md | 29 +++++++------ internal/server/projects.go | 48 ++++++++++++++++++++- internal/server/projects_test.go | 47 ++++++++++++++++++++ internal/server/server.go | 3 ++ internal/ui/live_templates/index.html | 18 +++++--- internal/ui/live_templates/styles/index.css | 36 ++++++++++++++++ web/src/index/index.js | 32 +++++++++++++- web/src/index/sessions-page.js | 10 ++++- web/src/index/sessions-page.test.js | 18 +++++++- 10 files changed, 224 insertions(+), 28 deletions(-) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 140d8e0..0edaa0b 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -117,10 +117,11 @@ and `RunRestart`. When `Updater` is nil the version/update routes are not regist when `RunInstall`/`RunRestart` are nil the corresponding endpoints respond `503`. On `New`, the server opens (and migrates) a SQLite database at -`~/.pi/agent/pi-web.sqlite` — a `scratchpads` table keyed by project path and a -`project_prefs` table that records which projects are shown on the index page -(see `projects.go`). A `PushManager` (when configured) persists web-push -subscriptions and VAPID keys under the agent dir. +`~/.pi/agent/pi-web.sqlite` — a `scratchpads` table keyed by project path, a +`project_prefs` table recording which projects are enabled, and an `app_settings` +key/value table holding the project-filter master switch (default off). See +`projects.go`. A `PushManager` (when configured) persists web-push subscriptions +and VAPID keys under the agent dir. ### `sessions.Session` @@ -210,7 +211,7 @@ type piRPCWorker struct { | `/api/git/info` | GET | `handleGitInfo` | Branch / dirty / PR-URL info for a project | | `/api/git/rename-branch` | POST | `handleGitRenameBranch` | Rename the current git branch | | `/api/scratchpad` | GET/POST | `handleGetScratchpad` / `handleSaveScratchpad` | Per-project scratchpad (SQLite) | -| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects; enable/disable/register/remove visibility (SQLite) | +| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects + filter state; enable/disable/register/remove, bulk enable-all/disable-all, enable-filter/disable-filter (SQLite) | | `/api/sounds` | GET | `handleApiSounds` | List available notification sounds | | `/sounds/` | GET | `handleSounds` | Serve a sound asset (no auth) | | `/custom-themes.css` | GET | `handleCustomThemes` | User custom theme CSS | diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index a253a80..dc04048 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -136,21 +136,26 @@ name, while pi-web itself continues listening only on localhost. └── push-subs.json ← web-push subscriptions (when push enabled) ``` -## Project Visibility (Allowlist) - -The index page only renders sessions whose project is **enabled**. Preferences -live in the `project_prefs` SQLite table (`internal/server/projects.go`) and sync -across devices since they are server-side. - -- **First run** (empty table): every discovered project is seeded enabled, so the - homepage looks unchanged until the user curates. -- **New projects** that appear after the first run default to **disabled** (hidden) - — an allowlist, so noise from one-off folders stays out of view. +## Project Visibility + +Project filtering is an **opt-in master switch**, stored in the `app_settings` +SQLite table (`project_filter_enabled`, default **off**). Per-project enable +state lives in the `project_prefs` table. Both are server-side, so they sync +across devices. See `internal/server/projects.go`. + +- **Filter off (default):** every session shows; new sessions (web- or + terminal-created) appear immediately, exactly like before the feature existed. +- **Filter on:** the index only renders sessions whose project is **enabled** — + an allowlist. Projects discovered after the table is first seeded default to + hidden, so one-off folders stay out of view. +- **First seed** (empty `project_prefs`): every discovered project is enabled, so + turning the filter on doesn't blank the homepage. - **Registering** a folder path (`action: register`) pre-approves it so sessions that later land there show immediately, even before any session exists. - Filtering is applied server-side in both `handleIndex` and `handleApiSessions` - (no client flash). Manage projects via the index menu → **Manage Projects**, - backed by `GET/POST /api/projects`. + (no client flash) and is a no-op while the master switch is off. Manage via the + index menu → **Manage Projects** (search, select/deselect-all, register, and the + filter switch), backed by `GET/POST /api/projects`. ## Startup Order diff --git a/internal/server/projects.go b/internal/server/projects.go index f919758..71be3d0 100644 --- a/internal/server/projects.go +++ b/internal/server/projects.go @@ -30,6 +30,41 @@ const projectPrefsSchema = `CREATE TABLE IF NOT EXISTS project_prefs ( updated_at DATETIME )` +// appSettingsSchema holds simple key/value app preferences. Currently only the +// project-filter master switch. +const appSettingsSchema = `CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT +)` + +const settingProjectFilterEnabled = "project_filter_enabled" + +// projectFilterEnabled reports whether the homepage should be filtered to only +// enabled projects. Off by default: with the filter off every project (and any +// new session) shows up normally. +func (s *Server) projectFilterEnabled() bool { + if s.db == nil { + return false + } + var v string + if err := s.db.QueryRow("SELECT value FROM app_settings WHERE key = ?", settingProjectFilterEnabled).Scan(&v); err != nil { + return false + } + return v == "1" +} + +func (s *Server) setProjectFilterEnabled(enabled bool) { + if s.db == nil { + return + } + v := "0" + if enabled { + v = "1" + } + _, _ = s.db.Exec(`INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, settingProjectFilterEnabled, v) +} + type projectEntry struct { Path string `json:"path"` Enabled bool `json:"enabled"` @@ -112,7 +147,7 @@ func (s *Server) enabledProjectSet() (map[string]bool, bool) { // filterEnabledSummaries drops sessions whose project is disabled. Sessions with // an empty project are always kept. With no database it is a no-op. func (s *Server) filterEnabledSummaries(summaries []sessions.SessionSummary) []sessions.SessionSummary { - if s.db == nil { + if s.db == nil || !s.projectFilterEnabled() { return summaries } s.syncProjectPrefs(distinctProjects(summaries)) @@ -201,7 +236,10 @@ func (s *Server) handleApiProjects(w http.ResponseWriter, r *http.Request) { return entries[i].Path < entries[j].Path }) - writeJSON(w, 0, map[string]any{"projects": entries}) + writeJSON(w, 0, map[string]any{ + "projects": entries, + "filterEnabled": s.projectFilterEnabled(), + }) } func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { @@ -222,6 +260,12 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { return } + if body.Action == "enable-filter" || body.Action == "disable-filter" { + s.setProjectFilterEnabled(body.Action == "enable-filter") + writeJSON(w, 0, map[string]any{"ok": true, "filterEnabled": s.projectFilterEnabled()}) + return + } + if body.Action == "enable-all" || body.Action == "disable-all" { s.setAllProjectsEnabled(body.Action == "enable-all") writeJSON(w, 0, map[string]any{"ok": true}) diff --git a/internal/server/projects_test.go b/internal/server/projects_test.go index 883fdb2..cdeed49 100644 --- a/internal/server/projects_test.go +++ b/internal/server/projects_test.go @@ -24,6 +24,9 @@ func newProjectPrefsDB(t *testing.T) *sql.DB { if _, err := db.Exec(projectPrefsSchema); err != nil { t.Fatalf("create project_prefs: %v", err) } + if _, err := db.Exec(appSettingsSchema); err != nil { + t.Fatalf("create app_settings: %v", err) + } t.Cleanup(func() { db.Close() }) return db } @@ -78,6 +81,7 @@ func TestSyncProjectPrefs_PreservesExistingState(t *testing.T) { func TestFilterEnabledSummaries(t *testing.T) { s := &Server{db: newProjectPrefsDB(t)} + s.setProjectFilterEnabled(true) // Seed: /a enabled, /b disabled. s.syncProjectPrefs([]string{"/a"}) s.syncProjectPrefs([]string{"/a", "/b"}) // /b hidden @@ -105,6 +109,49 @@ func TestFilterEnabledSummaries_NoDBIsNoOp(t *testing.T) { } } +func TestFilterDisabledShowsEverything(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + // /b is disabled in prefs, but the master filter is off (default). + s.syncProjectPrefs([]string{"/a"}) + s.syncProjectPrefs([]string{"/a", "/b"}) + + summaries := []sessions.SessionSummary{{ID: "1", Project: "/a"}, {ID: "2", Project: "/b"}} + if got := s.filterEnabledSummaries(summaries); len(got) != 2 { + t.Fatalf("filter off should show everything, got %d", len(got)) + } + + s.setProjectFilterEnabled(true) + if got := s.filterEnabledSummaries(summaries); len(got) != 1 { + t.Fatalf("filter on should hide /b, got %d", len(got)) + } +} + +func TestHandleUpdateProject_FilterToggle(t *testing.T) { + s := &Server{db: newProjectPrefsDB(t)} + if s.projectFilterEnabled() { + t.Fatal("filter should default off") + } + + post := func(action string) { + body, _ := json.Marshal(map[string]string{"action": action}) + req := httptest.NewRequest(http.MethodPost, "/api/projects", strings.NewReader(string(body))) + w := httptest.NewRecorder() + s.handleUpdateProject(w, req) + if w.Code != http.StatusOK { + t.Fatalf("%s status = %d", action, w.Code) + } + } + + post("enable-filter") + if !s.projectFilterEnabled() { + t.Fatal("enable-filter should turn the filter on") + } + post("disable-filter") + if s.projectFilterEnabled() { + t.Fatal("disable-filter should turn the filter off") + } +} + func TestHandleUpdateProject(t *testing.T) { s := &Server{db: newProjectPrefsDB(t)} diff --git a/internal/server/server.go b/internal/server/server.go index d6c8948..8ad72ae 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -120,6 +120,9 @@ func New(deps Deps) *Server { if _, err := db.Exec(projectPrefsSchema); err != nil { fmt.Fprintf(os.Stderr, "failed to create project_prefs table: %v\n", err) } + if _, err := db.Exec(appSettingsSchema); err != nil { + fmt.Fprintf(os.Stderr, "failed to create app_settings table: %v\n", err) + } } s := &Server{ diff --git a/internal/ui/live_templates/index.html b/internal/ui/live_templates/index.html index 1a9814f..20f817d 100644 --- a/internal/ui/live_templates/index.html +++ b/internal/ui/live_templates/index.html @@ -102,12 +102,20 @@

Start a new session

Manage projects

- -
- - + +
+
+ + +
+
-
+
-
+
@@ -265,12 +269,16 @@ hidden />
+
+ -
+
@@ -303,5 +311,6 @@
+
diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index b205178..8d20452 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -236,7 +236,7 @@ --darkGray: #3b4252; --accent-color: #88c0d0; --selectedBg: #434c5e; - --userMessageBg: #3b4252; + --userMessageBg: #434c5e; --toolPendingBg: #434c5e; --toolSuccessBg: #434c5e; --toolErrorBg: #4c566a; @@ -327,7 +327,7 @@ --darkGray: #44475a; --accent-color: #ff79c6; --selectedBg: #44475a; - --userMessageBg: #1e1f29; + --userMessageBg: #343746; --toolPendingBg: #343746; --toolSuccessBg: #343746; --toolErrorBg: #44475a; @@ -1151,6 +1151,49 @@ border-color: var(--pi-menu-border); } + .session-header-new { + height: 24px; + padding: 0 9px 0 7px; + font-size: 11px; + font-weight: 500; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 3px; + background: var(--accent); + color: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + margin-right: 8px; + transition: opacity 0.12s; + } + + .session-header-new span[aria-hidden] { + font-size: 14px; + line-height: 1; + } + + .session-header-new:hover, + .session-header-new:active { + opacity: 0.85; + } + + @media (max-width: 900px) { + .session-header-new { + padding: 0; + width: 28px; + height: 28px; + justify-content: center; + gap: 0; + } + + .session-header-new-label { + display: none; + } + } + .session-header-shortcuts-help { height: 24px; padding: 0 7px; @@ -1249,6 +1292,7 @@ color: var(--userMessageText); padding: var(--line-height); border-radius: 4px; + border-left: 3px solid var(--accent); position: relative; overflow-wrap: break-word; word-wrap: break-word; @@ -2319,6 +2363,15 @@ } .pi-git-branch-input[hidden] { display: none; } + .pi-git-right { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + /* Stay pinned right even when the branch cluster is hidden (non-git cwd). */ + margin-left: auto; + } + .pi-git-actions { display: inline-flex; align-items: center; @@ -2402,6 +2455,241 @@ } .pi-git-caret[hidden] { display: none; } + /* ── btw floating window ── */ + .pi-btw-window { + position: fixed; + top: 0; + left: 0; + z-index: 1200; + display: flex; + flex-direction: column; + width: 340px; + min-width: 260px; + max-width: calc(100vw - 24px); + height: 420px; + min-height: 220px; + max-height: calc(100vh - 24px); + background: var(--container-bg, var(--surface)); + border: 1px solid var(--dim); + border-radius: 8px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + overflow: hidden; + resize: both; + } + .pi-btw-window[hidden] { display: none; } + + .pi-btw-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + background: var(--chrome-bg); + border-bottom: 1px solid var(--dim); + cursor: move; + user-select: none; + touch-action: none; + } + .pi-btw-title { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-size: 17px; + color: var(--text); + } + .pi-btw-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + .pi-btw-new { + padding: 2px 9px; + font-size: 11px; + font-family: inherit; + background: transparent; + color: var(--text-soft); + border: 1px solid var(--dim); + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-btw-new:hover { + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + .pi-btw-new:disabled { opacity: 0.5; cursor: default; } + .pi-btw-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + font-size: 18px; + line-height: 1; + background: transparent; + color: var(--muted); + border: 0; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, background 0.15s; + } + .pi-btw-close:hover { + color: var(--text); + background: var(--hover); + } + + .pi-btw-body { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 12px; + font-size: 13px; + line-height: 1.55; + color: var(--text-soft); + } + .pi-btw-empty { + margin: auto; + text-align: center; + color: var(--muted); + font-size: 12px; + } + .pi-btw-msg { + max-width: 90%; + min-width: 0; + box-sizing: border-box; + padding: 7px 10px; + border-radius: 9px; + overflow-wrap: anywhere; + } + .pi-btw-msg.user { + align-self: flex-end; + background: var(--accent, #8abeb7); + color: var(--body-bg, #1d1f21); + } + .pi-btw-msg.user.pending { opacity: 0.6; } + .pi-btw-msg.assistant { + align-self: flex-start; + background: var(--hover); + color: var(--text); + } + + /* Rendered markdown inside a bubble: tight spacing, readable proportional + text, monospace only for code. */ + .pi-btw-md { min-width: 0; max-width: 100%; } + .pi-btw-md > :first-child { margin-top: 0; } + .pi-btw-md > :last-child { margin-bottom: 0; } + .pi-btw-md p { margin: 0 0 8px; } + .pi-btw-md img, + .pi-btw-md table { max-width: 100%; } + .pi-btw-md ul, + .pi-btw-md ol { margin: 0 0 8px; padding-left: 20px; } + .pi-btw-md li { margin: 2px 0; } + .pi-btw-md h1, + .pi-btw-md h2, + .pi-btw-md h3, + .pi-btw-md h4 { margin: 10px 0 6px; font-size: 1.05em; line-height: 1.3; } + .pi-btw-md code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + padding: 1px 4px; + border-radius: 4px; + background: color-mix(in srgb, var(--text) 12%, transparent); + } + .pi-btw-md pre { + margin: 0 0 8px; + padding: 8px 10px; + overflow-x: auto; + border-radius: 6px; + background: var(--code-bg, rgba(0, 0, 0, 0.28)); + } + .pi-btw-md pre code { padding: 0; background: none; } + .pi-btw-md a { color: var(--link, #8abeb7); } + .pi-btw-md blockquote { + margin: 0 0 8px; + padding-left: 10px; + border-left: 2px solid var(--dim); + color: var(--muted); + } + + /* Tool-call chips and bash lines. */ + .pi-btw-tool { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--muted); + overflow-wrap: anywhere; + } + .pi-btw-tool + .pi-btw-tool { margin-top: 2px; } + + /* Working / streaming indicator (cat runner). */ + .pi-btw-working { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + } + .pi-btw-spinner { + display: inline-block; + text-align: center; + color: var(--accent, #8abeb7); + } + .pi-btw-working-label { font-size: 12px; } + + .pi-btw-input-row { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--dim); + } + .pi-btw-input { + flex: 1 1 auto; + min-width: 0; + box-sizing: border-box; + padding: 8px 10px; + font-family: Georgia, "Times New Roman", serif; + font-size: 14px; + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--dim); + border-radius: 5px; + outline: none; + } + .pi-btw-input::placeholder { color: var(--muted); } + .pi-btw-send { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 14px; + background: transparent; + color: var(--text-soft); + border: 1px solid var(--dim); + border-radius: 5px; + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + } + .pi-btw-send:hover { + color: var(--text); + background: var(--hover); + border-color: var(--borderMuted); + } + .pi-btw-send.cancel { + border-radius: 50%; + color: var(--error, #d77); + border-color: color-mix(in srgb, var(--error, #d77) 55%, var(--dim)); + } + .pi-btw-send.cancel:hover { + color: #fff; + background: var(--error, #d77); + border-color: var(--error, #d77); + } + .pi-chat-shell { width: 100%; max-width: 760px; diff --git a/web/src/session/chat/git-footer.js b/web/src/session/chat/git-footer.js index 3ad2ad3..2fab9d8 100644 --- a/web/src/session/chat/git-footer.js +++ b/web/src/session/chat/git-footer.js @@ -30,6 +30,7 @@ export function setupGitFooter({ const bar = documentImpl.getElementById('pi-git-bar'); if (!bar || !gitApi) return; + const branchWrap = documentImpl.getElementById('pi-git-branch'); const nameEl = documentImpl.getElementById('pi-git-branch-name'); const editBtn = documentImpl.getElementById('pi-git-branch-edit'); const input = documentImpl.getElementById('pi-git-branch-input'); @@ -96,9 +97,14 @@ export function setupGitFooter({ function applyInfo(info) { if (!info || !info.isRepo || !info.branch) { - bar.hidden = true; + // Not a git repo: hide the git controls but keep the bar itself visible, + // since it also hosts the always-available btw button. + show(branchWrap, false); + show(prWrap, false); + bar.hidden = false; return; } + show(branchWrap, true); currentBranch = info.branch; prCreateUrl = info.prCreateUrl || ''; existingPrUrl = info.prUrl || ''; diff --git a/web/src/session/chat/git-footer.test.js b/web/src/session/chat/git-footer.test.js index ef06315..c8878e5 100644 --- a/web/src/session/chat/git-footer.test.js +++ b/web/src/session/chat/git-footer.test.js @@ -40,12 +40,16 @@ afterEach(() => { }); describe('setupGitFooter', () => { - it('stays hidden when the cwd is not a git repo', async () => { + it('hides the git controls but keeps the bar visible (for btw) when the cwd is not a git repo', async () => { dom = createDom(); const gitApi = { getGitInfo: vi.fn().mockResolvedValue({ isRepo: false }) }; setupGitFooter({ documentImpl: document, windowImpl: window, sessionId: 's', gitApi }); await flush(); - expect(id('pi-git-bar').hidden).toBe(true); + // Bar stays visible so the always-available btw button remains reachable. + expect(id('pi-git-bar').hidden).toBe(false); + // Git-specific clusters are hidden. + expect(id('pi-git-branch').hidden).toBe(true); + expect(id('pi-git-pr').hidden).toBe(true); }); it('renders synchronously from data attributes before the async fetch resolves', () => { diff --git a/web/src/session/live/btw-popup.js b/web/src/session/live/btw-popup.js new file mode 100644 index 0000000..de24bfa --- /dev/null +++ b/web/src/session/live/btw-popup.js @@ -0,0 +1,613 @@ +// The "btw" floating window: a draggable, resizable scratch-chat opened from +// the git bar. It talks to a single global pi session ("the btw session") +// persisted server-side in the sqlite app_settings table, so the conversation +// survives reloads AND stays in sync across every device in realtime. The +// window itself is built entirely in JS, so the export snapshot (no +// composer/git bar) never includes it. +// +// Backend contract: +// GET /api/btw -> { sessionId } current btw session (or "") +// POST /api/btw/new {path} -> { id } create + adopt a new btw session +// POST /api/chat?id= (FormData message) send a message +// POST /api/chat/cancel?id= cancel the running turn +// GET /api/worker-status?id= -> { state } idle|running|error +// GET /api/session?id=-> { entries, ... } transcript +// /events?id= SSE: "reload" + "chat-preview" {content,done} +// /events?id=__all__ SSE: "btw-changed" {sessionId} cross-device pointer sync + +import { marked } from 'marked'; +import { safeMarkedParse } from '../render/markdown.js'; +import { formatToolCall } from '../render/session-format.js'; +import { getSpinnerConfig } from './chat-preview.js'; + +const POS_KEY = 'pi-btw:window'; +const GLOBAL_TOPIC = '__all__'; + +export function setupBtwPopup({ + documentImpl = document, + windowImpl = window, + fetchImpl, + EventSourceImpl, + cwd = '', + renderMarkdown, +} = {}) { + const button = documentImpl.getElementById('pi-btw-button'); + if (!button) return null; + + // On phones the floating window and the main chat composer fight for the same + // cramped screen, so we keep them mutually exclusive there. + const isMobile = () => + !!(windowImpl.matchMedia && windowImpl.matchMedia('(hover: none) and (pointer: coarse)').matches); + + const doFetch = fetchImpl || windowImpl.fetch.bind(windowImpl); + const ES = EventSourceImpl || windowImpl.EventSource; + const setInterval = windowImpl.setInterval.bind(windowImpl); + const clearInterval = windowImpl.clearInterval.bind(windowImpl); + const toHtml = + renderMarkdown || + ((text) => { + try { + return safeMarkedParse(String(text == null ? '' : text), { marked }); + } catch (_) { + return escape(text); + } + }); + + let win = null; + let els = null; + let sessionId = ''; + let eventSource = null; // per-session: messages + let globalSource = null; // global: btw pointer changes + let entries = []; + let pendingUser = null; // optimistic user message awaiting canonical reload + let streamingText = ''; // live assistant text from chat-preview + let running = false; // worker is generating + let statusTimer = null; + let spinnerTimer = null; + let spinnerFrame = 0; + let spinnerConfig = getSpinnerConfig(windowImpl); + let lastSentAt = 0; // grace window: a freshly-sent turn shows "running" before + // the worker has actually transitioned out of idle. + + // ── persisted window geometry / open-state ── + function loadGeom() { + try { + const raw = windowImpl.localStorage?.getItem(POS_KEY); + return raw ? JSON.parse(raw) : null; + } catch (_) { + return null; + } + } + function saveGeom(patch) { + try { + const cur = loadGeom() || {}; + windowImpl.localStorage?.setItem(POS_KEY, JSON.stringify({ ...cur, ...patch })); + } catch (_) { + /* localStorage unavailable */ + } + } + + function escape(text) { + const div = documentImpl.createElement('div'); + div.textContent = String(text == null ? '' : text); + return div.innerHTML; + } + + function buildWindow() { + const root = documentImpl.createElement('div'); + root.className = 'pi-btw-window'; + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-label', 'btw'); + root.hidden = true; + + root.innerHTML = ` +
+ btw +
+ + +
+
+
+
+ + +
+ `; + + documentImpl.body.appendChild(root); + + els = { + header: root.querySelector('.pi-btw-header'), + newBtn: root.querySelector('.pi-btw-new'), + closeBtn: root.querySelector('.pi-btw-close'), + body: root.querySelector('#pi-btw-body'), + form: root.querySelector('#pi-btw-form'), + input: root.querySelector('#pi-btw-input'), + send: root.querySelector('#pi-btw-send'), + }; + + els.closeBtn.addEventListener('click', () => close()); + els.newBtn.addEventListener('click', () => startNewSession()); + els.form.addEventListener('submit', (e) => { + e.preventDefault(); + submitMessage(); + }); + els.send.addEventListener('click', () => { + if (running) cancel(); + else submitMessage(); + }); + enableDrag(root, els.header); + persistResize(root); + + const geom = loadGeom(); + if (geom && geom.width) root.style.width = `${geom.width}px`; + if (geom && geom.height) root.style.height = `${geom.height}px`; + + return root; + } + + // ── transcript rendering (markdown + tool calls) ── + function atBottom() { + if (!els) return true; + const b = els.body; + return b.scrollHeight - b.scrollTop - b.clientHeight < 40; + } + function scrollToBottom() { + if (els) els.body.scrollTop = els.body.scrollHeight; + } + + // Render one transcript entry to HTML, or '' to skip it. + function renderEntry(entry) { + if (!entry || entry.type !== 'message' || !entry.message) return ''; + const msg = entry.message; + + if (msg.role === 'user') { + const text = contentText(msg.content).trim(); + if (!text) return ''; + return `
${toHtml(text)}
`; + } + + if (msg.role === 'assistant') { + const parts = []; + const blocks = Array.isArray(msg.content) ? msg.content : []; + blocks.forEach((block) => { + if (block.type === 'text' && block.text && block.text.trim()) { + parts.push(`
${toHtml(block.text)}
`); + } else if (block.type === 'toolCall') { + parts.push( + `
${escape(formatToolCall(block.name, block.arguments || {}))}
` + ); + } + }); + // Plain-string assistant content (older entries) has no blocks. + if (parts.length === 0 && typeof msg.content === 'string' && msg.content.trim()) { + parts.push(`
${toHtml(msg.content)}
`); + } + if (parts.length === 0) return ''; + return `
${parts.join('')}
`; + } + + if (msg.role === 'bashExecution' && msg.command) { + return `
$ ${escape(msg.command)}
`; + } + + return ''; + } + + function contentText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((c) => c && c.type === 'text' && c.text) + .map((c) => c.text) + .join(''); + } + return ''; + } + + function spinnerHtml() { + const frame = spinnerConfig.frames[spinnerFrame % spinnerConfig.frames.length] || ''; + return `${escape( + frame + )}`; + } + + function render() { + if (!els) return; + const stick = atBottom(); + const rows = []; + + entries.forEach((entry) => { + const html = renderEntry(entry); + if (html) rows.push(html); + }); + + if (pendingUser) { + rows.push(`
${toHtml(pendingUser)}
`); + } + + // Working / streaming bubble while the worker is generating. + if (running || streamingText) { + const inner = streamingText + ? `
${toHtml(streamingText)}
` + : `${spinnerHtml()}Working…`; + rows.push(`
${inner}
`); + } + + if (rows.length === 0) { + els.body.innerHTML = sessionId + ? '
No messages yet — say hello.
' + : '
Type a message to start a btw chat, or hit “new”.
'; + } else { + els.body.innerHTML = rows.join(''); + } + + if (stick) scrollToBottom(); + } + + // ── data loading + live updates ── + function loadTranscript() { + if (!sessionId) { + entries = []; + render(); + return Promise.resolve(); + } + return doFetch('/api/session?id=' + encodeURIComponent(sessionId)) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (!data) return; + entries = data.entries || []; + if (pendingUser) { + const arrived = entries.some( + (e) => + e && + e.type === 'message' && + e.message && + e.message.role === 'user' && + contentText(e.message.content).trim() === pendingUser + ); + if (arrived) pendingUser = null; + } + render(); + }) + .catch(() => {}); + } + + function subscribe() { + unsubscribe(); + if (!sessionId || !ES) return; + eventSource = new ES('/events?id=' + encodeURIComponent(sessionId)); + eventSource.onmessage = (e) => { + if (e.data === 'reload') { + streamingText = ''; + loadTranscript(); + refreshStatus(); + } + }; + eventSource.addEventListener('chat-preview', (e) => { + try { + const p = JSON.parse(e.data); + streamingText = p.content || ''; + if (!p.done) setRunning(true); + render(); + } catch (_) { + /* ignore malformed preview */ + } + }); + eventSource.onerror = () => {}; + } + function unsubscribe() { + if (eventSource) { + try { + eventSource.close(); + } catch (_) { + /* already closed */ + } + eventSource = null; + } + } + + // Listen on the global topic so a "new" on another device switches us over. + function subscribeGlobal() { + if (globalSource || !ES) return; + globalSource = new ES('/events?id=' + encodeURIComponent(GLOBAL_TOPIC)); + globalSource.addEventListener('btw-changed', (e) => { + try { + const p = JSON.parse(e.data); + const id = p.sessionId || ''; + if (id !== sessionId) setSession(id); + } catch (_) { + /* ignore */ + } + }); + globalSource.onerror = () => {}; + } + function unsubscribeGlobal() { + if (globalSource) { + try { + globalSource.close(); + } catch (_) { + /* already closed */ + } + globalSource = null; + } + } + + // ── worker running state (spinner + cancel button) ── + function startSpinner() { + if (spinnerTimer) return; + spinnerConfig = getSpinnerConfig(windowImpl); + spinnerTimer = setInterval(() => { + spinnerFrame += 1; + if (!win || win.hidden) return; + const el = win.querySelector('.pi-btw-spinner'); + if (el) { + el.textContent = spinnerConfig.frames[spinnerFrame % spinnerConfig.frames.length] || ''; + } + }, spinnerConfig.interval || 100); + } + function stopSpinner() { + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = null; + } + } + + function setRunning(on) { + const was = running; + running = !!on; + if (els && els.send) { + els.send.textContent = running ? '◼' : '▷'; + els.send.classList.toggle('cancel', running); + els.send.setAttribute('aria-label', running ? 'Cancel' : 'Send'); + els.send.title = running ? 'Stop' : 'Send'; + } + if (running) startSpinner(); + else stopSpinner(); + if (running && !streamingText && !was) render(); + if (!running) { + streamingText = ''; + if (was) render(); + } + } + + function refreshStatus() { + if (!sessionId) return Promise.resolve(); + return doFetch('/api/worker-status?id=' + encodeURIComponent(sessionId)) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (!data) return; + if (data.state === 'running') setRunning(true); + else if (data.state === 'idle') { + // Ignore a transient idle right after sending: the worker may not have + // spun up yet. SSE/the next poll will confirm the real state. + if (Date.now() - lastSentAt > 3000) setRunning(false); + } else if (data.state === 'error') setRunning(false); + }) + .catch(() => {}); + } + + function startStatusPolling() { + if (statusTimer) return; + statusTimer = setInterval(() => refreshStatus(), 1500); + } + function stopStatusPolling() { + if (statusTimer) { + clearInterval(statusTimer); + statusTimer = null; + } + } + + function cancel() { + if (!sessionId) return; + doFetch('/api/chat/cancel?id=' + encodeURIComponent(sessionId), { method: 'POST' }) + .then(() => setRunning(false)) + .catch(() => {}); + } + + // ── actions ── + function setSession(id) { + sessionId = id || ''; + saveGeom({ sessionId }); + entries = []; + pendingUser = null; + streamingText = ''; + setRunning(false); + if (sessionId) { + subscribe(); + loadTranscript(); + refreshStatus(); + } else { + unsubscribe(); + render(); + } + } + + function createSession() { + return doFetch('/api/btw/new', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: cwd }), + }) + .then((r) => r.json()) + .then((data) => { + if (data && data.id) { + setSession(data.id); + return data.id; + } + throw new Error(data && data.error ? data.error : 'failed to create btw session'); + }); + } + + function startNewSession() { + if (els) els.newBtn.disabled = true; + createSession() + .catch(() => {}) + .finally(() => { + if (els) els.newBtn.disabled = false; + if (els) els.input.focus(); + }); + } + + async function submitMessage() { + const message = els.input.value.trim(); + if (!message) return; + + els.input.value = ''; + pendingUser = message; + lastSentAt = Date.now(); + render(); + + try { + if (!sessionId) await createSession(); + // createSession() runs setSession() which clears optimistic state; re-show + // the pending bubble so it stays visible until the canonical reload. + pendingUser = message; + setRunning(true); + render(); + const body = new windowImpl.FormData(); + body.set('message', message); + const resp = await doFetch('/api/chat?id=' + encodeURIComponent(sessionId), { + method: 'POST', + body, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || 'chat request failed'); + } catch (err) { + pendingUser = null; + setRunning(false); + els.input.value = message; + render(); + } + } + + // ── drag (move) + resize persistence ── + function enableDrag(root, handle) { + let dragging = false; + let startX = 0; + let startY = 0; + let originLeft = 0; + let originTop = 0; + + function onMove(e) { + if (!dragging) return; + const vw = windowImpl.innerWidth || 0; + const vh = windowImpl.innerHeight || 0; + const rect = root.getBoundingClientRect(); + const left = Math.max(0, Math.min(originLeft + (e.clientX - startX), vw - rect.width)); + const top = Math.max(0, Math.min(originTop + (e.clientY - startY), vh - rect.height)); + root.style.left = `${left}px`; + root.style.top = `${top}px`; + saveGeom({ left, top }); + } + function onUp() { + dragging = false; + documentImpl.removeEventListener('pointermove', onMove); + documentImpl.removeEventListener('pointerup', onUp); + } + handle.addEventListener('pointerdown', (e) => { + if (e.target && e.target.closest && e.target.closest('.pi-btw-actions')) return; + dragging = true; + const rect = root.getBoundingClientRect(); + originLeft = rect.left; + originTop = rect.top; + startX = e.clientX; + startY = e.clientY; + documentImpl.addEventListener('pointermove', onMove); + documentImpl.addEventListener('pointerup', onUp); + }); + } + + function persistResize(root) { + if (!windowImpl.ResizeObserver) return; + let raf = 0; + const ro = new windowImpl.ResizeObserver(() => { + if (raf) windowImpl.cancelAnimationFrame?.(raf); + raf = windowImpl.requestAnimationFrame + ? windowImpl.requestAnimationFrame(() => { + saveGeom({ width: root.offsetWidth, height: root.offsetHeight }); + }) + : 0; + }); + ro.observe(root); + } + + function placeInitial(root) { + const geom = loadGeom(); + if (geom && typeof geom.left === 'number' && typeof geom.top === 'number') { + root.style.left = `${geom.left}px`; + root.style.top = `${geom.top}px`; + return; + } + const vw = windowImpl.innerWidth || 0; + const vh = windowImpl.innerHeight || 0; + const rect = root.getBoundingClientRect(); + const left = Math.max(0, (vw - rect.width) / 2); + const top = Math.max(0, vh - rect.height - 90); + root.style.left = `${left}px`; + root.style.top = `${top}px`; + saveGeom({ left, top }); + } + + function open() { + if (!win) win = buildWindow(); + win.hidden = false; + placeInitial(win); + button.setAttribute('aria-expanded', 'true'); + saveGeom({ open: true }); + subscribeGlobal(); + startStatusPolling(); + // Sync to the persisted server-side btw session each time we open. + doFetch('/api/btw') + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const id = data && data.sessionId ? data.sessionId : ''; + if (id !== sessionId) setSession(id); + else if (id) { + loadTranscript(); + refreshStatus(); + } else render(); + }) + .catch(() => render()); + if (els) els.input.focus(); + } + + function close() { + if (win) win.hidden = true; + button.setAttribute('aria-expanded', 'false'); + saveGeom({ open: false }); + unsubscribe(); + unsubscribeGlobal(); + stopStatusPolling(); + stopSpinner(); + } + + function toggle() { + if (win && !win.hidden) close(); + else open(); + } + + button.setAttribute('aria-haspopup', 'dialog'); + button.setAttribute('aria-expanded', 'false'); + button.addEventListener('click', (e) => { + e.preventDefault(); + toggle(); + }); + + // On mobile, focusing the main chat composer should never leave the btw + // window covering it — get out of the way. + const composerTextarea = documentImpl.getElementById('pi-chat-message'); + if (composerTextarea) { + composerTextarea.addEventListener('focus', () => { + if (isMobile() && win && !win.hidden) close(); + }); + } + + // Auto-reopen if it was open before a reload, so the chat "comes back" — but + // not on mobile, where it would cover the composer unexpectedly. + const initialGeom = loadGeom(); + if (initialGeom && initialGeom.open && !isMobile()) open(); + + return { open, close, toggle }; +} diff --git a/web/src/session/live/btw-popup.test.js b/web/src/session/live/btw-popup.test.js new file mode 100644 index 0000000..a6bccf8 --- /dev/null +++ b/web/src/session/live/btw-popup.test.js @@ -0,0 +1,282 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { setupBtwPopup } from './btw-popup.js'; + +class FakeEventSource { + constructor(url) { + this.url = url; + this.listeners = {}; + FakeEventSource.instances.push(this); + } + addEventListener(type, fn) { + this.listeners[type] = fn; + } + emit(type, data) { + if (type === 'message' && this.onmessage) this.onmessage({ data }); + else if (this.listeners[type]) this.listeners[type]({ data }); + } + close() { + this.closed = true; + } +} +FakeEventSource.instances = []; + +function makeEnv() { + const dom = new JSDOM('', { url: 'http://localhost/' }); + const win = dom.window; + win.ResizeObserver = class { + observe() {} + disconnect() {} + }; + FakeEventSource.instances = []; + return { dom, win, doc: win.document }; +} + +function jsonResponse(body, ok = true) { + return Promise.resolve({ ok, json: () => Promise.resolve(body) }); +} + +// A fetch router that covers every endpoint the popup touches. +function router(overrides = {}) { + return vi.fn((url, opts) => { + if (url === '/api/btw') return jsonResponse(overrides.btw || { sessionId: '' }); + if (url === '/api/btw/new') return jsonResponse(overrides.new || { id: 'new-sess.jsonl' }); + if (url.startsWith('/api/worker-status')) return jsonResponse(overrides.status || { state: 'idle' }); + if (url.startsWith('/api/session')) return jsonResponse(overrides.session || { entries: [] }); + if (url.startsWith('/api/chat/cancel')) return jsonResponse({ ok: true }); + if (url.startsWith('/api/chat')) { + (overrides.sent || []).push(url); + return jsonResponse({ status: 'queued' }); + } + return jsonResponse({}); + }); +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('btw popup', () => { + beforeEach(() => { + FakeEventSource.instances = []; + }); + + it('returns null when the button is missing', () => { + const dom = new JSDOM(''); + const result = setupBtwPopup({ documentImpl: dom.window.document, windowImpl: dom.window }); + expect(result).toBeNull(); + }); + + it('builds the window with new + close + input on first open', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router(); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + const w = doc.querySelector('.pi-btw-window'); + expect(w).not.toBeNull(); + expect(w.hidden).toBe(false); + expect(w.querySelector('.pi-btw-new')).not.toBeNull(); + expect(w.querySelector('.pi-btw-close')).not.toBeNull(); + expect(w.querySelector('#pi-btw-input')).not.toBeNull(); + expect(fetchImpl).toHaveBeenCalledWith('/api/btw'); + api.close(); + }); + + it('renders the transcript as markdown', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ + btw: { sessionId: 'sess-1.jsonl' }, + session: { + entries: [ + { id: 'a', type: 'message', message: { role: 'user', content: 'hi' } }, + { id: 'b', type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: '**bold** answer' }] } }, + ], + }, + }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + const msgs = doc.querySelectorAll('.pi-btw-msg'); + expect(msgs.length).toBe(2); + expect(msgs[0].textContent.trim()).toBe('hi'); + // Markdown emphasis becomes a . + expect(msgs[1].querySelector('strong')).not.toBeNull(); + expect(msgs[1].textContent).toContain('bold'); + api.close(); + }); + + it('renders tool calls as chips', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ + btw: { sessionId: 'sess-1.jsonl' }, + session: { + entries: [ + { + id: 'a', + type: 'message', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'reading' }, + { type: 'toolCall', id: 't1', name: 'read', arguments: { path: '/repo/foo.go' } }, + ], + }, + }, + ], + }, + }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + const tool = doc.querySelector('.pi-btw-tool'); + expect(tool).not.toBeNull(); + expect(tool.textContent).toContain('read'); + api.close(); + }); + + it('creates a session then sends when none exists yet', async () => { + const { win, doc } = makeEnv(); + const sent = []; + const fetchImpl = router({ new: { id: 'new-sess.jsonl' }, sent }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource, cwd: '/repo/foo' }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + doc.getElementById('pi-btw-input').value = 'do a thing'; + doc.getElementById('pi-btw-form').dispatchEvent(new win.Event('submit')); + await flush(); + + expect(fetchImpl).toHaveBeenCalledWith('/api/btw/new', expect.objectContaining({ method: 'POST' })); + expect(sent[0]).toContain('new-sess.jsonl'); + expect(doc.querySelector('.pi-btw-msg.user')).not.toBeNull(); + api.close(); + }); + + it('new button posts to /api/btw/new with the cwd', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ new: { id: 'fresh.jsonl' } }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource, cwd: '/repo/bar' }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + doc.querySelector('.pi-btw-new').click(); + await flush(); + + const call = fetchImpl.mock.calls.find((c) => c[0] === '/api/btw/new'); + expect(JSON.parse(call[1].body)).toEqual({ path: '/repo/bar' }); + api.close(); + }); + + it('shows a working indicator and toggles the send button to cancel while running', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ btw: { sessionId: 'sess-1.jsonl' } }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + doc.getElementById('pi-btw-input').value = 'go'; + doc.getElementById('pi-btw-form').dispatchEvent(new win.Event('submit')); + await flush(); + + const send = doc.getElementById('pi-btw-send'); + expect(send.classList.contains('cancel')).toBe(true); + expect(doc.querySelector('.pi-btw-msg.working')).not.toBeNull(); + + // Clicking the cancel button cancels the running turn. + send.click(); + await flush(); + expect(fetchImpl.mock.calls.some((c) => String(c[0]).startsWith('/api/chat/cancel'))).toBe(true); + api.close(); + }); + + it('renders streaming assistant text from chat-preview events', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ btw: { sessionId: 'sess-1.jsonl' } }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + const es = FakeEventSource.instances.find((e) => e.url.includes('sess-1.jsonl')); + es.emit('chat-preview', JSON.stringify({ content: 'partial answer', done: false })); + + const streaming = doc.querySelector('.pi-btw-msg.assistant.working .pi-btw-md'); + expect(streaming).not.toBeNull(); + expect(streaming.textContent).toContain('partial answer'); + api.close(); + }); + + it('switches session in realtime on a global btw-changed event', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router({ btw: { sessionId: 'sess-1.jsonl' } }); + const api = setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + + const globalES = FakeEventSource.instances.find((e) => e.url.includes('__all__')); + expect(globalES).toBeTruthy(); + globalES.emit('btw-changed', JSON.stringify({ sessionId: 'sess-2.jsonl' })); + await flush(); + + // It should now be subscribed to the new session's events. + expect(FakeEventSource.instances.some((e) => e.url.includes('sess-2.jsonl'))).toBe(true); + api.close(); + }); + + it('closes when the main composer is focused on mobile', async () => { + const dom = new JSDOM( + '', + { url: 'http://localhost/' } + ); + const win = dom.window; + const doc = win.document; + win.ResizeObserver = class { observe() {} disconnect() {} }; + win.matchMedia = () => ({ matches: true }); // simulate a touch device + FakeEventSource.instances = []; + const fetchImpl = router(); + setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + + doc.getElementById('pi-btw-button').click(); + await flush(); + expect(doc.querySelector('.pi-btw-window').hidden).toBe(false); + + doc.getElementById('pi-chat-message').dispatchEvent(new win.FocusEvent('focus')); + expect(doc.querySelector('.pi-btw-window').hidden).toBe(true); + }); + + it('does not auto-reopen on mobile even if it was open before', async () => { + const dom = new JSDOM('', { url: 'http://localhost/' }); + const win = dom.window; + const doc = win.document; + win.ResizeObserver = class { observe() {} disconnect() {} }; + win.matchMedia = () => ({ matches: true }); + win.localStorage.setItem('pi-btw:window', JSON.stringify({ open: true })); + FakeEventSource.instances = []; + setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl: router(), EventSourceImpl: FakeEventSource }); + + // No window should have been built/opened on a touch device. + expect(doc.querySelector('.pi-btw-window')).toBeNull(); + }); + + it('toggles closed on a second button click', async () => { + const { win, doc } = makeEnv(); + const fetchImpl = router(); + setupBtwPopup({ documentImpl: doc, windowImpl: win, fetchImpl, EventSourceImpl: FakeEventSource }); + const btn = doc.getElementById('pi-btw-button'); + + btn.click(); + await flush(); + btn.click(); + + expect(doc.querySelector('.pi-btw-window').hidden).toBe(true); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/web/src/session/session.js b/web/src/session/session.js index b4ba1cd..5b9fdb2 100644 --- a/web/src/session/session.js +++ b/web/src/session/session.js @@ -18,6 +18,7 @@ import * as doneNotifier from './chat/done-notifier.js'; import * as chatApi from './chat/chat-api.js'; import * as gitApi from './chat/git-api.js'; import { setupGitFooter } from './chat/git-footer.js'; +import { setupBtwPopup } from './live/btw-popup.js'; import * as chatSelectors from './chat/chat-selectors.js'; import * as thinkingSelector from './chat/thinking-selector.js'; import * as modelSelector from './chat/model-selector.js'; @@ -426,6 +427,14 @@ export function runSessionApp({ target = window } = {}) { }); } + const newSessionHeaderBtn = documentImpl.getElementById('new-session-header-btn'); + if (newSessionHeaderBtn) { + newSessionHeaderBtn.addEventListener('click', (e) => { + e.stopPropagation(); + documentImpl.getElementById('new-btn')?.click(); + }); + } + // Initialize chat after live reload so the optimistic "message sent" event // has a listener before the user can submit. Otherwise cold-start sends can // clear/disable the composer without rendering the pending message preview. @@ -457,6 +466,8 @@ export function runSessionApp({ target = window } = {}) { gitApi }); + setupBtwPopup({ documentImpl, windowImpl: target, cwd: dataModel.header?.cwd || '' }); + // Handle Visual Viewport changes to prevent mobile browsers from shifting // the top fixed header out of view when the virtual keyboard is open. if (target.visualViewport) { From cb0ded215c65ae8b5d7521a280596cf153f5ebc8 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 1 Jun 2026 22:28:35 +0700 Subject: [PATCH 6/9] fix(session): hide branch cluster on non-git cwd in btw action bar The .pi-git-branch rule sets display:inline-flex, which overrode the [hidden] attribute (equal specificity, author rule wins), so the branch icon + rename pencil stayed visible in non-git directories even though the hidden attribute was set. Add a .pi-git-branch[hidden] override, matching the existing pattern used for .pi-git-bar, .pi-git-caret, etc. --- internal/ui/live_templates/styles/session.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index 8d20452..5fd6f14 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -2310,6 +2310,9 @@ min-width: 0; color: var(--muted); } + /* display:inline-flex above would otherwise defeat the [hidden] attribute + (equal specificity, author rule wins) — restore hiding for non-git cwds. */ + .pi-git-branch[hidden] { display: none; } .pi-git-icon { width: 13px; height: 13px; From e40c5f796dfff802380d40277fc12798b5db7c37 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 1 Jun 2026 22:35:54 +0700 Subject: [PATCH 7/9] fix(session): align the three bottom bars to a shared height The left tree-status, middle git/btw action bar, and right scratchpad footer each had different paddings/heights, so their top divider lines landed at different vertical positions across the columns. Give all three a shared --session-footer-h with vertically-centered, border-box content so the bottom divider reads as one continuous line. --- internal/ui/live_templates/styles/session.css | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index 5fd6f14..8098837 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -418,6 +418,9 @@ --right-sidebar-width: 320px; --right-sidebar-min-width: 240px; --right-sidebar-max-width: 640px; + /* Shared height of the three bottom bars (left tree status, middle git/btw + action bar, right scratchpad footer) so their top divider lines align. */ + --session-footer-h: 34px; } * { @@ -623,7 +626,9 @@ } .right-sidebar-footer { - padding: 6px 12px; + min-height: var(--session-footer-h); + box-sizing: border-box; + padding: 0 12px; border-top: 1px solid var(--dim); flex-shrink: 0; display: flex; @@ -829,7 +834,11 @@ } .tree-status { - padding: 4px 12px; + display: flex; + align-items: center; + min-height: var(--session-footer-h); + box-sizing: border-box; + padding: 0 12px; font-size: 10px; color: var(--muted); flex-shrink: 0; @@ -2296,7 +2305,8 @@ align-items: center; justify-content: space-between; gap: 10px; - padding: 9px 16px calc(9px + env(safe-area-inset-bottom)); + min-height: var(--session-footer-h); + padding: 0 16px env(safe-area-inset-bottom); font-size: 11px; background: var(--chrome-bg); border-top: 1px solid var(--dim); From 69da8e98a0fbd127a308f23585e1864cd2c64894 Mon Sep 17 00:00:00 2001 From: setkyar Date: Mon, 1 Jun 2026 23:15:48 +0700 Subject: [PATCH 8/9] feat(session): add header tree toggle and new-session button Move the tree-collapse and new-session controls into the session header, add a sidebar close button for mobile, and tweak user-message backgrounds for nord/dracula themes. --- internal/ui/live_menu.go | 1 - .../ui/live_templates/export/app/80-ui.js | 5 ++ internal/ui/live_templates/session.html | 14 +++++- internal/ui/live_templates/styles/session.css | 46 +++++++++++++++++-- internal/ui/live_templates/styles/theme.css | 4 +- web/src/session/ui/sidebar.js | 22 +++++++++ web/src/session/ui/sidebar.test.js | 32 +++++++++++++ 7 files changed, 115 insertions(+), 9 deletions(-) diff --git a/internal/ui/live_menu.go b/internal/ui/live_menu.go index 3611500..befc179 100644 --- a/internal/ui/live_menu.go +++ b/internal/ui/live_menu.go @@ -94,7 +94,6 @@ func sessionMenuHTML(id, class, bodyClass, itemClass, toggleID, themeIconClass, ItemClass: itemClass, Sections: []liveMenuSection{ {Title: "Session", Items: []liveMenuItem{ - {Label: "New Session", Suffix: template.HTML("⌘T"), Attrs: `data-action="new-session"`}, {Label: "Search Sessions", Suffix: template.HTML("⌘K"), Attrs: `data-action="list-sessions"`}, }}, {Items: []liveMenuItem{ diff --git a/internal/ui/live_templates/export/app/80-ui.js b/internal/ui/live_templates/export/app/80-ui.js index 8e1b056..d7d36d2 100644 --- a/internal/ui/live_templates/export/app/80-ui.js +++ b/internal/ui/live_templates/export/app/80-ui.js @@ -292,6 +292,11 @@ function setupSidebarCollapse() { if (hideSidebarBtn) { hideSidebarBtn.addEventListener('click', closeSidebar); } + + const sidebarCloseBtn = document.getElementById('sidebar-close'); + if (sidebarCloseBtn) { + sidebarCloseBtn.addEventListener('click', () => setSidebarOpen(false)); + } } setupSidebarCollapse(); diff --git a/internal/ui/live_templates/session.html b/internal/ui/live_templates/session.html index 7e10840..92a4ec6 100644 --- a/internal/ui/live_templates/session.html +++ b/internal/ui/live_templates/session.html @@ -13,9 +13,20 @@
- Sessions +
+ Sessions + +
{{.Title}}
+ +