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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ pi-sessions-viewer
/pi-web
web/node_modules/
web/dist/
web/dist-export/
internal/ui/live_templates/export/export.js
.pi/extensions/node_modules/
.pi/APPEND_SYSTEM.md

Expand Down
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Before making structural changes, read the relevant doc in `docs/`:
| Working on export/share | `docs/sequence-flows/share.md` |
| Writing or debugging E2E / browser tests | `docs/dev/e2e-testing.md` |

The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** — it explains the unified rendering where `web/` provides the live Vite app, and `internal/ui/live_templates/` provides the Go-embedded shells and consolidated `export/` JS.
The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** — it explains the unified rendering where `web/` provides the live Vite app, and `internal/ui/live_templates/` provides the Go-embedded shells. The static export snapshot bundle is built by Vite from `web/src/export/export-entry.js`, which **reuses the live session modules** in `web/src/session/` — there is no longer a hand-maintained parallel copy.

## Tech Stack

Expand Down Expand Up @@ -46,14 +46,15 @@ The most important doc for frontend work is **`docs/dev/templates-vs-web.md`**
| `web/src/session/annotations/` | Inline review annotations (offset-anchored highlights, Annotations tab, send-to-pi); synced via the `annotations` SSE event |
| `web/src/shared/` | API helpers, escape, storage, status events |
| `internal/ui/live_templates/` | Go-embedded HTML shells for index/session pages |
| `internal/ui/live_templates/export/` | Self-contained JS/vendor scripts for static Gist snapshots (no server, no chat, no SSE) |
| `web/src/export/` | Static-snapshot Vite entry (`export-entry.js`) — reuses `web/src/session/` rendering modules, omits all live/chat/SSE code |
| `internal/ui/live_templates/export/` | Built snapshot bundle (`export.js`, generated by `npm run build:export`) + inlined `vendor/` scripts for Gist snapshots (no server, no chat, no SSE) |

### Key Files
- `cmd/pi-web/main.go` — tiny CLI entrypoint and build-time version variable
- `internal/app/app.go` — CLI flags, Tailscale auto-detect, dependency wiring
- `internal/frontend/assets.go` + `web/assets_embed.go` — Vite output embedding, manifest parsing, static asset serving
- `internal/ui/session_page.go` — **Live session page** rendering (`internal/ui/live_templates/session.html`, chat composer)
- `internal/ui/export.go` — **Export/share snapshot** rendering (using `internal/ui/live_templates/session.html`, inlined JS, no server deps)
- `internal/ui/export.go` — **Export/share snapshot** rendering (using `internal/ui/live_templates/session.html`, inlines the built `export.js` + `vendor/`, no server deps)
- `internal/ui/live_templates/styles/session.css` — Live session & export CSS
- `.pi/extensions/pi-web.ts` — Pi extension with `/pi-web`, `/pi-web token`, `/pi-web set-token`, `/remote`, `/refresh` commands

Expand All @@ -63,7 +64,7 @@ The most important doc for frontend work is **`docs/dev/templates-vs-web.md`**
|---|---|---|
| Go file | `internal/ui/session_page.go` | `internal/ui/export.go` |
| HTML shell | `internal/ui/live_templates/session.html` (`IsLive: true`) | `internal/ui/live_templates/session.html` (`IsLive: false`) |
| JS source | `web/src/session/` (Vite) | `internal/ui/live_templates/export/app/*.js` + `vendor/` |
| JS source | `web/src/session/` (Vite) | `web/src/export/export-entry.js` (reuses `web/src/session/`), built → `internal/ui/live_templates/export/export.js` + `vendor/` |
| CSS | `internal/ui/live_templates/styles/session.css` | `internal/ui/live_templates/styles/session.css` |
| Chat composer | Yes (`internal/ui/live_templates/chat_composer.html`) | No |
| Action buttons | Yes (baked into `internal/ui/live_templates/session.html`) | No |
Expand All @@ -84,7 +85,7 @@ make e2e-setup # one-time: install e2e deps + Playwright browsers
make e2e # build binary + run Playwright E2E (not part of test/check)
```

**Critical:** `go build ./cmd/pi-web` requires `web/dist` to exist first because of `//go:embed`. Always run `make build`, never `go build` alone.
**Critical:** `go build ./cmd/pi-web` requires `web/dist` **and** `internal/ui/live_templates/export/export.js` to exist first because of `//go:embed`. Both are generated by the frontend build (`npm run build` runs the live build then `build:export`). Always run `make build`, never `go build` alone.

### Local development — do NOT rely on a launchd agent

Expand All @@ -99,13 +100,13 @@ For development, start pi-web with `make dev` (Vite watcher + Go hot-reload) or
## Coding Standards

- **Go:** Small focused packages; `internal/server` is the HTTP glue exception. Avoid global state — `internal/app/app.go` wires `server.New(server.Deps{...})`. Use sentinel errors. `WriteTimeout` stays 0 for SSE.
- **JS:** ES modules. Explicit DI (`documentImpl`, `windowImpl`) over globals. Keep `internal/ui/live_templates/` manually in sync with `web/src/session/live/` changes.
- **JS:** ES modules. Explicit DI (`documentImpl`, `windowImpl`) over globals. The export snapshot reuses the live `web/src/session/` modules via `web/src/export/export-entry.js` and is rebuilt by Vite — no manual copy to keep in sync. Keep rendering modules side-effect-free on import and DI-pure so they stay safe to bundle into the server-less export.
- **CSS:** both live styling and export styling are in `internal/ui/live_templates/styles/session.css`. Keep visual changes clean and unified.

## Critical Rules

1. **Live and export use a unified template.** `internal/ui/live_templates/session.html` serves both the live app and Gist snapshots. Do not split them.
2. **Always keep `internal/ui/live_templates/` in sync** with `web/src/session/ui/` changes when styling or structures shift.
2. **Export reuses the live source.** The static snapshot is built from `web/src/export/export-entry.js`, which imports the same `web/src/session/` rendering modules as the live app — fix rendering bugs once. Do not reintroduce a hand-maintained `export/app/*.js` copy. A guard test (`TestExportBundleIsSelfContained`) fails if a live-only module (SSE/chat) leaks into the export bundle.
3. **Existing session data is append-only for `session_info`.** Browser chat goes to a `pi --mode rpc` worker, which writes conversation entries. pi-web otherwise watches and broadcasts; its only direct writes to existing session files are appending `session_info` — for browser rename, and for auto-titling (marked `autoTitle:true`, see `internal/server/auto_title.go`). New-session creation may write initial implicit `model_change` / `thinking_level_change` entries in the fresh file.
4. **One worker per session.** Reused for subsequent messages. Crashed = evicted + replaced. Idle workers reaped after 10 min.
5. **SSE topics:** `globalSessID = "__all__"` for index-wide events; session ID for per-session events.
Expand Down
7 changes: 7 additions & 0 deletions e2e/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export async function startServer(): Promise<StartedServer> {
PATH: `${STUB_PI_DIR}:${process.env.PATH ?? ""}`,
// Ensure auth is off for tests regardless of the dev's shell env.
PI_WEB_TOKEN: "",
// Lower the large-session truncation thresholds so the load-earlier spec
// can exercise pagination with a ~150-entry session instead of rendering
// thousands of messages (which flaked under parallel CPU contention).
// Comfortably above every other spec's session size (max ~34 entries).
// Keep in sync with tests/load-earlier.spec.ts.
PI_WEB_LARGE_SESSION_THRESHOLD: "100",
PI_WEB_LARGE_SESSION_TAIL_ENTRIES: "50",
},
stdio: ["ignore", "pipe", "pipe"],
});
Expand Down
46 changes: 33 additions & 13 deletions e2e/tests/load-earlier.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { test, expect, collapseScratchpad } from "../lib/test";
import { uniqueSessionName, writeSession } from "../lib/sessions";

// Build a session large enough to cross the server-side truncation threshold
// (internal/ui/session_page.go: LargeSessionThreshold = 1500). The initial HTML
// render then embeds only the tail (LargeSessionTailEntries = 1000) and the
// frontend shows a "Load earlier" banner that lazily fetches preceding windows
// via /api/session?id=...&from=N&count=K.
const MESSAGE_COUNT = 1600; // + 1 header => 1601 entries, > 1500 threshold
const EARLY_INDEX = 5; // an early message, well outside the embedded tail
// Build a session large enough to cross the server-side truncation threshold.
// The e2e server lowers that threshold via env vars (lib/server.ts:
// PI_WEB_LARGE_SESSION_THRESHOLD=100, PI_WEB_LARGE_SESSION_TAIL_ENTRIES=50) so
// this spec exercises the exact pagination path with a small session that
// renders instantly — earlier it used 1600 messages and flaked under parallel
// CPU contention because each load re-renders the whole conversation. Keep
// MESSAGE_COUNT above the threshold and EARLY_INDEX outside the embedded tail.
const MESSAGE_COUNT = 150; // + 1 header => 151 entries, > 100 threshold
const EARLY_INDEX = 5; // an early message, well outside the embedded tail (50)
const EARLY_MARKER = "EARLY_MARKER_LOADME";

function buildLargeSession(): unknown[] {
Expand Down Expand Up @@ -37,6 +39,18 @@ function buildLargeSession(): unknown[] {
}

test.describe("load-earlier banner (large session pagination)", () => {
// This test does a user-triggered mid-flight fetch + re-render, so it's the
// canary for transient resource starvation during the full parallel matrix:
// 8+ browsers, the Node runner, and one shared pi-web server all competing for
// CPU can delay even the poll's own execution past a fixed timeout, despite the
// session being tiny. Two mitigations, both needed:
// 1. A small session (env-lowered thresholds) so the work itself is cheap.
// 2. Per-test retries to absorb rare contention spikes — a real regression
// still fails every attempt, so this hides timing flakes, not bugs.
test.describe.configure({ retries: 2 });

const WINDOW_TIMEOUT = 15_000;

test("truncated session loads earlier windows on demand", async ({
page,
sessionsDir,
Expand All @@ -51,7 +65,7 @@ test.describe("load-earlier banner (large session pagination)", () => {
await page.goto(`/session?id=${encodeURIComponent(id)}`);

const banner = page.locator("#load-earlier-banner");
await expect(banner).toBeVisible();
await expect(banner).toBeVisible({ timeout: WINDOW_TIMEOUT });
await expect(banner).toContainText(/Showing latest .* of .* messages/);

// The early message is outside the embedded tail, so it is not rendered yet.
Expand All @@ -61,20 +75,26 @@ test.describe("load-earlier banner (large session pagination)", () => {
// removes itself once `from` reaches 0 (load-earlier.js).
const button = banner.getByRole("button");
for (let i = 0; i < 6 && (await banner.count()) > 0; i += 1) {
await expect(button).toBeEnabled();
await expect(button).toBeEnabled({ timeout: WINDOW_TIMEOUT });
await button.click();
// Either the banner is gone, or it re-enabled for the next window.
await expect
.poll(async () =>
(await banner.count()) === 0 || (await button.isEnabled()),
.poll(
async () =>
(await banner.count()) === 0 || (await button.isEnabled()),
{ timeout: WINDOW_TIMEOUT },
)
.toBe(true);
}

await expect(page.locator("#load-earlier-banner")).toHaveCount(0);
await expect(page.locator("#load-earlier-banner")).toHaveCount(0, {
timeout: WINDOW_TIMEOUT,
});

// After all earlier windows load, the earliest message must actually be
// rendered in the conversation view — not just merged into the data model.
await expect(page.locator("#messages")).toContainText(EARLY_MARKER);
await expect(page.locator("#messages")).toContainText(EARLY_MARKER, {
timeout: WINDOW_TIMEOUT,
});
});
});
55 changes: 53 additions & 2 deletions e2e/tests/share.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,63 @@
import { test, expect } from "../lib/test";
import {
assistantTextEntry,
buildSession,
uniqueSessionName,
writeSession,
} from "../lib/sessions";

// NOTE: actually creating a share creates a GitHub Gist via the `gh` CLI
// (external + network + side effects), so we never trigger a real share here.
// We assert the live-only Share affordance exists and the endpoint contract,
// stopping before any gist is created. Export-HTML generation is covered by Go
// unit tests (internal/ui/export.go).
// and we render the snapshot via the local preview mode (?preview=1, no gh) to
// exercise the real exported HTML without network.

test.describe("share / export", () => {
test("exported snapshot renders inside a sandboxed iframe", async ({
page,
request,
sessionsDir,
}, testInfo) => {
// A real Gist preview loads the export HTML in a sandboxed iframe WITHOUT
// `allow-same-origin`, where even *reading* window.localStorage throws
// SecurityError. That previously crashed the bootstrap and blanked the page.
// Reproduce it: fetch the self-contained snapshot (preview mode skips gh)
// and load it into exactly such an iframe, asserting the conversation
// actually renders. Regression guard for the export bundle.
const MARKER = "SANDBOX_RENDER_MARKER";
const { entries, lastId } = buildSession();
const { entry } = assistantTextEntry(lastId, MARKER);
entries.push(entry);
const id = writeSession(
sessionsDir,
uniqueSessionName(testInfo, "share"),
entries,
);

const res = await request.get(
`/share?id=${encodeURIComponent(id)}&preview=1`,
);
expect(res.ok()).toBeTruthy();
expect(res.headers()["content-type"]).toContain("text/html");
const html = await res.text();

await page.setContent(
`<iframe id="snap" sandbox="allow-scripts" style="width:100%;height:90vh;border:0"></iframe>`,
);
// Set srcdoc as a property to avoid HTML-attribute escaping of the full doc.
await page.evaluate((doc) => {
(document.getElementById("snap") as HTMLIFrameElement).srcdoc = doc;
}, html);

// If the bootstrap crashed on localStorage access, #messages stays empty
// and this times out — which is exactly the bug this guards against.
const frame = page.frameLocator("#snap");
await expect(frame.locator("#messages")).toContainText(MARKER, {
timeout: 15_000,
});
await expect(frame.locator("#messages")).toContainText("Initial reply.");
});

test("live session page exposes the Share action", async ({ page }) => {
await page.goto("/");
await page.locator(".session-card", { hasText: "add deepseek-v4-pro" }).click();
Expand Down
30 changes: 30 additions & 0 deletions internal/server/share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,36 @@ func TestHandleShareRejectsGet(t *testing.T) {
}
}

func TestHandleSharePreviewReturnsHTMLWithoutGist(t *testing.T) {
runner := &fakeShareRunner{}
s, _ := newShareTestServer(t, runner)
req := httptest.NewRequest(http.MethodGet, "/share?id=session.jsonl&preview=1", nil)
rec := httptest.NewRecorder()
s.handleShare(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %q", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("content-type = %q, want text/html", ct)
}
if !strings.Contains(rec.Body.String(), "<html>") {
t.Fatalf("body missing rendered export HTML: %q", rec.Body.String())
}
if runner.createCalled {
t.Fatal("preview must not create a gist")
}
}

func TestHandleSharePreviewRequiresID(t *testing.T) {
s, _ := newShareTestServer(t, &fakeShareRunner{})
req := httptest.NewRequest(http.MethodGet, "/share?preview=1", nil)
rec := httptest.NewRecorder()
s.handleShare(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}

func TestHandleShareRequiresID(t *testing.T) {
s, _ := newShareTestServer(t, &fakeShareRunner{})
req := httptest.NewRequest(http.MethodPost, "/share", nil)
Expand Down
59 changes: 46 additions & 13 deletions internal/share/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,35 @@ type Dependencies struct {
}

func Handle(w http.ResponseWriter, r *http.Request, deps Dependencies) {
id := r.URL.Query().Get("id")

// Local preview: render and return the export HTML directly, skipping the
// GitHub gist round-trip entirely. Lets you eyeball a snapshot before
// sharing, and gives tests a network-free way to load the real exported page
// (e.g. inside a sandboxed iframe). GET-only; no gh required.
if r.URL.Query().Get("preview") == "1" {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if id == "" {
writeJSONError(w, http.StatusBadRequest, "missing id")
return
}
html, ok := renderExportHTML(w, r, deps, id)
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
return
}

if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}

id := r.URL.Query().Get("id")
if id == "" {
writeJSONError(w, http.StatusBadRequest, "missing id")
return
Expand All @@ -90,18 +113,8 @@ func Handle(w http.ResponseWriter, r *http.Request, deps Dependencies) {
return
}

resolved, err := deps.Resolve(id)
if err != nil {
writeJSONError(w, http.StatusNotFound, "session not found")
return
}
theme := "dark"
if cookie, err := r.Cookie("pi-web-theme"); err == nil {
theme = cookie.Value
}
html := deps.RenderExport(resolved, theme)
if html == "" {
writeJSONError(w, http.StatusNotFound, "session not found")
html, ok := renderExportHTML(w, r, deps, id)
if !ok {
return
}

Expand Down Expand Up @@ -136,6 +149,26 @@ func Handle(w http.ResponseWriter, r *http.Request, deps Dependencies) {
})
}

// renderExportHTML resolves the session and renders its self-contained export
// snapshot. On any failure it writes the JSON error response and returns ok=false.
func renderExportHTML(w http.ResponseWriter, r *http.Request, deps Dependencies, id string) (string, bool) {
resolved, err := deps.Resolve(id)
if err != nil {
writeJSONError(w, http.StatusNotFound, "session not found")
return "", false
}
theme := "dark"
if cookie, err := r.Cookie("pi-web-theme"); err == nil {
theme = cookie.Value
}
html := deps.RenderExport(resolved, theme)
if html == "" {
writeJSONError(w, http.StatusNotFound, "session not found")
return "", false
}
return html, true
}

func writeJSONError(w http.ResponseWriter, status int, message string) {
render.WriteJSONError(w, status, message)
}
Loading
Loading