Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,9 +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` — 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, 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`

Expand Down Expand Up @@ -208,6 +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 + 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 |
Expand Down
26 changes: 24 additions & 2 deletions docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) │
Expand Down Expand Up @@ -127,14 +128,35 @@ 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
├── vapid.json ← web-push VAPID keys (when push enabled)
└── push-subs.json ← web-push subscriptions (when push enabled)
```

## 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) 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

1. Parse CLI flags (`-p`, `-host`, `-o`, `-insecure`, `-version`)
Expand Down
106 changes: 106 additions & 0 deletions internal/server/btw.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package server

import (
"context"
"encoding/json"
"net/http"
"os"

"pi-web/internal/sessions"
)

// settingBtwSessionID is the app_settings key holding the id of the single,
// global "btw" scratch-chat session surfaced in the floating btw window.
const settingBtwSessionID = "btw_session_id"

func (s *Server) getBtwSessionID() string {
if s.db == nil {
return ""
}
var v string
if err := s.db.QueryRow("SELECT value FROM app_settings WHERE key = ?", settingBtwSessionID).Scan(&v); err != nil {
return ""
}
return v
}

func (s *Server) setBtwSessionID(id string) {
if s.db == nil {
return
}
prev := s.getBtwSessionID()
_, _ = s.db.Exec(`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, settingBtwSessionID, id)
// Notify every connected client (on any device) so an open btw window can
// switch to the new session in realtime. Only when it actually changed.
if id != prev {
s.broadcastBtwChanged(id)
}
}

// broadcastBtwChanged tells all clients which session is now the global btw
// session. Sent on the global topic so any open btw window re-syncs even if it
// is currently subscribed to a different (or no) session.
func (s *Server) broadcastBtwChanged(id string) {
msg, err := formatSSEJSONEvent("btw-changed", map[string]string{"sessionId": id})
if err != nil {
return
}
s.broadcast(globalSessID, msg)
}

// handleGetBtw returns the current global btw session id, clearing the stored
// pointer if the session file has since been deleted so the client can fall
// back to its empty state.
func (s *Server) handleGetBtw(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := s.getBtwSessionID()
if id != "" {
if _, err := sessions.ResolveByID(s.sessionsDir, id); err != nil {
id = ""
s.setBtwSessionID("")
}
}
writeJSON(w, http.StatusOK, map[string]any{"sessionId": id})
}

// handleNewBtw creates a fresh session, records it as the global btw session,
// and returns its id. The path defaults to the user's home directory when the
// caller does not supply one (e.g. the originating page had no cwd).
func (s *Server) handleNewBtw(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"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json body")
return
}
path := body.Path
if path == "" {
path, _ = os.UserHomeDir()
}

id, err := sessions.CreateSessionFileWithSettings(s.sessionsDir, path, sessions.InitialSettings{})
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
s.setBtwSessionID(id)

// Pre-warm a worker so the first chat message lands quickly, mirroring
// handleNewSession.
if s.chatSender != nil {
if resolved, err := sessions.ResolveByID(s.sessionsDir, id); err == nil {
go s.initializeNewSessionWorker(context.Background(), resolved.Session.ID, resolved.Path, sessions.InitialSettings{})
}
}

writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
}
128 changes: 128 additions & 0 deletions internal/server/btw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package server

import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

_ "modernc.org/sqlite"
)

func newAppSettingsDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
if _, err := db.Exec(appSettingsSchema); err != nil {
t.Fatalf("failed to create app_settings table: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}

func TestBtwSessionIDRoundTrip(t *testing.T) {
db := newAppSettingsDB(t)
s := &Server{db: db}

if got := s.getBtwSessionID(); got != "" {
t.Fatalf("expected empty id initially, got %q", got)
}
s.setBtwSessionID("abc.jsonl")
if got := s.getBtwSessionID(); got != "abc.jsonl" {
t.Fatalf("expected 'abc.jsonl', got %q", got)
}
// Upsert overwrites.
s.setBtwSessionID("def.jsonl")
if got := s.getBtwSessionID(); got != "def.jsonl" {
t.Fatalf("expected 'def.jsonl', got %q", got)
}
}

func TestHandleNewBtwThenGet(t *testing.T) {
db := newAppSettingsDB(t)
dir := t.TempDir()
s := &Server{db: db, sessionsDir: dir}

// Create a new btw session rooted at a real directory.
body := bytes.NewBufferString(`{"path":"` + dir + `"}`)
req := httptest.NewRequest(http.MethodPost, "/api/btw/new", body)
w := httptest.NewRecorder()
s.handleNewBtw(w, req)

if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var created map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
id, _ := created["id"].(string)
if id == "" {
t.Fatalf("expected a new session id, got %v", created)
}
if stored := s.getBtwSessionID(); stored != id {
t.Fatalf("expected stored btw id %q, got %q", id, stored)
}

// GET returns the same id, since the session file now exists.
greq := httptest.NewRequest(http.MethodGet, "/api/btw", nil)
gw := httptest.NewRecorder()
s.handleGetBtw(gw, greq)
if gw.Code != http.StatusOK {
t.Fatalf("expected 200 OK on get, got %d", gw.Code)
}
var got map[string]any
if err := json.Unmarshal(gw.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["sessionId"] != id {
t.Fatalf("expected sessionId %q, got %v", id, got["sessionId"])
}
}

func TestHandleGetBtwClearsStalePointer(t *testing.T) {
db := newAppSettingsDB(t)
s := &Server{db: db, sessionsDir: t.TempDir()}

// Point at a session that does not exist on disk.
s.setBtwSessionID("2026-01-01T00-00-00.000Z_deadbeef.jsonl")

req := httptest.NewRequest(http.MethodGet, "/api/btw", nil)
w := httptest.NewRecorder()
s.handleGetBtw(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", w.Code)
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["sessionId"] != "" {
t.Fatalf("expected empty sessionId for stale pointer, got %v", got["sessionId"])
}
if stored := s.getBtwSessionID(); stored != "" {
t.Fatalf("expected stale pointer cleared, still have %q", stored)
}
}

func TestHandleBtwMethodGuards(t *testing.T) {
s := &Server{db: newAppSettingsDB(t), sessionsDir: t.TempDir()}

// GET handler rejects POST.
w := httptest.NewRecorder()
s.handleGetBtw(w, httptest.NewRequest(http.MethodPost, "/api/btw", nil))
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for POST on GET handler, got %d", w.Code)
}

// new handler rejects GET.
w2 := httptest.NewRecorder()
s.handleNewBtw(w2, httptest.NewRequest(http.MethodGet, "/api/btw/new", nil))
if w2.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for GET on new handler, got %d", w2.Code)
}
}
3 changes: 3 additions & 0 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -169,6 +170,8 @@ func (s *Server) handleApiSessions(w http.ResponseWriter, r *http.Request) {
}
}
summaries = filtered
} else {
summaries = s.filterEnabledSummaries(summaries)
}
sessions.SortSummariesByActivity(summaries)

Expand Down
Loading
Loading